mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 03:38:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39c39ad4bf | ||
|
|
1ad3822421 | ||
|
|
0929787398 | ||
|
|
756016a433 | ||
|
|
c6e3c332ac | ||
|
|
7c95a6b4b2 | ||
|
|
16d234b11d | ||
|
|
70066fc8be | ||
|
|
d27691adef | ||
|
|
97d64f4054 | ||
|
|
1fe8f24313 | ||
|
|
790231ea18 | ||
|
|
1f24d00ee9 | ||
|
|
3f8ffac11c | ||
|
|
137bee4db0 | ||
|
|
5484dd6074 | ||
|
|
8fd3fe0a3a | ||
|
|
ce93a92dc8 | ||
|
|
3f4ed25bb9 | ||
|
|
02b54ac216 | ||
|
|
4b44fecc69 | ||
|
|
1c082665e8 | ||
|
|
7cec953e9a | ||
|
|
868178fb37 | ||
|
|
8439f4fbc9 | ||
|
|
78644d299f | ||
|
|
0d0134dae5 | ||
|
|
b03d31561c | ||
|
|
0e85408741 | ||
|
|
4107b7e611 | ||
|
|
6cbddf5b8e | ||
|
|
a3f4136ac8 | ||
|
|
6926209e53 | ||
|
|
efc97f3bdb | ||
|
|
f72cff08a1 | ||
|
|
7b98b074f4 | ||
|
|
40b02895f7 | ||
|
|
1f4c1edd99 | ||
|
|
2f320aea43 | ||
|
|
f41696701e | ||
|
|
a9d693d867 |
No files matched your search
@@ -27,6 +27,9 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-mise
|
||||
|
||||
- name: Install eas-cli
|
||||
run: npm install --global eas-cli@latest
|
||||
|
||||
- uses: ./.github/internal-actions/setup-gcloud
|
||||
|
||||
- name: Check if worker already exists
|
||||
@@ -61,7 +64,7 @@ jobs:
|
||||
SHOULD_UPLOAD_ARTIFACT="${{ inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false' }}"
|
||||
|
||||
# Run workflow and capture output (includes run ID)
|
||||
WORKFLOW_OUTPUT=$(npx -y eas-cli@latest workflow:run build-worker.yml \
|
||||
WORKFLOW_OUTPUT=$(eas workflow:run build-worker.yml \
|
||||
--ref ${{ github.sha }} \
|
||||
--input upload_artifact=$SHOULD_UPLOAD_ARTIFACT \
|
||||
--non-interactive \
|
||||
@@ -96,7 +99,7 @@ jobs:
|
||||
SHOULD_UPLOAD_ARTIFACT="${{ inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false' }}"
|
||||
|
||||
while true; do
|
||||
VIEW_OUTPUT=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" \
|
||||
VIEW_OUTPUT=$(eas workflow:view "$WORKFLOW_RUN_ID" \
|
||||
--non-interactive \
|
||||
--json)
|
||||
STATUS=$(echo "$VIEW_OUTPUT" | jq -r '.status')
|
||||
@@ -181,7 +184,7 @@ jobs:
|
||||
|
||||
WORKFLOW_RUN_ID="${{ steps.eas_workflow.outputs.workflow_run_id }}"
|
||||
if [ -n "$WORKFLOW_RUN_ID" ]; then
|
||||
npx -y eas-cli@latest workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive
|
||||
eas workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive
|
||||
fi
|
||||
|
||||
- name: Upload worker tarballs to GCP
|
||||
|
||||
@@ -15,12 +15,15 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-mise
|
||||
|
||||
- name: Install eas-cli
|
||||
run: npm install --global eas-cli@latest
|
||||
|
||||
- name: Create test app
|
||||
run: npx -y create-expo-app@latest ${{ runner.temp }}/test-app --template tabs
|
||||
|
||||
- name: Link to EAS project
|
||||
working-directory: ${{ runner.temp }}/test-app
|
||||
run: npx -y eas-cli@latest init --id 6ae8028d-4e6d-4912-8eb2-87d4e6122880 --force --non-interactive
|
||||
run: eas init --id 6ae8028d-4e6d-4912-8eb2-87d4e6122880 --force --non-interactive
|
||||
|
||||
- name: Add bundle identifiers to app.json
|
||||
working-directory: ${{ runner.temp }}/test-app
|
||||
@@ -30,7 +33,7 @@ jobs:
|
||||
|
||||
- name: Configure EAS Build
|
||||
working-directory: ${{ runner.temp }}/test-app
|
||||
run: npx -y eas-cli@latest build:configure --platform all
|
||||
run: eas build:configure --platform all
|
||||
|
||||
- name: Add system-test profile to eas.json
|
||||
working-directory: ${{ runner.temp }}/test-app
|
||||
@@ -49,7 +52,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BUILD_JSON=$(npx -y eas-cli@latest build --platform ios --profile system-test --non-interactive --no-wait --json)
|
||||
BUILD_JSON=$(eas build --platform ios --profile system-test --non-interactive --no-wait --json)
|
||||
BUILD_ID=$(echo "$BUILD_JSON" | jq -r 'if type=="array" then .[0].id // .[0].build.id // .[0].buildId else .id // .build.id // .buildId end // empty')
|
||||
if [ -z "$BUILD_ID" ]; then
|
||||
echo "Error: could not determine build ID"
|
||||
@@ -59,7 +62,7 @@ jobs:
|
||||
echo "BUILD_ID=$BUILD_ID" >> "$GITHUB_ENV"
|
||||
echo "Resolved BUILD_ID=$BUILD_ID"
|
||||
|
||||
WORKFLOW_JSON=$(npx -y eas-cli@latest workflow:run build-android.yml --non-interactive --no-wait --json)
|
||||
WORKFLOW_JSON=$(eas workflow:run build-android.yml --non-interactive --no-wait --json)
|
||||
WORKFLOW_RUN_ID=$(echo "$WORKFLOW_JSON" | jq -r '.id // .workflowRun.id // .workflowRunId // empty')
|
||||
if [ -z "$WORKFLOW_RUN_ID" ]; then
|
||||
echo "Error: could not determine workflow run ID"
|
||||
@@ -95,8 +98,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BUILD_STATUS=$(npx -y eas-cli@latest build:view "$BUILD_ID" --json | jq -r '.status // empty')
|
||||
WORKFLOW_STATUS=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" --non-interactive --json | jq -r '.status // empty')
|
||||
BUILD_STATUS=$(eas build:view "$BUILD_ID" --json | jq -r '.status // empty')
|
||||
WORKFLOW_STATUS=$(eas workflow:view "$WORKFLOW_RUN_ID" --non-interactive --json | jq -r '.status // empty')
|
||||
|
||||
echo "iOS build status: $BUILD_STATUS"
|
||||
echo "Android workflow status: $WORKFLOW_STATUS"
|
||||
@@ -130,13 +133,13 @@ jobs:
|
||||
|
||||
if [ -n "${BUILD_ID:-}" ]; then
|
||||
echo "Canceling EAS build: $BUILD_ID"
|
||||
BUILD_CANCEL_OUTPUT=$(npx -y eas-cli@latest build:cancel "$BUILD_ID" --non-interactive 2>&1 || true)
|
||||
BUILD_CANCEL_OUTPUT=$(eas build:cancel "$BUILD_ID" --non-interactive 2>&1 || true)
|
||||
if [ -n "$BUILD_CANCEL_OUTPUT" ]; then
|
||||
echo "$BUILD_CANCEL_OUTPUT"
|
||||
else
|
||||
echo "build:cancel produced no output"
|
||||
fi
|
||||
BUILD_STATUS=$(npx -y eas-cli@latest build:view "$BUILD_ID" --json 2>/dev/null | jq -r '.status // empty' || true)
|
||||
BUILD_STATUS=$(eas build:view "$BUILD_ID" --json 2>/dev/null | jq -r '.status // empty' || true)
|
||||
if [ -n "$BUILD_STATUS" ]; then
|
||||
echo "Post-cancel build status: $BUILD_STATUS"
|
||||
else
|
||||
@@ -145,13 +148,13 @@ jobs:
|
||||
fi
|
||||
if [ -n "${WORKFLOW_RUN_ID:-}" ]; then
|
||||
echo "Canceling EAS workflow run: $WORKFLOW_RUN_ID"
|
||||
WORKFLOW_CANCEL_OUTPUT=$(npx -y eas-cli@latest workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive 2>&1 || true)
|
||||
WORKFLOW_CANCEL_OUTPUT=$(eas workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive 2>&1 || true)
|
||||
if [ -n "$WORKFLOW_CANCEL_OUTPUT" ]; then
|
||||
echo "$WORKFLOW_CANCEL_OUTPUT"
|
||||
else
|
||||
echo "workflow:cancel produced no output"
|
||||
fi
|
||||
WORKFLOW_STATUS=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" --non-interactive --json 2>/dev/null | jq -r '.status // empty' || true)
|
||||
WORKFLOW_STATUS=$(eas workflow:view "$WORKFLOW_RUN_ID" --non-interactive --json 2>/dev/null | jq -r '.status // empty' || true)
|
||||
if [ -n "$WORKFLOW_STATUS" ]; then
|
||||
echo "Post-cancel workflow status: $WORKFLOW_STATUS"
|
||||
else
|
||||
|
||||
+42
-2
@@ -6,6 +6,46 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--launch-arg`, `--open-url`, and Expo Go `--sdk-version` options to `eas simulator`. ([#4264](https://github.com/expo/eas-cli/pull/4264) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Pass launch arguments and a URL to open when launching applications in simulator sessions. ([#4263](https://github.com/expo/eas-cli/pull/4263) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Add `ios_signing_backend` option to the repack step. ([#4239](https://github.com/expo/eas-cli/pull/4239) by [@gabrieldonadel](https://github.com/gabrieldonadel))
|
||||
- [build-tools] Support an optional `package_version` input on `eas/start_serve_sim_remote_session`, so a simulator session can pin the `@expo/serve-sim` version instead of always running `latest`. ([#4253](https://github.com/expo/eas-cli/pull/4253) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add composable custom build functions for downloading, installing, and launching application archives in simulator sessions. ([#4222](https://github.com/expo/eas-cli/pull/4222) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Add `--build-id`, `--application-archive-url`, and `--expo-go` to `eas simulator` to install and launch an application before the session is ready. ([#4223](https://github.com/expo/eas-cli/pull/4223) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Support downloading iOS simulator archives whose app bundle contents are stored at the archive root. ([#4262](https://github.com/expo/eas-cli/pull/4262) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [22.2.0](https://github.com/expo/eas-cli/releases/tag/v22.2.0) - 2026-08-20
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--device` flag to `eas simulator` for selecting the virtual device to start. ([#4172](https://github.com/expo/eas-cli/pull/4172) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Add support for Appium EAS Simulator sessions. ([#4190](https://github.com/expo/eas-cli/pull/4190) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Add `eas billing:subscribe <plan>` to start a Stripe checkout for an EAS plan, and `eas billing:manage` to open the Stripe customer portal. ([#4007](https://github.com/expo/eas-cli/pull/4007) by [@jonsamp](https://github.com/jonsamp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Disallow 0-length uploads. ([#4192](https://github.com/expo/eas-cli/pull/4192) by [@wschurman](https://github.com/wschurman))
|
||||
- [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
|
||||
|
||||
- [eas-cli] Fix lint and enforce max-warnings=0. ([#4219](https://github.com/expo/eas-cli/pull/4219) by [@wschurman](https://github.com/wschurman))
|
||||
- [build-tools] Rename serve-sim ngrok preview URLs to use the `web-preview` prefix. ([#4231](https://github.com/expo/eas-cli/pull/4231) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [22.0.0](https://github.com/expo/eas-cli/releases/tag/v22.0.0) - 2026-08-14
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Build objects in `--json` output (`eas build`, `eas build:list`, `eas build:view`, `eas build:resign`) now have `app`, `updateChannel` (object with `id` and `name`), and `runtime` (object with `id` and `version`) instead of `project`, `channel`, and `runtimeVersion`. `eas update:republish --json` output likewise has `runtime` instead of `runtimeVersion`. Other update commands' `--json` output and `eas-update-metadata.json` are unchanged. ([#4021](https://github.com/expo/eas-cli/pull/4021) by [@tchayen](https://github.com/tchayen))
|
||||
|
||||
### 🎉 New features
|
||||
@@ -15,11 +55,10 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Report EAS Simulator as available to Expo admins in `eas simulator:availability`, even when the project account's feature gate is disabled. ([#4181](https://github.com/expo/eas-cli/pull/4181) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Make the non-interactive "EAS project not configured" error actionable on every path: list the exact `eas init --id` / `eas init --account` commands and the accounts you can create projects in, instead of suggesting bare `eas init`, which fails the same way. ([#4153](https://github.com/expo/eas-cli/pull/4153) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [eas-cli] Set up the internal TestFlight group and invite admin testers when submitting interactively with an existing `ascAppId`, with `--no-auto-testflight-setup` to disable it, and fix the TestFlight group URL printed when adding testers partially fails. ([#4136](https://github.com/expo/eas-cli/pull/4136) by [@tchayen](https://github.com/tchayen))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [21.8.0](https://github.com/expo/eas-cli/releases/tag/v21.8.0) - 2026-08-11
|
||||
|
||||
### 🎉 New features
|
||||
@@ -81,6 +120,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Soft-stop EAS Simulator sessions before the backing job times out so cleanup can finish. ([#4117](https://github.com/expo/eas-cli/pull/4117) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] clean up error handling in local builds. ([#4105](https://github.com/expo/eas-cli/pull/4105) by [@douglowder](https://github.com/douglowder))
|
||||
- [build-tools] Install ffmpeg when it is missing so Argent screen recording works in EAS Simulator sessions. ([#4110](https://github.com/expo/eas-cli/pull/4110) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Stop simulator job runs when `eas simulator:start` is canceled before the session is ready. ([#4113](https://github.com/expo/eas-cli/pull/4113) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "21.8.0",
|
||||
"STAGING": "22.2.0",
|
||||
"PRODUCTION": "21.5.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "22.0.0",
|
||||
"version": "22.4.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
"watch": "yarn start",
|
||||
"eas": "packages/eas-cli/bin/run",
|
||||
"actionlint": "actionlint",
|
||||
"lint": "oxlint --type-aware --import-plugin --tsconfig ./tsconfig.oxlint.json .",
|
||||
"lint": "oxlint --type-aware --import-plugin --max-warnings=0 --tsconfig ./tsconfig.oxlint.json .",
|
||||
"fmt": "oxfmt .",
|
||||
"fmt:check": "oxfmt --check .",
|
||||
"lint-changelog": "./scripts/bin/run lint-changelog",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "22.0.0",
|
||||
"version": "22.4.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -46,7 +46,7 @@
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "22.0.0",
|
||||
"@expo/steps": "22.4.0",
|
||||
"@expo/template-file": "22.0.0",
|
||||
"@expo/turtle-spawn": "22.0.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
@@ -54,6 +54,7 @@
|
||||
"@sentry/node": "7.77.0",
|
||||
"@urql/core": "^6.0.1",
|
||||
"bplist-parser": "0.3.2",
|
||||
"content-disposition": "1.0.1",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fast-xml-parser": "^4.4.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
@@ -77,7 +78,8 @@
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/repack-app": "~0.6.1",
|
||||
"@expo/repack-app": "~0.9.0",
|
||||
"@types/content-disposition": "0.5.9",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.4",
|
||||
|
||||
@@ -98,7 +98,7 @@ async function buildInnerAsync(
|
||||
await restoreCcacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
platform: ctx.job.platform,
|
||||
target: { platform: ctx.job.platform },
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
@@ -238,7 +238,7 @@ async function buildInnerAsync(
|
||||
await saveCcacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
platform: ctx.job.platform,
|
||||
target: { platform: ctx.job.platform },
|
||||
evictUsedBefore,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
|
||||
@@ -103,7 +103,7 @@ async function buildInnerAsync(
|
||||
await restoreCcacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
platform: ctx.job.platform,
|
||||
target: { platform: ctx.job.platform, simulator: ctx.job.simulator === true },
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
@@ -245,7 +245,7 @@ async function buildInnerAsync(
|
||||
await saveCcacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
platform: ctx.job.platform,
|
||||
target: { platform: ctx.job.platform, simulator: ctx.job.simulator === true },
|
||||
evictUsedBefore,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { getXcodeVersionAsync } from '../xcode';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
describe(getXcodeVersionAsync, () => {
|
||||
it('returns the Xcode version', async () => {
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: 'Xcode 26.0\nBuild version 17A324\n',
|
||||
} as any);
|
||||
|
||||
await expect(getXcodeVersionAsync({ env: process.env })).resolves.toBe('26.0');
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('xcodebuild', ['-version'], {
|
||||
stdio: 'pipe',
|
||||
env: process.env,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when the Xcode version cannot be parsed', async () => {
|
||||
mockedSpawn.mockResolvedValue({ stdout: 'unexpected output' } as any);
|
||||
|
||||
await expect(getXcodeVersionAsync({ env: process.env })).rejects.toThrow(
|
||||
'Failed to determine Xcode version'
|
||||
);
|
||||
});
|
||||
|
||||
it('wraps errors from xcodebuild in a SystemError', async () => {
|
||||
const cause = new Error('xcode-select: error: invalid developer directory');
|
||||
mockedSpawn.mockRejectedValue(cause);
|
||||
|
||||
const promise = getXcodeVersionAsync({ env: process.env });
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: 'Failed to get Xcode version',
|
||||
cause,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
export async function getXcodeVersionAsync({ env }: { env: NodeJS.ProcessEnv }): Promise<string> {
|
||||
let stdout: string;
|
||||
try {
|
||||
({ stdout } = await spawn('xcodebuild', ['-version'], { stdio: 'pipe', env }));
|
||||
} catch (error) {
|
||||
throw new SystemError('Failed to get Xcode version', { cause: error });
|
||||
}
|
||||
const version = /^Xcode\s+(\d+(?:\.\d+)*)$/m.exec(stdout)?.[1];
|
||||
if (!version) {
|
||||
throw new SystemError(`Failed to determine Xcode version from: ${stdout.trim()}`);
|
||||
}
|
||||
return version;
|
||||
}
|
||||
@@ -19,8 +19,10 @@ import { generateGymfileFromTemplateFunction } from './functions/generateGymfile
|
||||
import { createGetCredentialsForBuildTriggeredByGithubIntegration } from './functions/getCredentialsForBuildTriggeredByGitHubIntegration';
|
||||
import { injectAndroidCredentialsFunction } from './functions/injectAndroidCredentials';
|
||||
import { createInstallMaestroBuildFunction } from './functions/installMaestro';
|
||||
import { createInstallBuildFunction } from './functions/installBuild';
|
||||
import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules';
|
||||
import { createInstallPodsBuildFunction } from './functions/installPods';
|
||||
import { createLaunchApplicationFunction } from './functions/launchApplication';
|
||||
import { createPrebuildBuildFunction } from './functions/prebuild';
|
||||
import { createReadAppConfigBuildFunction } from './functions/readAppConfig';
|
||||
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
|
||||
@@ -45,6 +47,7 @@ import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
|
||||
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
|
||||
import { createStartArgentRemoteSessionBuildFunction } from './functions/startArgentRemoteSession';
|
||||
import { createStartAppiumRemoteSessionBuildFunction } from './functions/startAppiumRemoteSession';
|
||||
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
|
||||
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
|
||||
import { createStartIosSimulatorRecordingsBuildFunction } from './functions/startIosSimulatorRecordings';
|
||||
@@ -73,6 +76,8 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createPrebuildBuildFunction(),
|
||||
createReadIpaInfoBuildFunction(),
|
||||
createDownloadBuildFunction(ctx),
|
||||
createInstallBuildFunction(),
|
||||
createLaunchApplicationFunction(),
|
||||
createEasExportBuildFunction(),
|
||||
createEasDeployBuildFunction(),
|
||||
createRepackBuildFunction(),
|
||||
@@ -94,6 +99,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
parseXcactivitylogFunction(),
|
||||
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
|
||||
createStartArgentRemoteSessionBuildFunction(ctx),
|
||||
createStartAppiumRemoteSessionBuildFunction(ctx),
|
||||
createStartAndroidEmulatorBuildFunction(),
|
||||
createStartCuttlefishDeviceBuildFunction(),
|
||||
createStartIosSimulatorBuildFunction(),
|
||||
|
||||
+12
-1
@@ -1,6 +1,6 @@
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { createInstallMaestroBuildFunction } from '../installMaestro';
|
||||
import { createInstallMaestroBuildFunction, installIdbFromBrew } from '../installMaestro';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('@expo/logger');
|
||||
@@ -57,3 +57,14 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
console.log(latestStep.outputs[0].value);
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
describe('installIdbFromBrew', () => {
|
||||
it('installs 1.1.8 version', async () => {
|
||||
const logger = createMockLogger({ logToConsole: true });
|
||||
|
||||
await installIdbFromBrew({
|
||||
logger,
|
||||
env: process.env,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,10 @@ import { Client, CombinedError } from '@urql/core';
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import * as tar from 'tar';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
@@ -19,6 +21,9 @@ const APP_TAR_GZ_BUFFER = Buffer.from(
|
||||
'base64'
|
||||
);
|
||||
|
||||
const FLAT_APP_INFO_PLIST_BASE64 =
|
||||
'YnBsaXN0MDDSAQIDBF8QEkNGQnVuZGxlRXhlY3V0YWJsZV8QE0NGQnVuZGxlUGFja2FnZVR5cGVXVGVzdEFwcFRBUFBMCA0iOEAAAAAAAAABAQAAAAAAAAAFAAAAAAAAAAAAAAAAAAAARQ==';
|
||||
|
||||
const APPLICATION_ARCHIVE_URL = `https://expo.dev/artifacts/eas/${randomUUID()}.tar.gz`;
|
||||
|
||||
function createMockGraphqlClient({
|
||||
@@ -51,6 +56,57 @@ function createMockGraphqlClient({
|
||||
} as unknown as Client;
|
||||
}
|
||||
|
||||
function createSuccessfulResponse({
|
||||
body,
|
||||
url,
|
||||
headers,
|
||||
}: {
|
||||
body: Buffer;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}): Response {
|
||||
const normalizedHeaders = Object.fromEntries(
|
||||
Object.entries(headers ?? {}).map(([name, value]) => [name.toLowerCase(), value])
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
body: Readable.from(body),
|
||||
url,
|
||||
headers: {
|
||||
get: (name: string): string | null => normalizedHeaders[name.toLowerCase()] ?? null,
|
||||
},
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
|
||||
const sourceDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'flat-app-source-'));
|
||||
const archivePath = path.join(os.tmpdir(), `${randomUUID()}.tar.gz`);
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
fs.promises.writeFile(path.join(sourceDirectory, 'Info.plist'), FLAT_APP_INFO_PLIST_BASE64, {
|
||||
encoding: 'base64',
|
||||
}),
|
||||
fs.promises.writeFile(path.join(sourceDirectory, 'TestApp'), 'i am executable\n'),
|
||||
]);
|
||||
await tar.create(
|
||||
{
|
||||
cwd: sourceDirectory,
|
||||
file: archivePath,
|
||||
gzip: true,
|
||||
},
|
||||
['Info.plist', 'TestApp']
|
||||
);
|
||||
return await fs.promises.readFile(archivePath);
|
||||
} finally {
|
||||
await Promise.all([
|
||||
fs.promises.rm(sourceDirectory, { recursive: true, force: true }),
|
||||
fs.promises.rm(archivePath, { force: true }),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
describe('downloadBuild', () => {
|
||||
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
|
||||
const buildId = randomUUID();
|
||||
@@ -58,11 +114,12 @@ describe('downloadBuild', () => {
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
body: Readable.from(APP_TAR_GZ_BUFFER),
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
} as unknown as Response);
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
createSuccessfulResponse({
|
||||
body: APP_TAR_GZ_BUFFER,
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
})
|
||||
);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
@@ -82,17 +139,39 @@ describe('downloadBuild', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('downloads an archive containing app bundle contents at its root', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
createSuccessfulResponse({
|
||||
body: await createFlatAppTarGzBufferAsync(),
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
})
|
||||
);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient: createMockGraphqlClient({}),
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
|
||||
expect(path.extname(artifactPath)).toBe('.app');
|
||||
expect(await fs.promises.readFile(path.join(artifactPath, 'TestApp'), 'utf8')).toBe(
|
||||
'i am executable\n'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle a straight-up file', async () => {
|
||||
const applicationArchiveUrl = `https://expo.dev/artifacts/eas/${randomUUID()}.apk`;
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl,
|
||||
});
|
||||
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
body: Readable.from(Buffer.from('hello')),
|
||||
url: applicationArchiveUrl,
|
||||
} as unknown as Response);
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: applicationArchiveUrl })
|
||||
);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
@@ -106,6 +185,128 @@ describe('downloadBuild', () => {
|
||||
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
|
||||
});
|
||||
|
||||
it('downloads a direct application archive URL without querying GraphQL or forwarding the EAS token', async () => {
|
||||
const applicationArchiveUrl = `https://artifacts.example.test/${randomUUID()}.apk`;
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: applicationArchiveUrl })
|
||||
);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl,
|
||||
graphqlClient,
|
||||
robotAccessToken: 'scoped-eas-token',
|
||||
extensions: ['apk'],
|
||||
});
|
||||
|
||||
expect(graphqlClient.query).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
applicationArchiveUrl,
|
||||
expect.objectContaining({ headers: undefined })
|
||||
);
|
||||
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
|
||||
});
|
||||
|
||||
it('uses the Content-Disposition filename for an extensionless URL', async () => {
|
||||
const applicationArchiveUrl = 'https://artifacts.example.test/download';
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
createSuccessfulResponse({
|
||||
body: Buffer.from('hello'),
|
||||
url: applicationArchiveUrl,
|
||||
headers: { 'content-disposition': 'attachment; filename="app.apk"' },
|
||||
})
|
||||
);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl,
|
||||
graphqlClient: createMockGraphqlClient({}),
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
|
||||
expect(path.basename(artifactPath)).toBe('app.apk');
|
||||
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
|
||||
});
|
||||
|
||||
it('uses the expected extension when the URL has no filename', async () => {
|
||||
const applicationArchiveUrl = 'https://artifacts.example.test/';
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: applicationArchiveUrl })
|
||||
);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl,
|
||||
graphqlClient: createMockGraphqlClient({}),
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
|
||||
expect(path.basename(artifactPath)).toBe('application.apk');
|
||||
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
|
||||
});
|
||||
|
||||
it('rejects missing or ambiguous build sources', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
|
||||
await expect(
|
||||
// @ts-expect-error Verify the runtime guard for callers without TypeScript.
|
||||
downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
errorCode: 'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
|
||||
message: 'Pass buildId or applicationArchiveUrl.',
|
||||
});
|
||||
|
||||
await expect(
|
||||
// @ts-expect-error Verify the runtime guard for callers without TypeScript.
|
||||
downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
errorCode: 'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
|
||||
message: 'Pass only one of buildId or applicationArchiveUrl.',
|
||||
});
|
||||
|
||||
expect(graphqlClient.query).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a non-HTTP application archive URL', async () => {
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: 'file:///tmp/app.apk',
|
||||
graphqlClient: createMockGraphqlClient({}),
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
errorCode: 'EAS_DOWNLOAD_BUILD_INVALID_APPLICATION_ARCHIVE_URL',
|
||||
message: 'application_archive_url must be a valid HTTP or HTTPS URL.',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws UserError when the build has no application archive url', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({ applicationArchiveUrl: null });
|
||||
|
||||
@@ -203,11 +404,12 @@ describe('downloadBuild', () => {
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
body: Readable.from(APP_TAR_GZ_BUFFER),
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
} as unknown as Response);
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
createSuccessfulResponse({
|
||||
body: APP_TAR_GZ_BUFFER,
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { createInstallBuildFunction, installBuildAsync } from '../installBuild';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
async function makeTemporaryDirectoryAsync(): Promise<string> {
|
||||
const temporaryDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'install-build-test-')
|
||||
);
|
||||
temporaryDirectories.push(temporaryDirectory);
|
||||
return temporaryDirectory;
|
||||
}
|
||||
|
||||
describe(installBuildAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.map(temporaryDirectory =>
|
||||
fs.promises.rm(temporaryDirectory, { recursive: true, force: true })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('installs an iOS Simulator .app', async () => {
|
||||
const temporaryDirectory = await makeTemporaryDirectoryAsync();
|
||||
const artifactPath = path.join(temporaryDirectory, 'Example.app');
|
||||
await fs.promises.mkdir(artifactPath);
|
||||
const logger = createMockLogger();
|
||||
mockedSpawn.mockResolvedValueOnce({ stdout: 'com.example.app\n', stderr: '' } as any);
|
||||
|
||||
const result = await installBuildAsync({
|
||||
artifactPath,
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ applicationIdentifier: 'com.example.app' });
|
||||
expect(mockedSpawn.mock.calls).toEqual([
|
||||
[
|
||||
'plutil',
|
||||
['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', path.join(artifactPath, 'Info.plist')],
|
||||
{ stdio: 'pipe', env: {} },
|
||||
],
|
||||
['xcrun', ['simctl', 'install', 'booted', artifactPath], { env: {}, logger }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('installs an Android Emulator .apk', async () => {
|
||||
const temporaryDirectory = await makeTemporaryDirectoryAsync();
|
||||
const artifactPath = path.join(temporaryDirectory, 'example.apk');
|
||||
await fs.promises.writeFile(artifactPath, 'apk');
|
||||
const logger = createMockLogger();
|
||||
mockedSpawn.mockResolvedValueOnce({
|
||||
stdout:
|
||||
"package: name='com.example.app'\nlaunchable-activity: name='com.example.app.MainActivity'\n",
|
||||
stderr: '',
|
||||
} as any);
|
||||
|
||||
const result = await installBuildAsync({
|
||||
artifactPath,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
});
|
||||
expect(mockedSpawn.mock.calls).toEqual([
|
||||
['aapt2', ['dump', 'badging', artifactPath], { stdio: 'pipe', env: {} }],
|
||||
['adb', ['install', '-r', artifactPath], { env: {}, logger }],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[BuildRuntimePlatform.DARWIN, 'example.ipa'],
|
||||
[BuildRuntimePlatform.LINUX, 'example.aab'],
|
||||
])('rejects an incompatible %s artifact', async (runtimePlatform, filename) => {
|
||||
const temporaryDirectory = await makeTemporaryDirectoryAsync();
|
||||
const artifactPath = path.join(temporaryDirectory, filename);
|
||||
await fs.promises.writeFile(artifactPath, 'artifact');
|
||||
|
||||
await expect(
|
||||
installBuildAsync({
|
||||
artifactPath,
|
||||
runtimePlatform,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_INSTALL_BUILD_INVALID_ARTIFACT' });
|
||||
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('installs an Android artifact without a launchable activity', async () => {
|
||||
const temporaryDirectory = await makeTemporaryDirectoryAsync();
|
||||
const artifactPath = path.join(temporaryDirectory, 'example.apk');
|
||||
await fs.promises.writeFile(artifactPath, 'apk');
|
||||
const logger = createMockLogger();
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: "package: name='com.example.app'\n",
|
||||
stderr: '',
|
||||
} as any);
|
||||
|
||||
const result = await installBuildAsync({
|
||||
artifactPath,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ applicationIdentifier: 'com.example.app' });
|
||||
expect(mockedSpawn.mock.calls).toEqual([
|
||||
['aapt2', ['dump', 'badging', artifactPath], { stdio: 'pipe', env: {} }],
|
||||
['adb', ['install', '-r', artifactPath], { env: {}, logger }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('exposes the installed application identifier and activity as step outputs', async () => {
|
||||
const temporaryDirectory = await makeTemporaryDirectoryAsync();
|
||||
const artifactPath = path.join(temporaryDirectory, 'example.apk');
|
||||
await fs.promises.writeFile(artifactPath, 'apk');
|
||||
mockedSpawn.mockResolvedValueOnce({
|
||||
stdout:
|
||||
"package: name='com.example.app'\nlaunchable-activity: name='com.example.app.MainActivity'\n",
|
||||
stderr: '',
|
||||
} as any);
|
||||
|
||||
const installBuild = createInstallBuildFunction();
|
||||
const buildStep = installBuild.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
|
||||
{ callInputs: { artifact_path: artifactPath } }
|
||||
);
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.outputById.application_identifier.value).toBe('com.example.app');
|
||||
expect(buildStep.outputById.activity_name.value).toBe('com.example.app.MainActivity');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +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';
|
||||
@@ -9,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(),
|
||||
@@ -16,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) => ({
|
||||
@@ -179,7 +204,7 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
['--version'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
PATH: expect.stringMatching(/:\/home\/expo\/\.maestro-runner\/bin$/),
|
||||
PATH: expect.stringMatching(/^\/home\/expo\/\.maestro-runner\/bin:/),
|
||||
}),
|
||||
})
|
||||
);
|
||||
@@ -189,6 +214,217 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('installs maestro-runner 1.1.15 on Xcode versions below 26', async () => {
|
||||
let versionChecks = 0;
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
switch (command) {
|
||||
case 'xcodebuild':
|
||||
return { stdout: 'Xcode 16.4\nBuild version 16F6\n' };
|
||||
case 'maestro-runner':
|
||||
return {
|
||||
stdout: versionChecks++ === 0 ? 'maestro-runner 1.1.23\n' : 'maestro-runner 1.1.15\n',
|
||||
};
|
||||
default:
|
||||
return { stdout: '' };
|
||||
}
|
||||
}) as any);
|
||||
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
text: async () => '#!/bin/sh\n',
|
||||
} as Response);
|
||||
const originalPath = process.env.PATH;
|
||||
|
||||
try {
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
HOME: '/home/expo',
|
||||
PATH: '/usr/bin',
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/install_maestro_runner.*\/install_maestro_runner\.sh$/),
|
||||
['--version', '1.1.15'],
|
||||
expect.objectContaining({ env: expect.objectContaining({ HOME: '/home/expo' }) })
|
||||
);
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.1.15');
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not check Xcode when a compatible maestro-runner version is installed', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
if (command === 'xcodebuild') {
|
||||
throw new Error('xcodebuild should not be called');
|
||||
}
|
||||
return { stdout: command === 'maestro-runner' ? 'maestro-runner 1.1.15\n' : '' };
|
||||
}) as any);
|
||||
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
HOME: '/home/expo',
|
||||
PATH: '/usr/bin',
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith(
|
||||
'xcodebuild',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.1.15');
|
||||
});
|
||||
|
||||
it('checks Xcode for latest when a compatible maestro-runner version is installed', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
switch (command) {
|
||||
case 'xcodebuild':
|
||||
return { stdout: 'Xcode 16.4\nBuild version 16F6\n' };
|
||||
case 'maestro-runner':
|
||||
return { stdout: 'maestro-runner 1.1.15\n' };
|
||||
default:
|
||||
return { stdout: '' };
|
||||
}
|
||||
}) as any);
|
||||
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
HOME: '/home/expo',
|
||||
PATH: '/usr/bin',
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner', maestro_version: 'latest' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcodebuild',
|
||||
['-version'],
|
||||
expect.objectContaining({ env: expect.anything() })
|
||||
);
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.1.15');
|
||||
});
|
||||
|
||||
it('installs the latest maestro-runner version on Xcode 26', async () => {
|
||||
let versionChecks = 0;
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
if (command === 'xcodebuild') {
|
||||
return { stdout: 'Xcode 26.0\nBuild version 17A324\n' };
|
||||
}
|
||||
if (command === 'maestro-runner' && versionChecks++ === 0) {
|
||||
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
|
||||
}
|
||||
return { stdout: command === 'maestro-runner' ? 'maestro-runner 1.1.23\n' : '' };
|
||||
}) as any);
|
||||
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
text: async () => '#!/bin/sh\n',
|
||||
} as Response);
|
||||
const originalPath = process.env.PATH;
|
||||
|
||||
try {
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
HOME: '/home/expo',
|
||||
PATH: '/usr/bin',
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/install_maestro_runner.*\/install_maestro_runner\.sh$/),
|
||||
[],
|
||||
expect.objectContaining({ env: expect.objectContaining({ HOME: '/home/expo' }) })
|
||||
);
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.1.23');
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it('throws a system error when the Xcode version cannot be determined', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
if (command === 'xcodebuild') {
|
||||
return { stdout: 'unexpected output' };
|
||||
}
|
||||
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
|
||||
}) as any);
|
||||
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
HOME: '/home/expo',
|
||||
PATH: '/usr/bin',
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(SystemError);
|
||||
});
|
||||
|
||||
it('rejects a requested maestro-runner version that is incompatible with Xcode', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
if (command === 'xcodebuild') {
|
||||
return { stdout: 'Xcode 16.4\nBuild version 16F6\n' };
|
||||
}
|
||||
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
|
||||
}) as any);
|
||||
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
HOME: '/home/expo',
|
||||
PATH: '/usr/bin',
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner', maestro_version: '1.1.23' },
|
||||
});
|
||||
|
||||
const executePromise = step.executeAsync();
|
||||
await expect(executePromise).rejects.toThrow(UserError);
|
||||
await expect(executePromise).rejects.toMatchObject({
|
||||
errorCode: 'ERR_MAESTRO_INVALID_INPUT',
|
||||
message:
|
||||
'maestro-runner 1.1.23 is not compatible with Xcode 16.4.0. Use maestro-runner 1.1.15 or an Xcode 26+ image.',
|
||||
});
|
||||
});
|
||||
|
||||
it('installs the requested maestro-runner version', async () => {
|
||||
let versionChecks = 0;
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
@@ -226,4 +462,184 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it('installs the prebuilt WDA cache for each available iOS runtime', async () => {
|
||||
const homeDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'install-maestro-test-')
|
||||
);
|
||||
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'maestro-runner') {
|
||||
return { stdout: 'maestro-runner 1.2.3\n' };
|
||||
}
|
||||
if (command === 'xcrun') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
runtimes: [
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
|
||||
isAvailable: true,
|
||||
version: '18.3.1',
|
||||
},
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-0',
|
||||
isAvailable: true,
|
||||
version: '26.0',
|
||||
},
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-17-5',
|
||||
isAvailable: false,
|
||||
version: '17.5',
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (command === 'xcodebuild') {
|
||||
return { stdout: 'Xcode 26.0\nBuild version 17A324\n' };
|
||||
}
|
||||
if (command === 'tar') {
|
||||
const maestroRunnerHome = args[args.indexOf('-C') + 1];
|
||||
const productsDirectory = path.join(
|
||||
maestroRunnerHome,
|
||||
'cache',
|
||||
'wda-builds',
|
||||
'generic',
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products'
|
||||
);
|
||||
await fs.promises.mkdir(productsDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(productsDirectory, 'WebDriverAgentRunner.xctestrun'),
|
||||
'cached'
|
||||
);
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as any);
|
||||
|
||||
try {
|
||||
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
|
||||
HOME: homeDirectory,
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenCalledWith(
|
||||
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.0-wda-11.1.3.tar.gz',
|
||||
expect.stringMatching(/install_maestro_runner_wda_cache.*\/wda-cache\.tar\.gz$/),
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
);
|
||||
for (const runtimeVersion of ['18.3', '26.0']) {
|
||||
await expect(
|
||||
fs.promises.readFile(
|
||||
path.join(
|
||||
homeDirectory,
|
||||
'.maestro-runner',
|
||||
'cache',
|
||||
'wda-builds',
|
||||
`sim-ios${runtimeVersion}-iphone`,
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products',
|
||||
'WebDriverAgentRunner.xctestrun'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).resolves.toBe('cached');
|
||||
}
|
||||
await expect(
|
||||
fs.promises.access(
|
||||
path.join(homeDirectory, '.maestro-runner', 'cache', 'wda-builds', 'sim-ios17.5-iphone')
|
||||
)
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not download the WDA cache when the installed WDA version is unknown', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => ({
|
||||
stdout:
|
||||
command === 'xcodebuild' ? 'Xcode 26.5\nBuild version 17F90\n' : 'maestro-runner 1.2.3\n',
|
||||
})) as any);
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build', HOME: '/home/expo' });
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
// The installed WDA version is unknown (no package.json under $HOME), so the
|
||||
// cache download is skipped before resolving the Xcode/runtime versions.
|
||||
expect(mockedDownloadFile).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn.mock.calls.map(([command]) => command)).not.toContain('xcrun');
|
||||
});
|
||||
|
||||
it('continues when the prebuilt WDA cache is not available', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
switch (command) {
|
||||
case 'maestro-runner':
|
||||
return { stdout: 'maestro-runner 1.2.3\n' };
|
||||
case 'xcodebuild':
|
||||
return { stdout: 'Xcode 26.5\nBuild version 17F90\n' };
|
||||
case 'xcrun':
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
runtimes: [
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
|
||||
isAvailable: true,
|
||||
version: '26.5',
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
default:
|
||||
return { stdout: '' };
|
||||
}
|
||||
}) as any);
|
||||
mockedDownloadFile.mockRejectedValue(new Error('HTTP 404'));
|
||||
const homeDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'install-maestro-test-')
|
||||
);
|
||||
|
||||
try {
|
||||
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
|
||||
HOME: homeDirectory,
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).resolves.toBeUndefined();
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.2.3');
|
||||
expect(mockedDownloadFile.mock.calls.map(([url]) => url)).toEqual([
|
||||
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
|
||||
'https://storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
|
||||
]);
|
||||
} finally {
|
||||
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { createLaunchApplicationFunction, launchApplicationAsync } from '../launchApplication';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
describe(launchApplicationAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
});
|
||||
|
||||
it('launches an iOS Simulator application by bundle identifier', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'launch', 'booted', 'com.example.app'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
});
|
||||
|
||||
it('passes launch arguments and opens a URL on iOS', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['--uitesting', 'true'],
|
||||
openUrl: 'exp://example.test',
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'xcrun',
|
||||
['simctl', 'launch', 'booted', 'host.exp.Exponent', '--uitesting', 'true'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'xcrun',
|
||||
['simctl', 'openurl', 'booted', 'exp://example.test'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
});
|
||||
|
||||
it('launches an Android Emulator application by package and activity', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'adb',
|
||||
['shell', 'am', 'start', '-n', 'com.example.app/com.example.app.MainActivity'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
});
|
||||
|
||||
it('passes launch arguments and opens a URL on Android', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'host.exp.exponent',
|
||||
activityName: 'host.exp.exponent.MainActivity',
|
||||
launchArgs: ['--ez', 'isTest', 'true'],
|
||||
openUrl: 'exp://example.test',
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'--ez',
|
||||
'isTest',
|
||||
'true',
|
||||
'-n',
|
||||
'host.exp.exponent/host.exp.exponent.MainActivity',
|
||||
],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-a',
|
||||
'android.intent.action.VIEW',
|
||||
'-d',
|
||||
'exp://example.test',
|
||||
'-n',
|
||||
'host.exp.exponent/host.exp.exponent.MainActivity',
|
||||
],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(logger.info).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'Launching host.exp.exponent with arguments ["--ez","isTest","true"].'
|
||||
);
|
||||
});
|
||||
|
||||
it('requires an activity when launching an Android application', async () => {
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_MISSING_ACTIVITY' });
|
||||
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('launches using application metadata passed to the build step', async () => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
|
||||
{
|
||||
callInputs: {
|
||||
application_identifier: 'com.example.app',
|
||||
activity_name: 'com.example.app.MainActivity',
|
||||
launch_args: ['--ez', 'isTest', 'true'],
|
||||
open_url: 'exp://example.test',
|
||||
},
|
||||
}
|
||||
);
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'--ez',
|
||||
'isTest',
|
||||
'true',
|
||||
'-n',
|
||||
'com.example.app/com.example.app.MainActivity',
|
||||
],
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-a',
|
||||
'android.intent.action.VIEW',
|
||||
'-d',
|
||||
'exp://example.test',
|
||||
'-n',
|
||||
'com.example.app/com.example.app.MainActivity',
|
||||
],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it.each([{}, ['valid', 42]])('rejects invalid launch arguments (%j)', async launchArgs => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.DARWIN }),
|
||||
{
|
||||
callInputs: {
|
||||
application_identifier: 'com.example.app',
|
||||
launch_args: launchArgs,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await expect(buildStep.executeAsync()).rejects.toMatchObject({
|
||||
errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
message: 'Input "launch_args" must be an array of strings.',
|
||||
});
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an invalid URL to open', async () => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.DARWIN }),
|
||||
{
|
||||
callInputs: {
|
||||
application_identifier: 'com.example.app',
|
||||
open_url: 'not a URL',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await expect(buildStep.executeAsync()).rejects.toMatchObject({
|
||||
errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
message: 'Input "open_url" must be a valid URL.',
|
||||
});
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['application_identifier', 'activity_name'])(
|
||||
'reports an empty %s input as a user error',
|
||||
async inputName => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
|
||||
{
|
||||
callInputs: {
|
||||
application_identifier: 'com.example.app',
|
||||
activity_name: 'com.example.app.MainActivity',
|
||||
[inputName]: '',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await expect(buildStep.executeAsync()).rejects.toMatchObject({
|
||||
errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
message: `Input "${inputName}" must be a non-empty string. Pass the "${inputName}" output from eas/install_build.`,
|
||||
});
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -10,11 +10,92 @@ import {
|
||||
parseFailedFlowNamesFromJUnitFile,
|
||||
parseFailedFlowsFromFileAttrs,
|
||||
parseFailedFlowsFromJUnit,
|
||||
parseFailedFlowsFromMaestroRunnerReport,
|
||||
parseJUnitTestCases,
|
||||
parseMaestroResults,
|
||||
parseMaestroResultsFromFileAttrs,
|
||||
parseMaestroRunnerReport,
|
||||
} from '../maestroResultParser';
|
||||
|
||||
describe(parseFailedFlowsFromMaestroRunnerReport, () => {
|
||||
it('returns exact sourceFile paths for failed flows', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/flows/pass.yml': '',
|
||||
'/project/flows/nested/fail.yml': '',
|
||||
'/reports/attempt-0/report.json': JSON.stringify({
|
||||
flows: [
|
||||
{ name: 'Passing flow', sourceFile: 'flows/pass.yml', status: 'passed' },
|
||||
{
|
||||
name: 'Failing flow',
|
||||
sourceFile: 'flows/nested/fail.yml',
|
||||
status: 'failed',
|
||||
},
|
||||
{ status: 'skipped' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
parseFailedFlowsFromMaestroRunnerReport({
|
||||
reportDirectory: '/reports/attempt-0',
|
||||
workingDirectory: '/project',
|
||||
})
|
||||
).resolves.toEqual(['flows/nested/fail.yml']);
|
||||
await expect(parseMaestroRunnerReport('/reports/attempt-0')).resolves.toEqual({
|
||||
flows: [
|
||||
{ name: 'Passing flow', sourceFile: 'flows/pass.yml', status: 'passed' },
|
||||
{
|
||||
name: 'Failing flow',
|
||||
sourceFile: 'flows/nested/fail.yml',
|
||||
status: 'failed',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'incomplete flow',
|
||||
report: {
|
||||
version: '1.0.0',
|
||||
status: 'failed',
|
||||
summary: { total: 1 },
|
||||
flows: [{ name: 'Failing flow', sourceFile: 'flows/fail.yml', status: 'running' }],
|
||||
},
|
||||
},
|
||||
])('returns null for an $name', async ({ report }) => {
|
||||
vol.fromJSON({
|
||||
'/project/flows/fail.yml': '',
|
||||
'/reports/attempt-0/report.json': JSON.stringify(report),
|
||||
});
|
||||
|
||||
await expect(
|
||||
parseFailedFlowsFromMaestroRunnerReport({
|
||||
reportDirectory: '/reports/attempt-0',
|
||||
workingDirectory: '/project',
|
||||
})
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when a failed sourceFile does not exist', async () => {
|
||||
vol.fromJSON({
|
||||
'/reports/attempt-0/report.json': JSON.stringify({
|
||||
version: '1.0.0',
|
||||
status: 'failed',
|
||||
summary: { total: 1 },
|
||||
flows: [{ name: 'Failing flow', sourceFile: 'flows/missing.yml', status: 'failed' }],
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
parseFailedFlowsFromMaestroRunnerReport({
|
||||
reportDirectory: '/reports/attempt-0',
|
||||
workingDirectory: '/project',
|
||||
})
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseFailedFlowNamesFromJUnitFile, () => {
|
||||
it('returns the names of failed testcases, keeping slashes', async () => {
|
||||
vol.fromJSON({
|
||||
@@ -453,6 +534,20 @@ describe('junitFileHasFileAttrs', () => {
|
||||
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for a maestro-runner report with a file property', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="a" time="1.0">',
|
||||
' <properties><property name="file" value="flows/a.yaml"/></properties>',
|
||||
' </testcase>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the file cannot be read', async () => {
|
||||
expect(await junitFileHasFileAttrs('/missing.xml')).toBe(false);
|
||||
});
|
||||
@@ -478,6 +573,41 @@ describe(parseJUnitTestCases, () => {
|
||||
expect(results[0].file).toBe('.maestro/login.yaml');
|
||||
});
|
||||
|
||||
it('parses maestro-runner file properties and standard JUnit pass status', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="login" time="1.0">',
|
||||
' <properties><property name="file" value="flows/login.yaml"/></properties>',
|
||||
' </testcase>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
|
||||
expect(results[0]).toEqual(
|
||||
expect.objectContaining({ file: 'flows/login.yaml', status: 'passed' })
|
||||
);
|
||||
});
|
||||
|
||||
it('excludes skipped testcases instead of counting them as passed', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="passing" time="1.0"/>',
|
||||
' <testcase name="skipped-flow" time="0"><skipped/></testcase>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
|
||||
expect(results.map(r => r.name)).toEqual(['passing']);
|
||||
});
|
||||
|
||||
it('treats a missing or empty file= attribute as undefined', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
|
||||
@@ -3,14 +3,160 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import {
|
||||
type HarvestedScreenshot,
|
||||
computePureFailureFlowNames,
|
||||
harvestFailureScreenshotsAsync,
|
||||
harvestMaestroRunnerFailureScreenshotsAsync,
|
||||
parseFailureScreenshotFilename,
|
||||
selectFailureScreenshots,
|
||||
} from '../maestroScreenshots';
|
||||
|
||||
describe(harvestMaestroRunnerFailureScreenshotsAsync, () => {
|
||||
const logger = createMockLogger();
|
||||
let reportDirectory: string;
|
||||
let captureSpy: jest.SpiedFunction<typeof Sentry.capture>;
|
||||
|
||||
beforeEach(async () => {
|
||||
reportDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'maestro-runner-harvest-test-'));
|
||||
await fs.mkdir(path.join(reportDirectory, 'flows'));
|
||||
await fs.mkdir(path.join(reportDirectory, 'assets', 'flow-000'), { recursive: true });
|
||||
captureSpy = jest.spyOn(Sentry, 'capture').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(reportDirectory, { recursive: true, force: true });
|
||||
captureSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('reads the failed command screenshot from a maestro-runner report bundle', async () => {
|
||||
const capturedSinceMs = Date.now() - 1_000;
|
||||
await fs.writeFile(
|
||||
path.join(reportDirectory, 'report.json'),
|
||||
JSON.stringify({
|
||||
flows: [
|
||||
{
|
||||
name: 'authentication/Login',
|
||||
status: 'failed',
|
||||
dataFile: 'flows/flow-000.json',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(reportDirectory, 'flows', 'flow-000.json'),
|
||||
JSON.stringify({
|
||||
commands: [
|
||||
{
|
||||
status: 'failed',
|
||||
artifacts: { screenshotAfter: 'assets/flow-000/cmd-001-after.png' },
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
const screenshotPath = path.join(reportDirectory, 'assets', 'flow-000', 'cmd-001-after.png');
|
||||
await fs.writeFile(screenshotPath, 'png');
|
||||
|
||||
const shots = await harvestMaestroRunnerFailureScreenshotsAsync({
|
||||
reportDirectory,
|
||||
capturedSinceMs,
|
||||
attemptIndex: 1,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toEqual([
|
||||
{
|
||||
fileAbsPath: screenshotPath,
|
||||
displayName: 'Failure Screenshot: authentication_Login (attempt 2)',
|
||||
metadata: {
|
||||
kind: 'maestro-test-screenshot',
|
||||
flowName: 'authentication_Login',
|
||||
attemptIndex: 1,
|
||||
capturedAtMs: expect.any(Number),
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a screenshot path that resolves to the parent directory (exact "..")', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(reportDirectory, 'report.json'),
|
||||
JSON.stringify({
|
||||
flows: [{ name: 'Login', status: 'failed', dataFile: 'flows/flow-000.json' }],
|
||||
})
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(reportDirectory, 'flows', 'flow-000.json'),
|
||||
JSON.stringify({
|
||||
commands: [{ status: 'failed', artifacts: { screenshotAfter: '..' } }],
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
harvestMaestroRunnerFailureScreenshotsAsync({
|
||||
reportDirectory,
|
||||
capturedSinceMs: 0,
|
||||
attemptIndex: 0,
|
||||
logger,
|
||||
})
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores passing flows and paths outside the report directory', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(reportDirectory, 'report.json'),
|
||||
JSON.stringify({
|
||||
flows: [
|
||||
{ name: 'Passing', status: 'passed', dataFile: 'flows/flow-000.json' },
|
||||
{ name: 'Unsafe', status: 'failed', dataFile: '../outside.json' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
harvestMaestroRunnerFailureScreenshotsAsync({
|
||||
reportDirectory,
|
||||
capturedSinceMs: 0,
|
||||
attemptIndex: 0,
|
||||
logger,
|
||||
})
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['null', 'null'],
|
||||
['a non-object flows value', JSON.stringify({ flows: 5 })],
|
||||
['a null flow entry', JSON.stringify({ flows: [null] })],
|
||||
])('reports to Sentry and returns [] when report.json is %s', async (_label, contents) => {
|
||||
await fs.writeFile(path.join(reportDirectory, 'report.json'), contents);
|
||||
|
||||
await expect(
|
||||
harvestMaestroRunnerFailureScreenshotsAsync({
|
||||
reportDirectory,
|
||||
capturedSinceMs: 0,
|
||||
attemptIndex: 0,
|
||||
logger,
|
||||
})
|
||||
).resolves.toEqual([]);
|
||||
expect(captureSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns [] without reporting to Sentry when report.json is not valid JSON', async () => {
|
||||
await fs.writeFile(path.join(reportDirectory, 'report.json'), 'not-json');
|
||||
|
||||
await expect(
|
||||
harvestMaestroRunnerFailureScreenshotsAsync({
|
||||
reportDirectory,
|
||||
capturedSinceMs: 0,
|
||||
attemptIndex: 0,
|
||||
logger,
|
||||
})
|
||||
).resolves.toEqual([]);
|
||||
expect(captureSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseFailureScreenshotFilename, () => {
|
||||
it('parses a plain failure screenshot', () => {
|
||||
expect(parseFailureScreenshotFilename('screenshot-❌-1781186692250-(Login Flow).png')).toEqual({
|
||||
|
||||
@@ -27,10 +27,14 @@ jest.mock('../../../utils/retry', () => ({
|
||||
jest.mock('../maestroScreenshots', () => ({
|
||||
...jest.requireActual('../maestroScreenshots'),
|
||||
harvestFailureScreenshotsAsync: jest.fn(),
|
||||
harvestMaestroRunnerFailureScreenshotsAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedHarvest = jest.mocked(maestroScreenshots.harvestFailureScreenshotsAsync);
|
||||
const mockedRunnerHarvest = jest.mocked(
|
||||
maestroScreenshots.harvestMaestroRunnerFailureScreenshotsAsync
|
||||
);
|
||||
const mockUploadArtifact = jest.fn();
|
||||
|
||||
function makeShot(index: number): maestroScreenshots.HarvestedScreenshot {
|
||||
@@ -89,6 +93,8 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
|
||||
mockedHarvest.mockReset();
|
||||
mockedHarvest.mockResolvedValue([]);
|
||||
mockedRunnerHarvest.mockReset();
|
||||
mockedRunnerHarvest.mockResolvedValue([]);
|
||||
mockUploadArtifact.mockReset();
|
||||
mockUploadArtifact.mockResolvedValue({ artifactId: 'artifact-1' });
|
||||
});
|
||||
@@ -137,6 +143,171 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(args!.join(' ')).toMatch(/--output=.*android-maestro-junit-attempt-0\.xml/);
|
||||
});
|
||||
|
||||
it('runs maestro-runner and collects its JUnit report when selected by input', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const copyFileSpy = jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const junitFailureNamesSpy = jest.spyOn(parser, 'parseFailedFlowNamesFromJUnitFile');
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'ios',
|
||||
backend: 'maestro-runner',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
const [command, args] = mockedSpawn.mock.calls[0];
|
||||
expect(command).toBe('maestro-runner');
|
||||
expect(args).toEqual([
|
||||
'--platform=ios',
|
||||
'test',
|
||||
'--output=/home/expo/.maestro/tests/ios-maestro-runner-attempt-0',
|
||||
'--flatten',
|
||||
'flows/a.yaml',
|
||||
'flows/b.yaml',
|
||||
]);
|
||||
expect(copyFileSpy).toHaveBeenCalledWith(
|
||||
'/home/expo/.maestro/tests/ios-maestro-runner-attempt-0/junit-report.xml',
|
||||
'/home/expo/.maestro/tests/junit-reports/ios-maestro-junit-attempt-0.xml'
|
||||
);
|
||||
expect(mockedHarvest).not.toHaveBeenCalled();
|
||||
expect(junitFailureNamesSpy).not.toHaveBeenCalled();
|
||||
expect(mockedRunnerHarvest).toHaveBeenCalledWith({
|
||||
reportDirectory: '/home/expo/.maestro/tests/ios-maestro-runner-attempt-0',
|
||||
capturedSinceMs: expect.any(Number),
|
||||
attemptIndex: 0,
|
||||
logger: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the deterministic maestro-runner output directory before each attempt', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const rmSpy = jest.spyOn(fs, 'rm').mockResolvedValue();
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'ios',
|
||||
backend: 'maestro-runner',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(rmSpy).toHaveBeenCalledWith('/home/expo/.maestro/tests/ios-maestro-runner-attempt-0', {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
// Cleared before the run, so a crash before fresh output can't resurrect stale results.
|
||||
expect(rmSpy.mock.invocationCallOrder[0]).toBeLessThan(mockedSpawn.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('selects maestro-runner from EAS_MAESTRO_BACKEND', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const step = createStep(
|
||||
{ flow_path: ['flows/a.yaml'], platform: 'android' },
|
||||
{ env: { HOME: '/home/expo', EAS_MAESTRO_BACKEND: 'maestro-runner' } }
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls[0][0]).toBe('maestro-runner');
|
||||
});
|
||||
|
||||
it('prefers the backend input over EAS_MAESTRO_BACKEND', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const step = createStep(
|
||||
{ flow_path: ['flows/a.yaml'], platform: 'android', backend: 'maestro' },
|
||||
{ env: { HOME: '/home/expo', EAS_MAESTRO_BACKEND: 'maestro-runner' } }
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls[0][0]).toBe('maestro');
|
||||
});
|
||||
|
||||
it('passes tags to maestro-runner', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
include_tags: 'smoke',
|
||||
exclude_tags: 'slow',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining(['--include-tags=smoke', '--exclude-tags=slow'])
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid backend values', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'other',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects sharding with maestro-runner', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
shards: 2,
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
'maestro-runner does not support EAS Maestro test sharding'
|
||||
);
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a non-junit output_format with maestro-runner', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
output_format: 'html',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
'maestro-runner only supports the "junit" output_format'
|
||||
);
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs that maestro-runner does not support direct DADB', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const logger = createMockLogger();
|
||||
jest.mocked(logger.child).mockReturnValue(logger);
|
||||
const step = createStep(
|
||||
{
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
android_connection_mode: 'dadb',
|
||||
},
|
||||
{ env: { HOME: '/home/expo', PATH: '/usr/bin' }, logger }
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
expect(mockedSpawn.mock.calls[0][0]).toBe('maestro-runner');
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'maestro-runner does not support DADB. Using the default ADB connection.'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses direct DADB when android_connection_mode is dadb without cleanup', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const writeFileSpy = jest.spyOn(fs, 'writeFile');
|
||||
@@ -453,6 +624,36 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(a1Args).not.toContain('flows/c.yaml');
|
||||
});
|
||||
|
||||
it('uses maestro-runner report.json for junit retries', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const runnerReportSpy = jest
|
||||
.spyOn(parser, 'parseFailedFlowsFromMaestroRunnerReport')
|
||||
.mockResolvedValue(['flows/b.yaml']);
|
||||
const junitParseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
platform: 'ios',
|
||||
backend: 'maestro-runner',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(runnerReportSpy).toHaveBeenCalledWith({
|
||||
reportDirectory: '/home/expo/.maestro/tests/ios-maestro-runner-attempt-0',
|
||||
workingDirectory: expect.any(String),
|
||||
});
|
||||
expect(junitParseSpy).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
|
||||
);
|
||||
const retryArgs = mockedSpawn.mock.calls[1][1]!;
|
||||
expect(retryArgs).toContain('flows/b.yaml');
|
||||
expect(retryArgs).not.toContain('flows/a.yaml');
|
||||
});
|
||||
|
||||
it('retries all flows when parseFailedFlowsFromJUnit returns null', async () => {
|
||||
const spawnMock = mockedSpawn
|
||||
.mockRejectedValueOnce(rejectExit1())
|
||||
@@ -1014,6 +1215,29 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('discovers maestro-runner flow results from the attempt report directory', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const shot = makeShot(0);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
mockedRunnerHarvest.mockResolvedValue([shot]);
|
||||
const parseFlowResultsSpy = jest.spyOn(parser, 'parseMaestroRunnerReport').mockResolvedValue({
|
||||
flows: [{ name: 'Login', sourceFile: 'a.yaml', status: 'failed' }],
|
||||
});
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
platform: 'ios',
|
||||
output_format: 'junit',
|
||||
backend: 'maestro-runner',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(parseFlowResultsSpy).toHaveBeenCalledWith(
|
||||
'/home/expo/.maestro/tests/ios-maestro-runner-attempt-0'
|
||||
);
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uploads screenshots even when all attempts fail, before throwing ERR_MAESTRO_TESTS_FAILED', async () => {
|
||||
mockedSpawn.mockRejectedValue(rejectExit1());
|
||||
const shot = makeShot(0);
|
||||
|
||||
@@ -26,6 +26,7 @@ describe(readIpaInfoAsync, () => {
|
||||
bundleIdentifier: 'dev.expo.SmallestAppExample',
|
||||
bundleShortVersion: '1.0',
|
||||
bundleVersion: '1',
|
||||
dtPlatformName: 'iphoneos',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import assert from 'node:assert';
|
||||
import fg from 'fast-glob';
|
||||
import { vol } from 'memfs';
|
||||
import path from 'node:path';
|
||||
@@ -193,22 +194,53 @@ describe(resolveIosSigningOptionsAsync, () => {
|
||||
const signingOptions = await resolveIosSigningOptionsAsync({
|
||||
job,
|
||||
logger: mockLogger,
|
||||
tmpDir: '/tmp',
|
||||
});
|
||||
|
||||
expect(signingOptions).not.toBeNull();
|
||||
expect(signingOptions?.keychainPath).toEqual('/tmp/ios_keychain');
|
||||
expect(signingOptions?.signingIdentity).toEqual('Test App Certificate');
|
||||
expect(signingOptions?.provisioningProfile).toEqual({
|
||||
'com.example.testapp': '/tmp/ios_provisioning_profile',
|
||||
expect(signingOptions).toMatchObject({
|
||||
keychainPath: '/tmp/ios_keychain',
|
||||
signingIdentity: 'Test App Certificate',
|
||||
provisioningProfile: {
|
||||
'com.example.testapp': '/tmp/ios_provisioning_profile',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve zsign signing options without touching the keychain', async () => {
|
||||
const job = createTestIosJob();
|
||||
const tmpDir = '/tmp';
|
||||
vol.mkdirSync(tmpDir, { recursive: true });
|
||||
const credentialsManagerSpy = jest.spyOn(IosCredentialsManager.prototype, 'prepare');
|
||||
|
||||
const signingOptions = await resolveIosSigningOptionsAsync({
|
||||
job,
|
||||
logger: mockLogger,
|
||||
backend: 'zsign',
|
||||
tmpDir,
|
||||
});
|
||||
|
||||
expect(credentialsManagerSpy).not.toHaveBeenCalled();
|
||||
expect(signingOptions).toMatchObject({
|
||||
backend: 'zsign',
|
||||
keyPassword: job.secrets?.buildCredentials?.['testapp'].distributionCertificate.password,
|
||||
});
|
||||
assert(signingOptions?.backend === 'zsign');
|
||||
expect(signingOptions.certificatePath).toMatch(/\/tmp\/dist-cert-.*\.p12/);
|
||||
expect(vol.existsSync(signingOptions.certificatePath)).toBe(true);
|
||||
assert(typeof signingOptions.provisioningProfile === 'object');
|
||||
const profilePath = signingOptions.provisioningProfile['testapp'];
|
||||
expect(profilePath).toMatch(/\/tmp\/profile-testapp-.*\.mobileprovision/);
|
||||
expect(vol.existsSync(profilePath)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return undefined if no build credentials are provided', async () => {
|
||||
// @ts-expect-error: createTestIosJob does not support buildCredentials as null
|
||||
const job = createTestIosJob({ buildCredentials: null });
|
||||
const signingOptions = await resolveIosSigningOptionsAsync({
|
||||
job,
|
||||
logger: mockLogger,
|
||||
tmpDir: '/tmp',
|
||||
});
|
||||
expect(signingOptions).toBeUndefined();
|
||||
});
|
||||
@@ -219,6 +251,7 @@ describe(resolveIosSigningOptionsAsync, () => {
|
||||
const signingOptions = await resolveIosSigningOptionsAsync({
|
||||
job,
|
||||
logger: mockLogger,
|
||||
tmpDir: '/tmp',
|
||||
});
|
||||
expect(signingOptions).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { compressCacheAsync } from '../saveCache';
|
||||
import { decompressCacheAsync } from '../restoreCache';
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return { info: jest.fn(), warn: jest.fn(), error: jest.fn() } as unknown as bunyan;
|
||||
}
|
||||
|
||||
describe('cache compress/decompress round trip', () => {
|
||||
it('preserves multiple sibling directories archived together', async () => {
|
||||
const logger = createLoggerMock();
|
||||
|
||||
const cachesDir = path.join(os.tmpdir(), 'gradle-caches');
|
||||
const buildCacheDir = path.join(cachesDir, 'build-cache-1');
|
||||
const journalDir = path.join(cachesDir, 'journal-1');
|
||||
await fs.promises.mkdir(buildCacheDir, { recursive: true });
|
||||
await fs.promises.mkdir(journalDir, { recursive: true });
|
||||
await fs.promises.writeFile(path.join(buildCacheDir, 'entry'), 'cache entry');
|
||||
await fs.promises.writeFile(path.join(journalDir, 'file-access.properties'), 'inception=1');
|
||||
|
||||
const { archivePath } = await compressCacheAsync({
|
||||
paths: [buildCacheDir, journalDir],
|
||||
workingDirectory: cachesDir,
|
||||
verbose: false,
|
||||
logger,
|
||||
});
|
||||
|
||||
const restoreDir = path.join(os.tmpdir(), 'gradle-caches-restored');
|
||||
await fs.promises.mkdir(restoreDir, { recursive: true });
|
||||
await decompressCacheAsync({
|
||||
archivePath,
|
||||
workingDirectory: restoreDir,
|
||||
verbose: false,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(
|
||||
await fs.promises.readFile(path.join(restoreDir, 'build-cache-1', 'entry'), 'utf8')
|
||||
).toBe('cache entry');
|
||||
expect(
|
||||
await fs.promises.readFile(
|
||||
path.join(restoreDir, 'journal-1', 'file-access.properties'),
|
||||
'utf8'
|
||||
)
|
||||
).toBe('inception=1');
|
||||
});
|
||||
|
||||
it('preserves file modification times so journal-fallback entries can still age out', async () => {
|
||||
const logger = createLoggerMock();
|
||||
|
||||
const sourceDir = path.join(os.tmpdir(), 'mtime-source');
|
||||
await fs.promises.mkdir(sourceDir, { recursive: true });
|
||||
const filePath = path.join(sourceDir, 'entry');
|
||||
await fs.promises.writeFile(filePath, 'cache entry');
|
||||
|
||||
// A whole-second value in the past — tar stores mtimes at one-second resolution.
|
||||
const mtime = new Date('2026-08-01T00:00:00.000Z');
|
||||
await fs.promises.utimes(filePath, mtime, mtime);
|
||||
|
||||
const { archivePath } = await compressCacheAsync({
|
||||
paths: [sourceDir],
|
||||
workingDirectory: sourceDir,
|
||||
verbose: false,
|
||||
logger,
|
||||
});
|
||||
|
||||
const restoreDir = path.join(os.tmpdir(), 'mtime-restored');
|
||||
await fs.promises.mkdir(restoreDir, { recursive: true });
|
||||
await decompressCacheAsync({
|
||||
archivePath,
|
||||
workingDirectory: restoreDir,
|
||||
verbose: false,
|
||||
logger,
|
||||
});
|
||||
|
||||
const restoredStat = await fs.promises.stat(path.join(restoreDir, 'entry'));
|
||||
expect(Math.floor(restoredStat.mtimeMs / 1000)).toBe(Math.floor(mtime.getTime() / 1000));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
|
||||
|
||||
import { resolveAppium3VersionSpec, resolveAppiumDeviceAsync } from '../startAppiumRemoteSession';
|
||||
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
AndroidEmulatorUtils: { getAttachedDevicesAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
IosSimulatorUtils: { getAvailableDevicesAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as never;
|
||||
|
||||
describe(resolveAppium3VersionSpec, () => {
|
||||
it('uses the worker-supported Appium 3 version by default', () => {
|
||||
expect(resolveAppium3VersionSpec(undefined)).toBe('^3');
|
||||
});
|
||||
|
||||
it.each(['3', '^3', '3.x', '3.5.0', '>=3 <4'])('accepts Appium 3 version %s', version => {
|
||||
expect(resolveAppium3VersionSpec(version)).toBe(version);
|
||||
});
|
||||
|
||||
it.each(['2', '2.19.0', 'latest', '4.0.0', '>=3'])('rejects version %s', version => {
|
||||
expect(() => resolveAppium3VersionSpec(version)).toThrow(
|
||||
`Appium 3 is required for EAS Simulator sessions. Received package version "${version}".`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveAppiumDeviceAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns XCUITest capabilities for the booted iOS simulator', async () => {
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'ios-simulator-id' } as never]);
|
||||
|
||||
await expect(
|
||||
resolveAppiumDeviceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
platformName: 'iOS',
|
||||
automationName: 'XCUITest',
|
||||
driverName: 'xcuitest',
|
||||
udid: 'ios-simulator-id',
|
||||
});
|
||||
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledWith({ env: {}, logger });
|
||||
expect(IosSimulatorUtils.getAvailableDevicesAsync).toHaveBeenCalledWith({
|
||||
env: {},
|
||||
filter: 'booted',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns UiAutomator2 capabilities for the booted Android emulator', async () => {
|
||||
jest
|
||||
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
|
||||
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' } as never]);
|
||||
|
||||
await expect(
|
||||
resolveAppiumDeviceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
platformName: 'Android',
|
||||
automationName: 'UiAutomator2',
|
||||
driverName: 'uiautomator2',
|
||||
udid: 'emulator-5554',
|
||||
});
|
||||
expect(AndroidEmulatorUtils.getAttachedDevicesAsync).toHaveBeenCalledWith({ env: {} });
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -8,15 +8,19 @@ import {
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import { Client } from '@urql/core';
|
||||
import contentDisposition from 'content-disposition';
|
||||
import { glob } from 'fast-glob';
|
||||
import { graphql } from 'gql.tada';
|
||||
import fetch from 'node-fetch';
|
||||
import assert from 'node:assert';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import stream from 'stream';
|
||||
import { promisify } from 'util';
|
||||
import { z } from 'zod';
|
||||
import bplistParser from 'bplist-parser';
|
||||
import plist from 'plist';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
@@ -26,6 +30,10 @@ import { pluralize } from '../../utils/strings';
|
||||
|
||||
const streamPipeline = promisify(stream.pipeline);
|
||||
|
||||
type DownloadBuildSource =
|
||||
| { buildId: string; applicationArchiveUrl?: never }
|
||||
| { buildId?: never; applicationArchiveUrl: string };
|
||||
|
||||
const BUILD_BY_ID_QUERY = graphql(`
|
||||
query DownloadBuildByIdQuery($buildId: ID!) {
|
||||
builds {
|
||||
@@ -49,7 +57,12 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'build_id',
|
||||
required: true,
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'application_archive_url',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
@@ -70,12 +83,39 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
|
||||
const extensions = z.array(z.string()).parse(inputs.extensions.value);
|
||||
logger.info(`Expected extensions: [${extensions.join(', ')}]`);
|
||||
const buildId = z.string().uuid().parse(inputs.build_id.value);
|
||||
logger.info(`Downloading build ${buildId}...`);
|
||||
const buildId = inputs.build_id.value
|
||||
? z.string().uuid().parse(inputs.build_id.value)
|
||||
: undefined;
|
||||
const applicationArchiveUrl = inputs.application_archive_url.value
|
||||
? parseHttpApplicationArchiveUrl(inputs.application_archive_url.value)
|
||||
: undefined;
|
||||
|
||||
let source: DownloadBuildSource;
|
||||
if (buildId) {
|
||||
if (applicationArchiveUrl) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
|
||||
'Pass only one of build_id or application_archive_url.'
|
||||
);
|
||||
}
|
||||
source = { buildId };
|
||||
} else {
|
||||
if (!applicationArchiveUrl) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
|
||||
'Pass build_id or application_archive_url.'
|
||||
);
|
||||
}
|
||||
source = { applicationArchiveUrl };
|
||||
}
|
||||
|
||||
logger.info(
|
||||
buildId ? `Downloading build ${buildId}...` : `Downloading application archive...`
|
||||
);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger,
|
||||
buildId,
|
||||
...source,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
robotAccessToken: stepsCtx.global.staticContext.job.secrets?.robotAccessToken ?? null,
|
||||
extensions,
|
||||
@@ -115,39 +155,50 @@ async function fetchApplicationArchiveUrlAsync({
|
||||
return applicationArchiveUrl;
|
||||
}
|
||||
|
||||
export async function downloadBuildAsync({
|
||||
logger,
|
||||
buildId,
|
||||
graphqlClient,
|
||||
robotAccessToken,
|
||||
extensions,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
buildId: string;
|
||||
graphqlClient: Client;
|
||||
robotAccessToken: string | null;
|
||||
extensions: string[];
|
||||
}): Promise<{ artifactPath: string }> {
|
||||
export async function downloadBuildAsync(
|
||||
params: DownloadBuildSource & {
|
||||
logger: bunyan;
|
||||
graphqlClient: Client;
|
||||
robotAccessToken: string | null;
|
||||
extensions: string[];
|
||||
}
|
||||
): Promise<{ artifactPath: string }> {
|
||||
const { logger, graphqlClient, robotAccessToken, extensions } = params;
|
||||
|
||||
let downloadUrl: string;
|
||||
let headers: { Authorization: string } | undefined;
|
||||
if (params.applicationArchiveUrl) {
|
||||
if (params.buildId) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
|
||||
'Pass only one of buildId or applicationArchiveUrl.'
|
||||
);
|
||||
}
|
||||
downloadUrl = parseHttpApplicationArchiveUrl(params.applicationArchiveUrl);
|
||||
headers = undefined;
|
||||
} else if (params.buildId) {
|
||||
const buildId = z.string().uuid().parse(params.buildId);
|
||||
downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient });
|
||||
headers = robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined;
|
||||
} else {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
|
||||
'Pass buildId or applicationArchiveUrl.'
|
||||
);
|
||||
}
|
||||
|
||||
const downloadDestinationDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'download_build-downloaded-')
|
||||
);
|
||||
|
||||
const downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient });
|
||||
|
||||
const response = await retryOnDNSFailure(fetch)(downloadUrl, {
|
||||
headers: robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined,
|
||||
});
|
||||
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
const textResult = await asyncResult(response.text());
|
||||
throw new Error(`Unexpected response from server (${response.status}): ${textResult.value}`);
|
||||
}
|
||||
|
||||
// URL may contain percent-encoded characters, e.g. my%20file.apk
|
||||
// this replaces all non-alphanumeric characters (excluding dot) with underscore
|
||||
const archiveFilename = path
|
||||
.basename(new URL(response.url).pathname)
|
||||
.replace(/([^a-z0-9.-]+)/gi, '_');
|
||||
const archiveFilename = resolveArchiveFilename({ response, extensions });
|
||||
const archivePath = path.join(downloadDestinationDirectory, archiveFilename);
|
||||
|
||||
await streamPipeline(response.body, fs.createWriteStream(archivePath));
|
||||
@@ -177,6 +228,18 @@ export async function downloadBuildAsync({
|
||||
onlyFiles: false,
|
||||
onlyDirectories: false,
|
||||
});
|
||||
let matchingFilesRoot = extractionDirectory;
|
||||
|
||||
if (
|
||||
matchingFiles.length === 0 &&
|
||||
extensions.includes('app') &&
|
||||
(await isIosAppBundleAsync(extractionDirectory))
|
||||
) {
|
||||
const appBundlePath = `${extractionDirectory}.app`;
|
||||
await fs.promises.rename(extractionDirectory, appBundlePath);
|
||||
matchingFiles.push(appBundlePath);
|
||||
matchingFilesRoot = path.dirname(appBundlePath);
|
||||
}
|
||||
|
||||
if (matchingFiles.length === 0) {
|
||||
throw new UserError(
|
||||
@@ -189,8 +252,68 @@ export async function downloadBuildAsync({
|
||||
`Found ${matchingFiles.length} matching ${pluralize(
|
||||
matchingFiles.length,
|
||||
'entry'
|
||||
)}:\n${matchingFiles.map(f => `- ${path.relative(extractionDirectory, f)}`).join('\n')}`
|
||||
)}:\n${matchingFiles.map(f => `- ${path.relative(matchingFilesRoot, f)}`).join('\n')}`
|
||||
);
|
||||
|
||||
return { artifactPath: matchingFiles[0] };
|
||||
}
|
||||
|
||||
async function isIosAppBundleAsync(directory: string): Promise<boolean> {
|
||||
try {
|
||||
const infoPlist = await fs.promises.readFile(path.join(directory, 'Info.plist'));
|
||||
const isBinaryPlist = infoPlist.subarray(0, 8).toString('ascii') === 'bplist00';
|
||||
const parsedInfoPlist = (
|
||||
isBinaryPlist
|
||||
? bplistParser.parseBuffer(infoPlist)[0]
|
||||
: plist.parse(infoPlist.toString('utf8'))
|
||||
) as Record<string, unknown> | undefined;
|
||||
|
||||
return parsedInfoPlist?.CFBundlePackageType === 'APPL';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseHttpApplicationArchiveUrl(value: unknown): string {
|
||||
try {
|
||||
const applicationArchiveUrl = z.string().parse(value);
|
||||
const parsedUrl = new URL(applicationArchiveUrl);
|
||||
assert(parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:');
|
||||
return applicationArchiveUrl;
|
||||
} catch {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_INVALID_APPLICATION_ARCHIVE_URL',
|
||||
'application_archive_url must be a valid HTTP or HTTPS URL.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveArchiveFilename({
|
||||
response,
|
||||
extensions,
|
||||
}: {
|
||||
response: Awaited<ReturnType<typeof fetch>>;
|
||||
extensions: string[];
|
||||
}): string {
|
||||
const contentDispositionHeader = response.headers.get('content-disposition');
|
||||
let headerFilename: string | undefined;
|
||||
if (contentDispositionHeader) {
|
||||
try {
|
||||
headerFilename = contentDisposition.parse(contentDispositionHeader).parameters.filename;
|
||||
} catch {
|
||||
// Ignore malformed Content-Disposition headers and fall back to the response URL.
|
||||
}
|
||||
}
|
||||
|
||||
const urlFilename = path.basename(new URL(response.url).pathname);
|
||||
let archiveFilename = path.basename(headerFilename ?? urlFilename ?? '');
|
||||
if (!archiveFilename || archiveFilename === '.' || archiveFilename === '..') {
|
||||
archiveFilename = 'application';
|
||||
}
|
||||
if (!path.extname(archiveFilename) && extensions.length === 1) {
|
||||
archiveFilename = `${archiveFilename}.${extensions[0]}`;
|
||||
}
|
||||
|
||||
// URL and header filenames may contain percent-encoded or unsafe filesystem characters.
|
||||
return archiveFilename.replace(/([^a-z0-9.-]+)/gi, '_');
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepEnv,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
export function createInstallBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'install_build',
|
||||
name: 'Install build',
|
||||
__metricsId: 'eas/install_build',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'artifact_path',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'application_identifier',
|
||||
required: true,
|
||||
}),
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'activity_name',
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
fn: async ({ global, logger }, { inputs, outputs, env }) => {
|
||||
const artifactPath = z.string().min(1).parse(inputs.artifact_path.value);
|
||||
const { applicationIdentifier, activityName } = await installBuildAsync({
|
||||
artifactPath,
|
||||
runtimePlatform: global.runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
outputs.application_identifier.set(applicationIdentifier);
|
||||
if (activityName) {
|
||||
outputs.activity_name.set(activityName);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function installBuildAsync({
|
||||
artifactPath,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
artifactPath: string;
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<{ applicationIdentifier: string; activityName?: string }> {
|
||||
const artifactStat = await fs.promises.stat(artifactPath).catch(err => {
|
||||
throw new UserError(
|
||||
'EAS_INSTALL_BUILD_INVALID_ARTIFACT',
|
||||
`Build artifact does not exist at ${artifactPath}.`,
|
||||
{ cause: err }
|
||||
);
|
||||
});
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
if (path.extname(artifactPath) !== '.app' || !artifactStat.isDirectory()) {
|
||||
throw new UserError(
|
||||
'EAS_INSTALL_BUILD_INVALID_ARTIFACT',
|
||||
'iOS Simulator sessions require a .app build artifact.'
|
||||
);
|
||||
}
|
||||
|
||||
const infoPlistPath = path.join(artifactPath, 'Info.plist');
|
||||
const { stdout } = await spawn(
|
||||
'plutil',
|
||||
['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', infoPlistPath],
|
||||
{ stdio: 'pipe', env }
|
||||
);
|
||||
const applicationIdentifier = stdout.trim();
|
||||
if (!applicationIdentifier) {
|
||||
throw new UserError(
|
||||
'EAS_INSTALL_BUILD_MISSING_IDENTIFIER',
|
||||
`Could not read CFBundleIdentifier from ${infoPlistPath}.`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Installing ${artifactPath} on the iOS Simulator.`);
|
||||
await spawn('xcrun', ['simctl', 'install', 'booted', artifactPath], { env, logger });
|
||||
return { applicationIdentifier };
|
||||
}
|
||||
|
||||
if (path.extname(artifactPath) !== '.apk' || !artifactStat.isFile()) {
|
||||
throw new UserError(
|
||||
'EAS_INSTALL_BUILD_INVALID_ARTIFACT',
|
||||
'Android Emulator sessions require an .apk build artifact.'
|
||||
);
|
||||
}
|
||||
|
||||
const { stdout } = await spawn('aapt2', ['dump', 'badging', artifactPath], {
|
||||
stdio: 'pipe',
|
||||
env,
|
||||
});
|
||||
const applicationIdentifier = stdout.match(/package: name='([^']+)'/)?.[1];
|
||||
const activityName = stdout.match(/launchable-activity: name='([^']+)'/)?.[1];
|
||||
if (!applicationIdentifier) {
|
||||
throw new UserError(
|
||||
'EAS_INSTALL_BUILD_MISSING_IDENTIFIER',
|
||||
`Could not read an Android application identifier from ${artifactPath}.`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Installing ${artifactPath} on the Android Emulator.`);
|
||||
await spawn('adb', ['install', '-r', artifactPath], { env, logger });
|
||||
return { applicationIdentifier, activityName };
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
@@ -14,9 +16,15 @@ import assert from 'assert';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
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({
|
||||
@@ -120,19 +128,66 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
await installIdbFromBrew({ logger, env });
|
||||
}
|
||||
|
||||
// Skip installing if the input sets a specific Maestro version to install
|
||||
// and it is already installed which happens when developing on a local computer.
|
||||
if (
|
||||
!currentMaestroVersion ||
|
||||
(requestedVersion && requestedVersion !== currentMaestroVersion)
|
||||
) {
|
||||
switch (backend) {
|
||||
case 'maestro':
|
||||
switch (backend) {
|
||||
case 'maestro':
|
||||
// Skip installing if the input sets a specific Maestro version to install
|
||||
// and it is already installed, either on a build image or a local computer.
|
||||
if (
|
||||
!currentMaestroVersion ||
|
||||
(requestedVersion && requestedVersion !== currentMaestroVersion)
|
||||
) {
|
||||
await installMaestro({ version: requestedVersion, global, logger, env });
|
||||
break;
|
||||
case 'maestro-runner':
|
||||
await installMaestroRunner({ version: requestedVersion, global, logger, env });
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 'maestro-runner': {
|
||||
let maestroRunnerVersionToInstall = requestedVersion;
|
||||
const currentMaestroRunnerVersion = semver.coerce(currentMaestroVersion)?.version;
|
||||
const requestedMaestroRunnerVersion =
|
||||
requestedVersion && requestedVersion !== 'latest'
|
||||
? semver.valid(requestedVersion)
|
||||
: null;
|
||||
if (
|
||||
global.runtimePlatform === BuildRuntimePlatform.DARWIN &&
|
||||
((requestedMaestroRunnerVersion &&
|
||||
semver.gte(requestedMaestroRunnerVersion, '1.1.16')) ||
|
||||
requestedVersion === 'latest' ||
|
||||
(requestedVersion === undefined &&
|
||||
(!currentMaestroRunnerVersion || semver.gt(currentMaestroRunnerVersion, '1.1.15'))))
|
||||
) {
|
||||
// `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 (xcodeVersion && semver.lt(xcodeVersion, '26.0.0')) {
|
||||
if (requestedMaestroRunnerVersion) {
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_INVALID_INPUT',
|
||||
`maestro-runner ${requestedVersion} is not compatible with Xcode ${xcodeVersion}. Use maestro-runner 1.1.15 or an Xcode 26+ image.`
|
||||
);
|
||||
}
|
||||
maestroRunnerVersionToInstall = '1.1.15';
|
||||
logger.info(
|
||||
`Xcode ${xcodeVersion} requires maestro-runner ${maestroRunnerVersionToInstall}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!currentMaestroVersion ||
|
||||
(maestroRunnerVersionToInstall &&
|
||||
maestroRunnerVersionToInstall !== currentMaestroVersion)
|
||||
) {
|
||||
await installMaestroRunner({
|
||||
version: maestroRunnerVersionToInstall,
|
||||
global,
|
||||
logger,
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,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);
|
||||
|
||||
@@ -154,6 +216,142 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function getMaestroVersion({
|
||||
env,
|
||||
backend,
|
||||
@@ -211,10 +409,10 @@ async function installMaestroRunner({
|
||||
const binDir = path.join(env.HOME, '.maestro-runner', 'bin');
|
||||
global.updateEnv({
|
||||
...global.env,
|
||||
PATH: `${global.env.PATH}:${binDir}`,
|
||||
PATH: `${binDir}:${global.env.PATH}`,
|
||||
});
|
||||
env.PATH = `${env.PATH}:${binDir}`;
|
||||
process.env.PATH = `${process.env.PATH}:${binDir}`;
|
||||
env.PATH = `${binDir}:${env.PATH}`;
|
||||
process.env.PATH = `${binDir}:${process.env.PATH}`;
|
||||
} finally {
|
||||
await fs.promises.rm(tempDirectory, { force: true, recursive: true });
|
||||
}
|
||||
@@ -261,10 +459,10 @@ async function installMaestro({
|
||||
const maestroBinDir = path.join(maestroDir, 'bin');
|
||||
global.updateEnv({
|
||||
...global.env,
|
||||
PATH: `${global.env.PATH}:${maestroBinDir}`,
|
||||
PATH: `${maestroBinDir}:${global.env.PATH}`,
|
||||
});
|
||||
env.PATH = `${env.PATH}:${maestroBinDir}`;
|
||||
process.env.PATH = `${process.env.PATH}:${maestroBinDir}`;
|
||||
env.PATH = `${maestroBinDir}:${env.PATH}`;
|
||||
process.env.PATH = `${maestroBinDir}:${process.env.PATH}`;
|
||||
} finally {
|
||||
await fs.promises.rm(tempDirectory, { force: true, recursive: true });
|
||||
}
|
||||
@@ -272,37 +470,64 @@ async function installMaestro({
|
||||
|
||||
async function isIdbInstalled({ env }: { env: BuildStepEnv }): Promise<boolean> {
|
||||
try {
|
||||
await spawn('idb', ['-h'], { ignoreStdio: true, env });
|
||||
await spawn('idb_companion', ['--version'], { ignoreStdio: true, env });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function installIdbFromBrew({
|
||||
export async function installIdbFromBrew({
|
||||
logger,
|
||||
env,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<void> {
|
||||
// Unfortunately our Mac images sometimes have two Homebrew
|
||||
// installations. We should use the ARM64 one, located in /opt/homebrew.
|
||||
const brewPath = '/opt/homebrew/bin/brew';
|
||||
const localEnv = {
|
||||
...env,
|
||||
HOMEBREW_NO_AUTO_UPDATE: '1',
|
||||
HOMEBREW_NO_INSTALL_CLEANUP: '1',
|
||||
};
|
||||
try {
|
||||
// Unfortunately our Mac images sometimes have two Homebrew
|
||||
// installations. We should use the ARM64 one, located in /opt/homebrew.
|
||||
const brewPath = '/opt/homebrew/bin/brew';
|
||||
const localEnv = {
|
||||
...env,
|
||||
HOMEBREW_NO_AUTO_UPDATE: '1',
|
||||
HOMEBREW_NO_INSTALL_CLEANUP: '1',
|
||||
};
|
||||
|
||||
await spawn(brewPath, ['tap', 'facebook/fb'], {
|
||||
env: localEnv,
|
||||
logger,
|
||||
});
|
||||
await spawn(brewPath, ['install', 'idb-companion'], {
|
||||
env: localEnv,
|
||||
logger,
|
||||
});
|
||||
logger.info('Tapping facebook/fb...');
|
||||
await spawn(brewPath, ['tap', 'facebook/fb'], {
|
||||
env: localEnv,
|
||||
logger,
|
||||
});
|
||||
|
||||
const brewRepo = await spawn(brewPath, ['--repo', 'facebook/fb'], {
|
||||
env: localEnv,
|
||||
});
|
||||
const tapPath = brewRepo.stdout.trim();
|
||||
|
||||
// c0386793f59da10c619787f2aa18d938ef1d69c9 is hash for 1.1.8 release,
|
||||
// last known compatible version + post_install fix.
|
||||
const gitSha = 'c0386793f59da10c619787f2aa18d938ef1d69c9';
|
||||
logger.info('Checking out facebook/fb at [email protected]...');
|
||||
await spawn('git', ['fetch', 'origin', gitSha], {
|
||||
cwd: tapPath,
|
||||
logger,
|
||||
});
|
||||
await spawn('git', ['checkout', gitSha], {
|
||||
cwd: tapPath,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info('Installing idb_companion v1.1.8...');
|
||||
await spawn(brewPath, ['install', 'facebook/fb/idb-companion'], {
|
||||
env: localEnv,
|
||||
logger,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new SystemError('Failed to install idb-companion required for Maestro to run.', {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function isJavaInstalled({ env }: { env: BuildStepEnv }): Promise<boolean> {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepEnv,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
export function createLaunchApplicationFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'launch_application',
|
||||
name: 'Launch application',
|
||||
__metricsId: 'eas/launch_application',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'application_identifier',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'activity_name',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'launch_args',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'open_url',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
fn: async ({ global, logger }, { inputs, env }) => {
|
||||
const applicationIdentifier = parseNonEmptyStringInput(
|
||||
inputs.application_identifier.value,
|
||||
'application_identifier'
|
||||
);
|
||||
const activityName =
|
||||
inputs.activity_name.value === undefined
|
||||
? undefined
|
||||
: parseNonEmptyStringInput(inputs.activity_name.value, 'activity_name');
|
||||
const launchArgs = parseLaunchArgsInput(inputs.launch_args.value);
|
||||
const openUrl =
|
||||
inputs.open_url.value === undefined ? undefined : parseOpenUrlInput(inputs.open_url.value);
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier,
|
||||
activityName,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
runtimePlatform: global.runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function launchApplicationAsync({
|
||||
applicationIdentifier,
|
||||
activityName,
|
||||
launchArgs = [],
|
||||
openUrl,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
applicationIdentifier: string;
|
||||
activityName?: string;
|
||||
launchArgs?: string[];
|
||||
openUrl?: string;
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
logApplicationLaunch(logger, applicationIdentifier, launchArgs);
|
||||
await spawn('xcrun', ['simctl', 'launch', 'booted', applicationIdentifier, ...launchArgs], {
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
if (openUrl) {
|
||||
logger.info(`Opening ${openUrl} in ${applicationIdentifier}.`);
|
||||
await spawn('xcrun', ['simctl', 'openurl', 'booted', openUrl], { env, logger });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activityName) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_MISSING_ACTIVITY',
|
||||
'Launching an Android application requires activity_name.'
|
||||
);
|
||||
}
|
||||
|
||||
logApplicationLaunch(logger, applicationIdentifier, launchArgs);
|
||||
// Android does not support process arguments like iOS. Pass raw `am start` Intent
|
||||
// arguments instead, such as `--es key value` or `--ez key true`.
|
||||
await spawn(
|
||||
'adb',
|
||||
['shell', 'am', 'start', ...launchArgs, '-n', `${applicationIdentifier}/${activityName}`],
|
||||
{
|
||||
env,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
if (openUrl) {
|
||||
logger.info(`Opening ${openUrl} in ${applicationIdentifier}.`);
|
||||
await spawn(
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-a',
|
||||
'android.intent.action.VIEW',
|
||||
'-d',
|
||||
openUrl,
|
||||
'-n',
|
||||
`${applicationIdentifier}/${activityName}`,
|
||||
],
|
||||
{ env, logger }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function logApplicationLaunch(
|
||||
logger: bunyan,
|
||||
applicationIdentifier: string,
|
||||
launchArgs: string[]
|
||||
): void {
|
||||
const argumentsDescription =
|
||||
launchArgs.length > 0 ? ` with arguments ${JSON.stringify(launchArgs)}` : '';
|
||||
logger.info(`Launching ${applicationIdentifier}${argumentsDescription}.`);
|
||||
}
|
||||
|
||||
function parseNonEmptyStringInput(value: unknown, inputName: string): string {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
`Input "${inputName}" must be a non-empty string. Pass the "${inputName}" output from eas/install_build.`
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseLaunchArgsInput(value: unknown): string[] {
|
||||
if (value === undefined) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(value) || !value.every(argument => typeof argument === 'string')) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
'Input "launch_args" must be an array of strings.'
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseOpenUrlInput(value: unknown): string {
|
||||
const openUrl = parseNonEmptyStringInput(value, 'open_url');
|
||||
if (!URL.canParse(openUrl)) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
'Input "open_url" must be a valid URL.'
|
||||
);
|
||||
}
|
||||
return openUrl;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { asyncResult } from '@expo/results';
|
||||
import { XMLBuilder, XMLParser, XMLValidator } from 'fast-xml-parser';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface MaestroFlowResult {
|
||||
name: string;
|
||||
@@ -34,10 +35,16 @@ const xmlParser = new XMLParser({
|
||||
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
|
||||
});
|
||||
|
||||
// A `file=` attribute counts as present only when it is a non-empty string.
|
||||
// Official Maestro writes the flow path as a `file=` testcase attribute. maestro-runner writes
|
||||
// the same value as a `<property name="file" value="..."/>` child.
|
||||
function fileAttrOf(tc: any): string | undefined {
|
||||
const f = tc?.['@_file'];
|
||||
return typeof f === 'string' && f.length > 0 ? f : undefined;
|
||||
if (typeof f === 'string' && f.length > 0) {
|
||||
return f;
|
||||
}
|
||||
const properties: { '@_name'?: unknown; '@_value'?: unknown }[] = tc?.properties?.property ?? [];
|
||||
const fileProperty = properties.find(property => property['@_name'] === 'file')?.['@_value'];
|
||||
return typeof fileProperty === 'string' && fileProperty.length > 0 ? fileProperty : undefined;
|
||||
}
|
||||
|
||||
function parseJUnitContent(content: string): JUnitTestCaseResult[] {
|
||||
@@ -62,26 +69,46 @@ function parseJUnitContent(content: string): JUnitTestCaseResult[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Standard JUnit marks skipped tests with a <skipped/> child (no failure/error). Exclude
|
||||
// them so they aren't miscounted as passed, matching the report.json path which drops
|
||||
// skipped flows.
|
||||
if (tc.skipped != null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const file = fileAttrOf(tc);
|
||||
|
||||
const timeStr = tc['@_time'];
|
||||
const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
|
||||
const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
|
||||
|
||||
const status: 'passed' | 'failed' = tc['@_status'] === 'SUCCESS' ? 'passed' : 'failed';
|
||||
// maestro-runner puts the real error in the `message` attribute and the command
|
||||
// label (e.g. `tapOn`) in the body; official Maestro only writes the body. Prefer
|
||||
// `@_message` and fall back to `#text` so both stay correct.
|
||||
const failureText =
|
||||
tc.failure != null
|
||||
? typeof tc.failure === 'string'
|
||||
? tc.failure
|
||||
: (tc.failure?.['#text'] ?? null)
|
||||
: (tc.failure?.['@_message'] ?? tc.failure?.['#text'] ?? null)
|
||||
: null;
|
||||
const errorText =
|
||||
tc.error != null
|
||||
? typeof tc.error === 'string'
|
||||
? tc.error
|
||||
: (tc.error?.['#text'] ?? null)
|
||||
: (tc.error?.['@_message'] ?? tc.error?.['#text'] ?? null)
|
||||
: null;
|
||||
const errorMessage: string | null = failureText ?? errorText ?? null;
|
||||
// Official Maestro uses status="SUCCESS". maestro-runner uses standard JUnit semantics:
|
||||
// a testcase passes when it has no failure or error child.
|
||||
const statusAttribute = tc['@_status'];
|
||||
const status: 'passed' | 'failed' =
|
||||
typeof statusAttribute === 'string'
|
||||
? statusAttribute === 'SUCCESS'
|
||||
? 'passed'
|
||||
: 'failed'
|
||||
: tc.failure == null && tc.error == null
|
||||
? 'passed'
|
||||
: 'failed';
|
||||
|
||||
const rawProperties: { '@_name': string; '@_value': string }[] =
|
||||
tc.properties?.property ?? [];
|
||||
@@ -176,6 +203,69 @@ async function fileExists(absPath: string): Promise<boolean> {
|
||||
return (await asyncResult(fs.stat(absPath))).ok;
|
||||
}
|
||||
|
||||
// maestro-runner writes report.json as the source of truth for a run. Use it for runner control
|
||||
// flow because sourceFile preserves the exact flow path, while older runner JUnit reports flatten
|
||||
// it to a basename. Keep only passed and failed flows so the result matches Maestro JUnit reports,
|
||||
// which do not include skipped flows.
|
||||
const MaestroRunnerRecordedFlowSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
sourceFile: z.string().min(1),
|
||||
status: z.enum(['passed', 'failed']),
|
||||
});
|
||||
const MaestroRunnerReportSchema = z.object({
|
||||
flows: z
|
||||
.array(
|
||||
z.discriminatedUnion('status', [
|
||||
MaestroRunnerRecordedFlowSchema,
|
||||
z.object({ status: z.literal('skipped') }),
|
||||
])
|
||||
)
|
||||
.transform(flows =>
|
||||
flows.filter(
|
||||
(flow): flow is z.infer<typeof MaestroRunnerRecordedFlowSchema> => flow.status !== 'skipped'
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
type MaestroRunnerReport = z.infer<typeof MaestroRunnerReportSchema>;
|
||||
|
||||
export async function parseFailedFlowsFromMaestroRunnerReport(args: {
|
||||
reportDirectory: string;
|
||||
workingDirectory: string;
|
||||
}): Promise<string[] | null> {
|
||||
const report = await parseMaestroRunnerReport(args.reportDirectory);
|
||||
if (report === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const failedPaths = [
|
||||
...new Set(report.flows.filter(flow => flow.status === 'failed').map(flow => flow.sourceFile)),
|
||||
];
|
||||
if (failedPaths.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const flowPath of failedPaths) {
|
||||
if (!(await fileExists(path.resolve(args.workingDirectory, flowPath)))) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return failedPaths;
|
||||
}
|
||||
|
||||
export async function parseMaestroRunnerReport(
|
||||
reportDirectory: string
|
||||
): Promise<MaestroRunnerReport | null> {
|
||||
let report: unknown;
|
||||
try {
|
||||
report = JSON.parse(await fs.readFile(path.join(reportDirectory, 'report.json'), 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const result = MaestroRunnerReportSchema.safeParse(report);
|
||||
return result.success ? result.data : null;
|
||||
}
|
||||
|
||||
// Group by `file=` so two same-named flows in different files stay separate.
|
||||
export async function parseMaestroResultsFromFileAttrs(
|
||||
junitDirectory: string
|
||||
|
||||
@@ -2,6 +2,9 @@ import { bunyan } from '@expo/logger';
|
||||
import { type Dirent } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Sentry } from '../../sentry';
|
||||
|
||||
export interface ParsedFailureScreenshot {
|
||||
flowName: string;
|
||||
@@ -133,6 +136,156 @@ export async function harvestFailureScreenshotsAsync(args: {
|
||||
return [...legacyShots, ...dedupeBundleShotsByFlowName(bundleShots)];
|
||||
}
|
||||
|
||||
const MaestroRunnerCommandSchema = z.object({
|
||||
status: z.string().optional(),
|
||||
artifacts: z
|
||||
.object({
|
||||
screenshotBefore: z.string().optional(),
|
||||
screenshotAfter: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
get subCommands() {
|
||||
return z.array(MaestroRunnerCommandSchema).optional();
|
||||
},
|
||||
});
|
||||
type MaestroRunnerCommand = z.infer<typeof MaestroRunnerCommandSchema>;
|
||||
|
||||
const MaestroRunnerFlowDetailSchema = z.object({
|
||||
commands: z.array(MaestroRunnerCommandSchema).optional(),
|
||||
});
|
||||
|
||||
const MaestroRunnerScreenshotReportSchema = z.object({
|
||||
flows: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
dataFile: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// maestro-runner records failure screenshot paths in its report JSON instead of using the
|
||||
// official Maestro debug-directory layout. Never throws, so screenshots cannot change the test
|
||||
// result.
|
||||
export async function harvestMaestroRunnerFailureScreenshotsAsync(args: {
|
||||
reportDirectory: string;
|
||||
capturedSinceMs: number;
|
||||
attemptIndex: number;
|
||||
logger: bunyan;
|
||||
}): Promise<HarvestedScreenshot[]> {
|
||||
let report: unknown;
|
||||
try {
|
||||
report = JSON.parse(await fs.readFile(path.join(args.reportDirectory, 'report.json'), 'utf8'));
|
||||
} catch (err: any) {
|
||||
args.logger.info(
|
||||
{ err },
|
||||
`Skipping maestro-runner screenshot harvest: cannot read ${args.reportDirectory}.`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsedReport = MaestroRunnerScreenshotReportSchema.safeParse(report);
|
||||
if (!parsedReport.success) {
|
||||
args.logger.warn(
|
||||
{ err: parsedReport.error },
|
||||
`Skipping maestro-runner screenshot harvest: unexpected report.json shape in ${args.reportDirectory}.`
|
||||
);
|
||||
Sentry.capture('maestro-runner report.json failed schema validation', parsedReport.error);
|
||||
return [];
|
||||
}
|
||||
const flows = parsedReport.data.flows ?? [];
|
||||
|
||||
const shots: HarvestedScreenshot[] = [];
|
||||
for (const { name, status, dataFile } of flows) {
|
||||
if (status !== 'failed' || !name || !dataFile) {
|
||||
continue;
|
||||
}
|
||||
const flowDataPath = resolvePathInsideDirectory(args.reportDirectory, dataFile);
|
||||
if (!flowDataPath) {
|
||||
args.logger.info(`Skipping maestro-runner flow data outside the report directory.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let screenshotPath: string | undefined;
|
||||
try {
|
||||
const detail = MaestroRunnerFlowDetailSchema.safeParse(
|
||||
JSON.parse(await fs.readFile(flowDataPath, 'utf8'))
|
||||
);
|
||||
if (!detail.success) {
|
||||
args.logger.warn(
|
||||
{ err: detail.error },
|
||||
`Skipping malformed maestro-runner flow data ${flowDataPath}.`
|
||||
);
|
||||
Sentry.capture('maestro-runner flow data failed schema validation', detail.error);
|
||||
continue;
|
||||
}
|
||||
screenshotPath = findFailedMaestroRunnerScreenshot(detail.data.commands ?? []);
|
||||
} catch (err: any) {
|
||||
args.logger.info({ err }, `Skipping unreadable maestro-runner flow data ${flowDataPath}.`);
|
||||
continue;
|
||||
}
|
||||
if (!screenshotPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileAbsPath = resolvePathInsideDirectory(args.reportDirectory, screenshotPath);
|
||||
if (!fileAbsPath) {
|
||||
args.logger.info(`Skipping maestro-runner screenshot outside the report directory.`);
|
||||
continue;
|
||||
}
|
||||
let capturedAtMs: number;
|
||||
try {
|
||||
capturedAtMs = Math.round((await fs.stat(fileAbsPath)).mtimeMs);
|
||||
} catch (err: any) {
|
||||
args.logger.info({ err }, `Skipping unreadable screenshot ${fileAbsPath}.`);
|
||||
continue;
|
||||
}
|
||||
if (capturedAtMs < args.capturedSinceMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const flowName = normalizeFlowName(name);
|
||||
shots.push({
|
||||
fileAbsPath,
|
||||
displayName: `Failure Screenshot: ${flowName} (attempt ${args.attemptIndex + 1})`,
|
||||
metadata: {
|
||||
kind: 'maestro-test-screenshot',
|
||||
flowName,
|
||||
attemptIndex: args.attemptIndex,
|
||||
capturedAtMs,
|
||||
},
|
||||
});
|
||||
}
|
||||
return shots;
|
||||
}
|
||||
|
||||
function findFailedMaestroRunnerScreenshot(
|
||||
commands: readonly MaestroRunnerCommand[]
|
||||
): string | undefined {
|
||||
for (const command of commands) {
|
||||
if (command.status !== 'failed') {
|
||||
continue;
|
||||
}
|
||||
const nested = findFailedMaestroRunnerScreenshot(command.subCommands ?? []);
|
||||
const screenshot =
|
||||
nested ?? command.artifacts?.screenshotAfter ?? command.artifacts?.screenshotBefore;
|
||||
if (screenshot) {
|
||||
return screenshot;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolvePathInsideDirectory(directory: string, relativePath: string): string | null {
|
||||
const candidate = path.resolve(directory, relativePath);
|
||||
const relative = path.relative(directory, candidate);
|
||||
return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)
|
||||
? candidate
|
||||
: null;
|
||||
}
|
||||
|
||||
// Maestro >= 2.7.0: resolve a bundle dir to its owning failed flow and return that flow's failure
|
||||
// screenshot — the highest-numbered step in `screenshots/`. A required failure halts the flow, so
|
||||
// the failed step is normally the last (highest-numbered) captured step; earlier warned steps have
|
||||
|
||||
@@ -13,6 +13,7 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { MaestroBackend, resolveMaestroBackend } from './maestroBackend';
|
||||
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
|
||||
import {
|
||||
copyLatestAttemptXml,
|
||||
@@ -21,12 +22,15 @@ import {
|
||||
parseFailedFlowNamesFromJUnitFile,
|
||||
parseFailedFlowsFromFileAttrs,
|
||||
parseFailedFlowsFromJUnit,
|
||||
parseFailedFlowsFromMaestroRunnerReport,
|
||||
parseJUnitTestCases,
|
||||
parseMaestroRunnerReport,
|
||||
} from './maestroResultParser';
|
||||
import {
|
||||
type HarvestedScreenshot,
|
||||
computePureFailureFlowNames,
|
||||
harvestFailureScreenshotsAsync,
|
||||
harvestMaestroRunnerFailureScreenshotsAsync,
|
||||
selectFailureScreenshots,
|
||||
} from './maestroScreenshots';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
@@ -62,35 +66,58 @@ function isFilesystemError(err: any): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// `outputPath: null` means "let maestro pick" (no --output flag). Junit and
|
||||
// other declared formats pass an explicit path so downstream upload steps
|
||||
// know where to find the result.
|
||||
function buildMaestroArgs(args: {
|
||||
flow_path: string[];
|
||||
outputPath: string | null;
|
||||
output_format: string | undefined;
|
||||
function buildMaestroArgs({
|
||||
backend,
|
||||
platform,
|
||||
flowPaths,
|
||||
output,
|
||||
outputFormat,
|
||||
shards,
|
||||
includeTags,
|
||||
excludeTags,
|
||||
}: {
|
||||
backend: MaestroBackend;
|
||||
platform: 'ios' | 'android';
|
||||
flowPaths: string[];
|
||||
output: string | null;
|
||||
outputFormat: string | undefined;
|
||||
shards: number | undefined;
|
||||
include_tags: string | undefined;
|
||||
exclude_tags: string | undefined;
|
||||
}): string[] {
|
||||
const out: string[] = ['test'];
|
||||
if (args.output_format) {
|
||||
out.push(`--format=${args.output_format.toUpperCase()}`);
|
||||
includeTags: string | undefined;
|
||||
excludeTags: string | undefined;
|
||||
}): { executable: MaestroBackend; args: string[] } {
|
||||
switch (backend) {
|
||||
case 'maestro': {
|
||||
const args = ['test'];
|
||||
if (outputFormat) {
|
||||
args.push(`--format=${outputFormat.toUpperCase()}`);
|
||||
}
|
||||
if (output) {
|
||||
args.push(`--output=${output}`);
|
||||
}
|
||||
if (shards !== undefined) {
|
||||
args.push(`--shard-split=${shards}`);
|
||||
}
|
||||
if (includeTags) {
|
||||
args.push(`--include-tags=${includeTags}`);
|
||||
}
|
||||
if (excludeTags) {
|
||||
args.push(`--exclude-tags=${excludeTags}`);
|
||||
}
|
||||
args.push(...flowPaths);
|
||||
return { executable: 'maestro', args };
|
||||
}
|
||||
case 'maestro-runner': {
|
||||
const args = [`--platform=${platform}`, 'test', `--output=${output}`, '--flatten'];
|
||||
if (includeTags) {
|
||||
args.push(`--include-tags=${includeTags}`);
|
||||
}
|
||||
if (excludeTags) {
|
||||
args.push(`--exclude-tags=${excludeTags}`);
|
||||
}
|
||||
args.push(...flowPaths);
|
||||
return { executable: 'maestro-runner', args };
|
||||
}
|
||||
}
|
||||
if (args.outputPath) {
|
||||
out.push(`--output=${args.outputPath}`);
|
||||
}
|
||||
if (args.shards !== undefined) {
|
||||
out.push(`--shard-split=${args.shards}`);
|
||||
}
|
||||
if (args.include_tags) {
|
||||
out.push(`--include-tags=${args.include_tags}`);
|
||||
}
|
||||
if (args.exclude_tags) {
|
||||
out.push(`--exclude-tags=${args.exclude_tags}`);
|
||||
}
|
||||
out.push(...args.flow_path);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
@@ -149,6 +176,11 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'backend',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({ id: 'junit_report_directory', required: true }),
|
||||
@@ -197,6 +229,14 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
outputs.final_report_path.set(finalReportPath);
|
||||
}
|
||||
|
||||
// Resolved after the output assignments above: an invalid backend input or
|
||||
// EAS_MAESTRO_BACKEND throws, and downstream `if: always()` upload steps still
|
||||
// need the outputs interpolated.
|
||||
const backend = resolveMaestroBackend({
|
||||
input: inputs.backend.value,
|
||||
env,
|
||||
});
|
||||
|
||||
const flowPaths = parseInput(
|
||||
FlowPathSchema,
|
||||
inputs.flow_path.value,
|
||||
@@ -219,6 +259,18 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
undefined,
|
||||
'android_connection_mode and EAS_MAESTRO_ANDROID_CONNECTION_MODE must be either "adb" or "dadb".'
|
||||
);
|
||||
if (backend === 'maestro-runner' && shards !== undefined && shards > 1) {
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_INVALID_INPUT',
|
||||
'maestro-runner does not support EAS Maestro test sharding. Remove shards or set it to 1.'
|
||||
);
|
||||
}
|
||||
if (backend === 'maestro-runner' && outputFormat !== undefined && outputFormat !== 'junit') {
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_INVALID_INPUT',
|
||||
`maestro-runner only supports the "junit" output_format, but received "${outputFormat}".`
|
||||
);
|
||||
}
|
||||
const retryFailedOnly = inputs.retry_failed_only.value as boolean;
|
||||
|
||||
try {
|
||||
@@ -227,7 +279,7 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
throw new SystemError('Failed to create JUnit report directory', { cause: err });
|
||||
}
|
||||
|
||||
// Legacy-only (Maestro < 2.6.0 reports carry no `file=` attribute): the
|
||||
// Official Maestro legacy-only (Maestro < 2.6.0 reports carry no `file=` attribute): the
|
||||
// flow scan is built lazily in the retry branch below and memoized so
|
||||
// retries share one scan. Never runs when the report has `file=`.
|
||||
let nameToPathPromise: Promise<Map<string, string> | null> | undefined;
|
||||
@@ -237,15 +289,23 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
// numeric err.status → maestro exited non-zero → retry.
|
||||
// else (signal-only, OOM kill, unknown) → infra → SystemError, never
|
||||
// downgraded to "tests failed".
|
||||
// Retry-failed-only (junit mode): after a failed attempt, subset to the failing
|
||||
// flows. The failed-flow parsers return null when the JUnit cannot be
|
||||
// trusted; we then fall through to dumb retry (re-run everything).
|
||||
// Retry-failed-only: after a failed attempt, subset to the failing flows. The
|
||||
// failed-flow parsers return null when a report cannot be trusted; we then fall
|
||||
// through to dumb retry (re-run everything).
|
||||
let flowsToRun: string[] = flowPaths;
|
||||
let lastAttemptExitCode: number | null = null;
|
||||
const harvested: HarvestedScreenshot[] = [];
|
||||
const reportDirectories = backend === 'maestro' ? [junitReportDirectory] : [];
|
||||
|
||||
const totalAttempts = retries + 1;
|
||||
if (platform === 'android' && androidConnectionMode === 'dadb') {
|
||||
if (
|
||||
backend === 'maestro-runner' &&
|
||||
platform === 'android' &&
|
||||
androidConnectionMode === 'dadb'
|
||||
) {
|
||||
logger.info('maestro-runner does not support DADB. Using the default ADB connection.');
|
||||
}
|
||||
if (backend === 'maestro' && platform === 'android' && androidConnectionMode === 'dadb') {
|
||||
try {
|
||||
const adbOverrideDirectoryPath = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'maestro-tests-adb-override-')
|
||||
@@ -280,29 +340,45 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
// maestro-runner writes its JUnit report and screenshot metadata to this directory.
|
||||
const runnerOutputDirectory = path.join(
|
||||
testsDirectory,
|
||||
`${platform}-maestro-runner-attempt-${attempt}`
|
||||
);
|
||||
const outputPath =
|
||||
outputFormat === 'junit'
|
||||
? path.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
|
||||
: outputFormat
|
||||
: backend === 'maestro' && outputFormat
|
||||
? path.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
|
||||
: null;
|
||||
|
||||
const maestroArgs = buildMaestroArgs({
|
||||
flow_path: flowsToRun,
|
||||
outputPath,
|
||||
output_format: outputFormat,
|
||||
const { executable, args: maestroArgs } = buildMaestroArgs({
|
||||
backend,
|
||||
platform,
|
||||
flowPaths: flowsToRun,
|
||||
output: backend === 'maestro-runner' ? runnerOutputDirectory : outputPath,
|
||||
outputFormat,
|
||||
shards,
|
||||
include_tags: includeTags,
|
||||
exclude_tags: excludeTags,
|
||||
includeTags,
|
||||
excludeTags,
|
||||
});
|
||||
logger.info(
|
||||
`Running maestro (attempt ${attempt + 1}/${totalAttempts}): maestro ${maestroArgs.join(' ')}`
|
||||
`Running ${executable} (attempt ${attempt + 1}/${totalAttempts}): ${executable} ${maestroArgs.join(' ')}`
|
||||
);
|
||||
|
||||
// The runner output directory is deterministic and can survive a prior run; clear it
|
||||
// best-effort so a crash before it writes fresh output can't resurrect stale results.
|
||||
if (backend === 'maestro-runner') {
|
||||
try {
|
||||
await fs.rm(runnerOutputDirectory, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `Failed to clear ${runnerOutputDirectory} before the attempt.`);
|
||||
}
|
||||
}
|
||||
|
||||
const attemptStartedAtMs = Date.now();
|
||||
|
||||
try {
|
||||
await spawn('maestro', maestroArgs, {
|
||||
await spawn(executable, maestroArgs, {
|
||||
cwd: stepCtx.workingDirectory,
|
||||
env: spawnEnv,
|
||||
logger,
|
||||
@@ -311,56 +387,108 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
lastAttemptExitCode = 0;
|
||||
} catch (err: any) {
|
||||
if (err && (err.code === 'ENOENT' || err.code === 'EACCES')) {
|
||||
throw new SystemError('Failed to invoke maestro', { cause: err });
|
||||
throw new SystemError(`Failed to invoke ${executable}`, { cause: err });
|
||||
}
|
||||
if (err && typeof err.status === 'number') {
|
||||
lastAttemptExitCode = err.status;
|
||||
} else {
|
||||
throw new SystemError('Unexpected spawn failure invoking maestro', { cause: err });
|
||||
throw new SystemError(`Unexpected spawn failure invoking ${executable}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// maestro-runner writes a report directory. Copy its JUnit file into the existing
|
||||
// per-attempt directory so retry parsing and final report merging remain shared.
|
||||
if (backend === 'maestro-runner' && outputPath) {
|
||||
try {
|
||||
await fs.copyFile(path.join(runnerOutputDirectory, 'junit-report.xml'), outputPath);
|
||||
} catch (err: any) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Failed to collect the maestro-runner JUnit report for attempt ${attempt + 1}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (backend === 'maestro-runner') {
|
||||
reportDirectories.push(runnerOutputDirectory);
|
||||
}
|
||||
|
||||
// Harvest this attempt's failure screenshots before any retry subsetting. Gated on
|
||||
// junit: test-case-result rows (and therefore the summary icons) only exist for junit
|
||||
// runs, so harvesting other formats would just create orphan artifacts the website hides.
|
||||
if (outputFormat === 'junit') {
|
||||
const failedFlowNames = outputPath
|
||||
? await parseFailedFlowNamesFromJUnitFile(outputPath)
|
||||
: new Set<string>();
|
||||
harvested.push(
|
||||
...(await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: attemptStartedAtMs,
|
||||
attemptIndex: attempt,
|
||||
failedFlowNames,
|
||||
logger,
|
||||
}))
|
||||
);
|
||||
let screenshots: HarvestedScreenshot[];
|
||||
switch (backend) {
|
||||
case 'maestro': {
|
||||
const failedFlowNames = outputPath
|
||||
? await parseFailedFlowNamesFromJUnitFile(outputPath)
|
||||
: new Set<string>();
|
||||
screenshots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: attemptStartedAtMs,
|
||||
attemptIndex: attempt,
|
||||
failedFlowNames,
|
||||
logger,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'maestro-runner':
|
||||
screenshots = await harvestMaestroRunnerFailureScreenshotsAsync({
|
||||
reportDirectory: runnerOutputDirectory,
|
||||
capturedSinceMs: attemptStartedAtMs,
|
||||
attemptIndex: attempt,
|
||||
logger,
|
||||
});
|
||||
break;
|
||||
}
|
||||
harvested.push(...screenshots);
|
||||
}
|
||||
|
||||
if (lastAttemptExitCode === 0 || attempt === retries) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (retryFailedOnly && outputFormat === 'junit' && outputPath) {
|
||||
if (
|
||||
retryFailedOnly &&
|
||||
(backend === 'maestro-runner' || (outputFormat === 'junit' && outputPath))
|
||||
) {
|
||||
let failed: string[] | null;
|
||||
if (await junitFileHasFileAttrs(outputPath)) {
|
||||
failed = await parseFailedFlowsFromFileAttrs({
|
||||
junitFile: outputPath,
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
});
|
||||
} else {
|
||||
// Legacy (Maestro < 2.6.0): map failed testcase names back to flow
|
||||
// paths via the flow-file scan. DELETE this arm once the fleet is
|
||||
// on >= 2.6.0.
|
||||
const nameToPath = await (nameToPathPromise ??= buildFlowNameToPathMap({
|
||||
inputFlowPaths: flowPaths,
|
||||
projectRoot: stepCtx.workingDirectory,
|
||||
logger,
|
||||
}));
|
||||
failed = nameToPath
|
||||
? await parseFailedFlowsFromJUnit({ junitFile: outputPath, nameToPath })
|
||||
: null;
|
||||
switch (backend) {
|
||||
case 'maestro-runner':
|
||||
failed = await parseFailedFlowsFromMaestroRunnerReport({
|
||||
reportDirectory: runnerOutputDirectory,
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
});
|
||||
break;
|
||||
case 'maestro':
|
||||
if (!outputPath) {
|
||||
failed = null;
|
||||
break;
|
||||
}
|
||||
if (await junitFileHasFileAttrs(outputPath)) {
|
||||
failed = await parseFailedFlowsFromFileAttrs({
|
||||
junitFile: outputPath,
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
});
|
||||
break;
|
||||
}
|
||||
// Legacy (Maestro < 2.6.0): map failed testcase names back to flow
|
||||
// paths via the flow-file scan. DELETE this arm once the fleet is
|
||||
// on >= 2.6.0.
|
||||
const nameToPath = await (nameToPathPromise ??= buildFlowNameToPathMap({
|
||||
inputFlowPaths: flowPaths,
|
||||
projectRoot: stepCtx.workingDirectory,
|
||||
logger,
|
||||
}));
|
||||
failed = nameToPath
|
||||
? await parseFailedFlowsFromJUnit({
|
||||
junitFile: outputPath,
|
||||
nameToPath,
|
||||
})
|
||||
: null;
|
||||
break;
|
||||
}
|
||||
if (failed !== null && failed.length > 0) {
|
||||
flowsToRun = failed;
|
||||
@@ -413,7 +541,13 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
// Upload before the ERR_MAESTRO_TESTS_FAILED throw below so fully-failed runs (which need
|
||||
// screenshots most) still upload. Harvest only ran for junit, so guard the same way.
|
||||
if (outputFormat === 'junit') {
|
||||
await uploadFailureScreenshotsAsync({ harvested, junitReportDirectory, ctx, logger });
|
||||
await uploadFailureScreenshotsAsync({
|
||||
harvested,
|
||||
backend,
|
||||
reportDirectories,
|
||||
ctx,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
// The retry loop exits via success (0), numeric status (retryable),
|
||||
@@ -434,12 +568,14 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
// the maestro test result. Caller guards on junit (harvest only runs for junit).
|
||||
async function uploadFailureScreenshotsAsync({
|
||||
harvested,
|
||||
junitReportDirectory,
|
||||
backend,
|
||||
reportDirectories,
|
||||
ctx,
|
||||
logger,
|
||||
}: {
|
||||
harvested: HarvestedScreenshot[];
|
||||
junitReportDirectory: string;
|
||||
backend: MaestroBackend;
|
||||
reportDirectories: string[];
|
||||
ctx: CustomBuildContext;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
@@ -449,9 +585,25 @@ async function uploadFailureScreenshotsAsync({
|
||||
// test verdict (the whole step is verdict-neutral for screenshots).
|
||||
let selected: HarvestedScreenshot[];
|
||||
try {
|
||||
const pureFailureFlowNames = computePureFailureFlowNames(
|
||||
await parseJUnitTestCases(junitReportDirectory)
|
||||
);
|
||||
let flowResults: { name: string; status: 'passed' | 'failed' }[];
|
||||
switch (backend) {
|
||||
case 'maestro':
|
||||
flowResults = (
|
||||
await Promise.all(reportDirectories.map(directory => parseJUnitTestCases(directory)))
|
||||
).flat();
|
||||
break;
|
||||
case 'maestro-runner': {
|
||||
const results = await Promise.all(
|
||||
reportDirectories.map(directory => parseMaestroRunnerReport(directory))
|
||||
);
|
||||
// Use whichever reports parsed. An unreadable report (e.g. a final retry that crashed
|
||||
// before writing report.json) contributes no flows rather than discarding screenshots
|
||||
// harvested from the attempts that did report — mirroring the maestro path above.
|
||||
flowResults = results.flatMap(result => result?.flows ?? []);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const pureFailureFlowNames = computePureFailureFlowNames(flowResults);
|
||||
selected = selectFailureScreenshots(harvested, pureFailureFlowNames);
|
||||
} catch (err: any) {
|
||||
logger.warn({ err }, 'Failed to classify failure screenshots; skipping screenshot upload.');
|
||||
|
||||
@@ -16,6 +16,10 @@ export type IpaInfo = {
|
||||
bundleIdentifier: string;
|
||||
bundleShortVersion: string;
|
||||
bundleVersion: string;
|
||||
// `DTPlatformName` from the bundle's Info.plist (e.g. `iphoneos`, `appletvos`,
|
||||
// `macosx`, `xros`), or null when absent. Used to pick the App Store Connect
|
||||
// platform for the upload. See `AscApiUtils.ascPlatformFromDtPlatformName`.
|
||||
dtPlatformName: string | null;
|
||||
};
|
||||
|
||||
const INFO_PLIST_PATH_REGEXP = /^Payload\/[^/]+\.app\/Info\.plist$/;
|
||||
@@ -91,10 +95,14 @@ export async function readIpaInfoAsync(ipaPath: string): Promise<IpaInfo> {
|
||||
);
|
||||
}
|
||||
|
||||
const dtPlatformName =
|
||||
typeof infoPlist.DTPlatformName === 'string' ? infoPlist.DTPlatformName : null;
|
||||
|
||||
return {
|
||||
bundleIdentifier,
|
||||
bundleShortVersion,
|
||||
bundleVersion,
|
||||
dtPlatformName,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof UserError) {
|
||||
|
||||
@@ -62,6 +62,12 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'ios_signing_backend',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
allowedValues: ['fastlane', 'zsign'],
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'repack_version',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
@@ -144,12 +150,14 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
iosSigningOptions: await resolveIosSigningOptionsAsync({
|
||||
job: stepsCtx.global.staticContext.job,
|
||||
logger: stepsCtx.logger,
|
||||
backend: inputs.ios_signing_backend.value as 'fastlane' | 'zsign' | undefined,
|
||||
useAppEntitlements: inputs.ios_signing_use_source_app_entitlements.value as
|
||||
| boolean
|
||||
| undefined,
|
||||
entitlementsPath: inputs.ios_signing_app_entitlements_path.value as
|
||||
| string
|
||||
| undefined,
|
||||
tmpDir,
|
||||
}),
|
||||
logger: stepsCtx.logger,
|
||||
spawnAsync: repackSpawnAsync,
|
||||
@@ -297,24 +305,51 @@ export async function resolveAndroidSigningOptionsAsync({
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves iOS signing options from the job secrets.
|
||||
* Resolves iOS signing options from the job secrets, dispatching on the
|
||||
* requested signing backend.
|
||||
*/
|
||||
export async function resolveIosSigningOptionsAsync({
|
||||
job,
|
||||
logger,
|
||||
backend,
|
||||
useAppEntitlements,
|
||||
entitlementsPath,
|
||||
tmpDir,
|
||||
}: {
|
||||
job: Job;
|
||||
logger: bunyan;
|
||||
backend?: 'fastlane' | 'zsign';
|
||||
useAppEntitlements?: boolean;
|
||||
entitlementsPath?: string;
|
||||
tmpDir: string;
|
||||
}): Promise<IosSigningOptions | undefined> {
|
||||
const iosJob = job as Ios.Job;
|
||||
const buildCredentials = iosJob.secrets?.buildCredentials;
|
||||
if (iosJob.simulator || buildCredentials == null) {
|
||||
return undefined;
|
||||
}
|
||||
const commonOptions = { buildCredentials, logger, useAppEntitlements, entitlementsPath };
|
||||
return backend === 'zsign'
|
||||
? await createIosZsignOptionsAsync({ ...commonOptions, tmpDir })
|
||||
: await createIosFastlaneOptionsAsync(commonOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates signing options for the fastlane backend: certificates are imported
|
||||
* into a temporary keychain and provisioning profiles are parsed with the
|
||||
* macOS `security` tool.
|
||||
*/
|
||||
async function createIosFastlaneOptionsAsync({
|
||||
buildCredentials,
|
||||
logger,
|
||||
useAppEntitlements,
|
||||
entitlementsPath,
|
||||
}: {
|
||||
buildCredentials: Ios.BuildCredentials;
|
||||
logger: bunyan;
|
||||
useAppEntitlements?: boolean;
|
||||
entitlementsPath?: string;
|
||||
}): Promise<IosSigningOptions> {
|
||||
const credentialsManager = new IosCredentialsManager(buildCredentials);
|
||||
const credentials = await credentialsManager.prepare(logger);
|
||||
|
||||
@@ -330,3 +365,53 @@ export async function resolveIosSigningOptionsAsync({
|
||||
entitlementsPath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates signing options for the zsign backend. The distribution certificate
|
||||
* secret is already a PKCS#12 file, so it goes to disk as-is together with the
|
||||
* provisioning profiles.
|
||||
*/
|
||||
async function createIosZsignOptionsAsync({
|
||||
buildCredentials,
|
||||
logger,
|
||||
useAppEntitlements,
|
||||
entitlementsPath,
|
||||
tmpDir,
|
||||
}: {
|
||||
buildCredentials: Ios.BuildCredentials;
|
||||
logger: bunyan;
|
||||
useAppEntitlements?: boolean;
|
||||
entitlementsPath?: string;
|
||||
tmpDir: string;
|
||||
}): Promise<IosSigningOptions> {
|
||||
const targets = Object.entries(buildCredentials);
|
||||
const [targetName, targetCredentials] = targets[0];
|
||||
logger.info(`Using the distribution certificate from target '${targetName}' for zsign`);
|
||||
|
||||
const certificatePath = path.join(tmpDir, `dist-cert-${randomUUID()}.p12`);
|
||||
await fs.promises.writeFile(
|
||||
certificatePath,
|
||||
new Uint8Array(Buffer.from(targetCredentials.distributionCertificate.dataBase64, 'base64'))
|
||||
);
|
||||
|
||||
// zsign matches profiles to bundles by the app-id suffix itself, so the
|
||||
// record keys are informational only.
|
||||
const provisioningProfile: Record<string, string> = {};
|
||||
for (const [target, credentials] of targets) {
|
||||
const profilePath = path.join(tmpDir, `profile-${target}-${randomUUID()}.mobileprovision`);
|
||||
await fs.promises.writeFile(
|
||||
profilePath,
|
||||
new Uint8Array(Buffer.from(credentials.provisioningProfileBase64, 'base64'))
|
||||
);
|
||||
provisioningProfile[target] = profilePath;
|
||||
}
|
||||
|
||||
return {
|
||||
backend: 'zsign',
|
||||
certificatePath,
|
||||
keyPassword: targetCredentials.distributionCertificate.password,
|
||||
provisioningProfile,
|
||||
useAppEntitlements,
|
||||
entitlementsPath,
|
||||
};
|
||||
}
|
||||
@@ -15,8 +15,9 @@ import path from 'path';
|
||||
import { sendCcacheStatsAsync } from './ccacheStats';
|
||||
import { decompressCacheAsync, downloadCacheAsync, downloadPublicCacheAsync } from './restoreCache';
|
||||
import {
|
||||
CACHE_KEY_PREFIX_BY_PLATFORM,
|
||||
CcacheBuildTarget,
|
||||
generateDefaultBuildCacheKeyAsync,
|
||||
getCcacheKeyPrefix,
|
||||
getCcachePath,
|
||||
} from '../../utils/cacheKey';
|
||||
import { Datadog } from '../../datadog';
|
||||
@@ -35,6 +36,11 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'simulator',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { env, inputs }) => {
|
||||
const { logger } = stepCtx;
|
||||
@@ -48,10 +54,20 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
|
||||
);
|
||||
}
|
||||
|
||||
const target: CcacheBuildTarget =
|
||||
platform === Platform.IOS
|
||||
? {
|
||||
platform,
|
||||
simulator:
|
||||
(inputs.simulator.value as boolean | undefined) ??
|
||||
(stepCtx.global.staticContext.job.platform === Platform.IOS &&
|
||||
stepCtx.global.staticContext.job.simulator === true),
|
||||
}
|
||||
: { platform };
|
||||
await restoreCcacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
platform,
|
||||
target,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
@@ -92,13 +108,13 @@ export function createCacheStatsBuildFunction(): BuildFunction {
|
||||
export async function restoreCcacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
platform,
|
||||
target,
|
||||
env,
|
||||
secrets,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
workingDirectory: string;
|
||||
platform: Platform;
|
||||
target: CcacheBuildTarget;
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
@@ -136,7 +152,7 @@ export async function restoreCcacheAsync({
|
||||
})
|
||||
);
|
||||
|
||||
const cacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, platform);
|
||||
const cacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, target);
|
||||
logger.info(`Restoring cache key: ${cacheKey}`);
|
||||
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
@@ -147,8 +163,8 @@ export async function restoreCcacheAsync({
|
||||
robotAccessToken,
|
||||
paths: [cachePath],
|
||||
key: cacheKey,
|
||||
keyPrefixes: [CACHE_KEY_PREFIX_BY_PLATFORM[platform]],
|
||||
platform,
|
||||
keyPrefixes: [getCcacheKeyPrefix(target)],
|
||||
platform: target.platform,
|
||||
});
|
||||
|
||||
await decompressCacheAsync({
|
||||
@@ -175,7 +191,7 @@ export async function restoreCcacheAsync({
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
paths: [cachePath],
|
||||
platform,
|
||||
platform: target.platform,
|
||||
});
|
||||
await decompressCacheAsync({
|
||||
archivePath,
|
||||
@@ -258,13 +274,14 @@ export async function restoreGradleCacheAsync({
|
||||
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
|
||||
|
||||
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
|
||||
const journalPath = path.join(gradleCachesPath, 'journal-1');
|
||||
|
||||
const { archivePath, matchedKey } = await downloadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
paths: [buildCachePath],
|
||||
paths: [buildCachePath, journalPath],
|
||||
key: cacheKey,
|
||||
keyPrefixes: [GRADLE_CACHE_KEY_PREFIX],
|
||||
platform: Platform.ANDROID,
|
||||
|
||||
@@ -15,7 +15,11 @@ import path from 'path';
|
||||
|
||||
import { compressCacheAsync, uploadCacheAsync } from './saveCache';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { generateDefaultBuildCacheKeyAsync, getCcachePath } from '../../utils/cacheKey';
|
||||
import {
|
||||
CcacheBuildTarget,
|
||||
generateDefaultBuildCacheKeyAsync,
|
||||
getCcachePath,
|
||||
} from '../../utils/cacheKey';
|
||||
import { generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
|
||||
|
||||
export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFunction {
|
||||
@@ -30,6 +34,11 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'simulator',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { env, inputs }) => {
|
||||
const { logger } = stepCtx;
|
||||
@@ -43,10 +52,20 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
|
||||
);
|
||||
}
|
||||
|
||||
const target: CcacheBuildTarget =
|
||||
platform === Platform.IOS
|
||||
? {
|
||||
platform,
|
||||
simulator:
|
||||
(inputs.simulator.value as boolean | undefined) ??
|
||||
(stepCtx.global.staticContext.job.platform === Platform.IOS &&
|
||||
stepCtx.global.staticContext.job.simulator === true),
|
||||
}
|
||||
: { platform };
|
||||
await saveCcacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
platform,
|
||||
target,
|
||||
evictUsedBefore,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
@@ -67,14 +86,14 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
|
||||
export async function saveCcacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
platform,
|
||||
target,
|
||||
evictUsedBefore,
|
||||
env,
|
||||
secrets,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
workingDirectory: string;
|
||||
platform: Platform;
|
||||
target: CcacheBuildTarget;
|
||||
evictUsedBefore: Date;
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
@@ -99,7 +118,7 @@ export async function saveCcacheAsync({
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, platform);
|
||||
const cacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, target);
|
||||
logger.info(`Saving cache key: ${cacheKey}`);
|
||||
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
@@ -143,7 +162,7 @@ export async function saveCcacheAsync({
|
||||
key: cacheKey,
|
||||
paths: [cachePath],
|
||||
size,
|
||||
platform,
|
||||
platform: target.platform,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to save cache');
|
||||
@@ -167,6 +186,7 @@ export async function saveGradleCacheAsync({
|
||||
|
||||
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
|
||||
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
|
||||
const journalPath = path.join(gradleCachesPath, 'journal-1');
|
||||
|
||||
try {
|
||||
await fs.promises.access(buildCachePath);
|
||||
@@ -186,9 +206,11 @@ export async function saveGradleCacheAsync({
|
||||
);
|
||||
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
|
||||
await fs.promises.mkdir(journalPath, { recursive: true });
|
||||
|
||||
logger.info('Compressing Gradle build cache...');
|
||||
const { archivePath } = await compressCacheAsync({
|
||||
paths: [buildCachePath],
|
||||
paths: [buildCachePath, journalPath],
|
||||
workingDirectory: gradleCachesPath,
|
||||
verbose: env.EXPO_DEBUG === '1',
|
||||
logger,
|
||||
@@ -204,7 +226,7 @@ export async function saveGradleCacheAsync({
|
||||
robotAccessToken,
|
||||
archivePath,
|
||||
key: cacheKey,
|
||||
paths: [buildCachePath],
|
||||
paths: [buildCachePath, journalPath],
|
||||
size,
|
||||
platform: Platform.ANDROID,
|
||||
});
|
||||
|
||||
@@ -348,7 +348,10 @@ export async function compressCacheAsync({
|
||||
for (const { absolutePath, archivePath: targetRelativePath } of allFiles) {
|
||||
const targetPath = path.join(tempDir, targetRelativePath);
|
||||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
// We want to keep source timestamps since Gradle may check them when pruning cache.
|
||||
const { atime, mtime } = await fs.promises.stat(absolutePath);
|
||||
await fs.promises.copyFile(absolutePath, targetPath);
|
||||
await fs.promises.utimes(targetPath, atime, mtime);
|
||||
|
||||
if (verbose) {
|
||||
logger.info(`- ${targetRelativePath}`);
|
||||
|
||||
@@ -60,6 +60,11 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_duration_seconds',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
// Fail fast before any expensive setup if the injected env
|
||||
@@ -73,6 +78,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
logger.info(
|
||||
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
|
||||
@@ -146,6 +152,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
maxDurationSeconds,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
type BuildStepEnv,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
const APPIUM_HOST = '127.0.0.1';
|
||||
const APPIUM_PORT = 4723;
|
||||
const APPIUM_STARTUP_TIMEOUT_MS = 120_000;
|
||||
const DEFAULT_APPIUM_VERSION = '^3';
|
||||
|
||||
const AppiumInstalledDriversSchema = z.record(
|
||||
z.string(),
|
||||
z.object({ installed: z.boolean().optional() }).passthrough()
|
||||
);
|
||||
|
||||
export function createStartAppiumRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_appium_remote_session',
|
||||
name: 'Start Appium remote session',
|
||||
__metricsId: 'eas/start_appium_remote_session',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_idle_time_minutes',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
const versionSpec = resolveAppium3VersionSpec(packageVersion);
|
||||
|
||||
logger.info(
|
||||
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
const device = await resolveAppiumDeviceAsync({ runtimePlatform, env, logger });
|
||||
const { appiumHome, appiumBinPath, appiumEnv } = await installAppiumAsync({
|
||||
versionSpec,
|
||||
driverName: device.driverName,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
const appiumProcess = spawnDetached({
|
||||
command: appiumBinPath,
|
||||
args: [
|
||||
'--address',
|
||||
APPIUM_HOST,
|
||||
'--port',
|
||||
String(APPIUM_PORT),
|
||||
'--base-path',
|
||||
'/',
|
||||
'--log-level',
|
||||
'error',
|
||||
// Appium 3 gates session listing (GET /appium/sessions) behind the
|
||||
// session_discovery insecure feature. We rely on it to poll for
|
||||
// Appium Event Timings, so enable it for all drivers.
|
||||
'--allow-insecure',
|
||||
'*:session_discovery',
|
||||
'--default-capabilities',
|
||||
JSON.stringify({ 'appium:eventTimings': true }),
|
||||
],
|
||||
env: appiumEnv,
|
||||
});
|
||||
try {
|
||||
await waitForAppiumReadyAsync({ appiumProcess, logger });
|
||||
} catch (error) {
|
||||
await appiumProcess.stopAsync();
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const eventCollection = await startAppiumEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
appiumUrl: `http://${APPIUM_HOST}:${APPIUM_PORT}/`,
|
||||
logger,
|
||||
});
|
||||
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let serveSim: Awaited<ReturnType<typeof startServeSimWithTunnelAsync>> | undefined;
|
||||
try {
|
||||
appiumTunnel = await startNgrokTunnelAsync({
|
||||
port: APPIUM_PORT,
|
||||
subdomainPrefix: 'appium',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
logger,
|
||||
});
|
||||
|
||||
switch (runtimePlatform) {
|
||||
case BuildRuntimePlatform.DARWIN:
|
||||
serveSim = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
break;
|
||||
case BuildRuntimePlatform.LINUX:
|
||||
break;
|
||||
}
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
appiumUrl: appiumTunnel.url,
|
||||
capabilities: {
|
||||
platformName: device.platformName,
|
||||
'appium:automationName': device.automationName,
|
||||
'appium:udid': device.udid,
|
||||
},
|
||||
...(serveSim ? { webPreviewUrl: serveSim.previewUrl } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
if (serveSim) {
|
||||
await serveSim.stopAsync();
|
||||
}
|
||||
if (appiumTunnel) {
|
||||
await appiumTunnel.stopAsync();
|
||||
}
|
||||
await eventCollection.stopAsync();
|
||||
await appiumProcess.stopAsync();
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveAppium3VersionSpec(packageVersion: string | undefined): string {
|
||||
const versionSpec = packageVersion ?? DEFAULT_APPIUM_VERSION;
|
||||
const range = semver.validRange(versionSpec);
|
||||
if (!range || !semver.subset(range, '>=3.0.0 <4.0.0-0')) {
|
||||
throw new SystemError(
|
||||
`Appium 3 is required for EAS Simulator sessions. Received package version "${versionSpec}".`
|
||||
);
|
||||
}
|
||||
return versionSpec;
|
||||
}
|
||||
|
||||
type AppiumDevice = {
|
||||
platformName: 'iOS' | 'Android';
|
||||
automationName: 'XCUITest' | 'UiAutomator2';
|
||||
driverName: 'xcuitest' | 'uiautomator2';
|
||||
udid: string;
|
||||
};
|
||||
|
||||
export async function resolveAppiumDeviceAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<AppiumDevice> {
|
||||
switch (runtimePlatform) {
|
||||
case BuildRuntimePlatform.DARWIN: {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
const [bootedDevice] = await IosSimulatorUtils.getAvailableDevicesAsync({
|
||||
env,
|
||||
filter: 'booted',
|
||||
});
|
||||
if (!bootedDevice) {
|
||||
throw new SystemError('Could not find a booted iOS simulator for the Appium session.');
|
||||
}
|
||||
return {
|
||||
platformName: 'iOS',
|
||||
automationName: 'XCUITest',
|
||||
driverName: 'xcuitest',
|
||||
udid: bootedDevice.udid,
|
||||
};
|
||||
}
|
||||
case BuildRuntimePlatform.LINUX: {
|
||||
const attachedDevices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env });
|
||||
const bootedDevice = attachedDevices.find(device => device.state === 'device');
|
||||
if (!bootedDevice) {
|
||||
throw new SystemError('Could not find a booted Android emulator for the Appium session.');
|
||||
}
|
||||
return {
|
||||
platformName: 'Android',
|
||||
automationName: 'UiAutomator2',
|
||||
driverName: 'uiautomator2',
|
||||
udid: bootedDevice.serialId,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function installAppiumAsync({
|
||||
versionSpec,
|
||||
driverName,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
versionSpec: string;
|
||||
driverName: AppiumDevice['driverName'];
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<{ appiumHome: string; appiumBinPath: string; appiumEnv: BuildStepEnv }> {
|
||||
const appiumHome = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-appium-home-'));
|
||||
await fs.promises.writeFile(
|
||||
path.join(appiumHome, 'package.json'),
|
||||
`${JSON.stringify({ name: 'eas-appium-home', private: true })}\n`
|
||||
);
|
||||
const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome };
|
||||
const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium');
|
||||
|
||||
logger.info(`Installing appium@${versionSpec}.`);
|
||||
await spawn('npm', ['install', '--prefix', appiumHome, `appium@${versionSpec}`], {
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
});
|
||||
const { stdout } = await spawn(appiumBinPath, ['driver', 'list', '--installed', '--json'], {
|
||||
env: appiumEnv,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const installedDrivers = AppiumInstalledDriversSchema.parse(JSON.parse(stdout));
|
||||
if (installedDrivers[driverName]?.installed) {
|
||||
logger.info(`Updating the installed Appium ${driverName} driver.`);
|
||||
await spawn(appiumBinPath, ['driver', 'update', driverName], { env: appiumEnv, logger });
|
||||
} else {
|
||||
logger.info(`Installing the Appium ${driverName} driver.`);
|
||||
await spawn(appiumBinPath, ['driver', 'install', driverName], { env: appiumEnv, logger });
|
||||
}
|
||||
return { appiumHome, appiumBinPath, appiumEnv };
|
||||
}
|
||||
|
||||
async function waitForAppiumReadyAsync({
|
||||
appiumProcess,
|
||||
logger,
|
||||
}: {
|
||||
appiumProcess: { getOutput: () => string };
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const deadline = Date.now() + APPIUM_STARTUP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await turtleFetch(`http://${APPIUM_HOST}:${APPIUM_PORT}/status`, 'GET', {
|
||||
timeout: 2_000,
|
||||
retries: 0,
|
||||
logger,
|
||||
});
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
const output = appiumProcess.getOutput();
|
||||
throw new SystemError(
|
||||
`Timed out waiting for Appium to become ready.${output ? `\nAppium output:\n${output}` : ''}`
|
||||
);
|
||||
}
|
||||
@@ -73,6 +73,11 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_duration_seconds',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
// Fail fast before any expensive setup if the injected env
|
||||
@@ -86,6 +91,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
|
||||
const versionSpec = packageVersion ?? 'latest';
|
||||
const { runtimePlatform } = global;
|
||||
@@ -221,6 +227,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
maxDurationSeconds,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
@@ -21,9 +26,23 @@ export function createStartServeSimRemoteSessionBuildFunction(
|
||||
name: 'Start serve-sim remote session',
|
||||
__metricsId: 'eas/start_serve_sim_remote_session',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
fn: async ({ logger }, { env, signal }) => {
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_duration_seconds',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { inputs, env, signal }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
const packageVersion = inputs.package_version?.value as string | undefined;
|
||||
|
||||
logger.info('Starting serve-sim remote session.');
|
||||
|
||||
@@ -34,6 +53,7 @@ export function createStartServeSimRemoteSessionBuildFunction(
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
packageVersion,
|
||||
});
|
||||
logger.info(`Preview URL: ${serveSim.previewUrl}`);
|
||||
|
||||
@@ -49,6 +69,7 @@ export function createStartServeSimRemoteSessionBuildFunction(
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
maxDurationSeconds,
|
||||
signal,
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -115,16 +115,27 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
`Uploading Build to "${appResponse.data.attributes.name}" (${ascAppBundleIdentifier})...`
|
||||
);
|
||||
|
||||
// Derive the App Store Connect platform from the IPA itself so tvOS (and
|
||||
// other) binaries are uploaded to the correct version train instead of the
|
||||
// iOS one. Falls back to `IOS` if the platform cannot be read.
|
||||
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
|
||||
const platform = ipaInfoResult.ok
|
||||
? AscApiUtils.ascPlatformFromDtPlatformName(ipaInfoResult.value.dtPlatformName)
|
||||
: 'IOS';
|
||||
stepsCtx.logger.info(`Detected App Store Connect platform: ${platform}`);
|
||||
|
||||
stepsCtx.logger.info('Creating Build Upload...');
|
||||
const buildUploadResponse = await AscApiUtils.createBuildUploadAsync({
|
||||
client,
|
||||
appleAppIdentifier,
|
||||
bundleShortVersion,
|
||||
bundleVersion,
|
||||
platform,
|
||||
});
|
||||
|
||||
const buildUploadId = buildUploadResponse.data.id;
|
||||
const buildUploadUrl = `https://appstoreconnect.apple.com/apps/${appleAppIdentifier}/testflight/ios/${buildUploadId}`;
|
||||
const platformPathSegment = AscApiUtils.testFlightPlatformPathSegment(platform);
|
||||
const buildUploadUrl = `https://appstoreconnect.apple.com/apps/${appleAppIdentifier}/testflight/${platformPathSegment}/${buildUploadId}`;
|
||||
outputs.build_upload_id.set(buildUploadId);
|
||||
outputs.build_upload_url.set(buildUploadUrl);
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startAppiumEventCollectionAsync } from '../appiumEvents';
|
||||
import { startDeviceRunSessionEventCollectionAsync } from '../deviceRunSessionEvents';
|
||||
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../deviceRunSessionEvents', () => ({
|
||||
startDeviceRunSessionEventCollectionAsync: jest.fn(async () => ({
|
||||
getLastEventObservedAt: () => undefined,
|
||||
stopAsync: async () => undefined,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe(startAppiumEventCollectionAsync, () => {
|
||||
it('writes new Appium Event Timings commands to a normal event file', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(response({ value: [{ id: 'appium-session-id' }] }))
|
||||
.mockResolvedValueOnce(
|
||||
response({
|
||||
value: {
|
||||
commands: [
|
||||
{ cmd: 'getOrientation', startTime: 1_786_693_000_000, endTime: 1_786_693_000_025 },
|
||||
{ cmd: 'getLogEvents', startTime: 1_786_693_000_026, endTime: 1_786_693_000_027 },
|
||||
],
|
||||
},
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(response({ value: [] }));
|
||||
|
||||
const collection = await startAppiumEventCollectionAsync({
|
||||
ctx: {} as never,
|
||||
deviceRunSessionId: 'device-session-id',
|
||||
appiumUrl: 'http://127.0.0.1:4723/',
|
||||
logger: { warn: jest.fn() } as never,
|
||||
pollIntervalMs: 60_000,
|
||||
});
|
||||
const source = jest.mocked(startDeviceRunSessionEventCollectionAsync).mock.calls[0][0].source;
|
||||
const [eventFile] = await source.findEventFilesAsync();
|
||||
let contents = '';
|
||||
await waitForAsync(async () => {
|
||||
contents = await fs.promises.readFile(eventFile, 'utf8');
|
||||
expect(contents).toContain('getOrientation');
|
||||
});
|
||||
const lines = contents.trim().split('\n');
|
||||
expect(lines).toHaveLength(1);
|
||||
const [line] = lines;
|
||||
expect(
|
||||
source.parseLine({
|
||||
line,
|
||||
sourceKey: source.sourceKeyForFile(eventFile),
|
||||
sequenceNumber: 1,
|
||||
deviceRunSessionId: 'device-session-id',
|
||||
})
|
||||
).toMatchObject({
|
||||
event: {
|
||||
producer: 'appium',
|
||||
type: 'operation.completed',
|
||||
durationMs: 25,
|
||||
summary: 'getOrientation',
|
||||
},
|
||||
});
|
||||
|
||||
// Appium 3 removed GET /sessions; session discovery must use /appium/sessions.
|
||||
expect(turtleFetch).toHaveBeenCalledWith(
|
||||
'http://127.0.0.1:4723/appium/sessions',
|
||||
'GET',
|
||||
expect.anything()
|
||||
);
|
||||
expect(turtleFetch).toHaveBeenCalledWith(
|
||||
'http://127.0.0.1:4723/session/appium-session-id/appium/events',
|
||||
'POST',
|
||||
expect.anything()
|
||||
);
|
||||
|
||||
await collection.stopAsync();
|
||||
});
|
||||
});
|
||||
|
||||
async function waitForAsync(assertion: () => Promise<void>): Promise<void> {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
try {
|
||||
await assertion();
|
||||
return;
|
||||
} catch {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
await assertion();
|
||||
}
|
||||
|
||||
function response(value: unknown): Awaited<ReturnType<typeof turtleFetch>> {
|
||||
return { json: async () => value } as never;
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import { bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import {
|
||||
clearTimeout as clearTimeoutCallback,
|
||||
setTimeout as setTimeoutCallback,
|
||||
} from 'node:timers';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
@@ -20,6 +24,7 @@ import {
|
||||
} from '../remoteDeviceRunSession';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
jest.mock('node:timers');
|
||||
jest.mock('node:timers/promises');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../../utils/retry', () => ({ sleepAsync: jest.fn() }));
|
||||
@@ -152,6 +157,20 @@ describe(createServeSimArgs, () => {
|
||||
'https://expo.dev',
|
||||
]);
|
||||
});
|
||||
|
||||
it('pins the requested package version', () => {
|
||||
expect(createServeSimArgs({ port: 4321, packageVersion: '0.1.38' }).slice(0, 2)).toEqual([
|
||||
'--yes',
|
||||
'@expo/[email protected]',
|
||||
]);
|
||||
});
|
||||
|
||||
it('pins a dist-tag', () => {
|
||||
expect(createServeSimArgs({ port: 4321, packageVersion: 'next' }).slice(0, 2)).toEqual([
|
||||
'--yes',
|
||||
'@expo/serve-sim@next',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(metricsCorsOriginToServeSimArgs, () => {
|
||||
@@ -216,14 +235,14 @@ describe(startNgrokTunnelAsync, () => {
|
||||
it('uses a 128-bit capability hostname and exposes explicit cleanup', async () => {
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://serve-sim.example.test',
|
||||
url: () => 'https://web-preview.example.test',
|
||||
close,
|
||||
} as never);
|
||||
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
port: 4321,
|
||||
subdomainPrefix: 'serve-sim',
|
||||
baseDomain: 'tunnel.example.test',
|
||||
subdomainPrefix: 'web-preview',
|
||||
baseDomain: 'eas-simulator.ngrok.dev',
|
||||
authtoken: 'token',
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
@@ -232,10 +251,10 @@ describe(startNgrokTunnelAsync, () => {
|
||||
expect.objectContaining({
|
||||
addr: 4321,
|
||||
authtoken: 'token',
|
||||
domain: expect.stringMatching(/^serve-sim-[a-f0-9]{32}\.tunnel\.example\.test$/),
|
||||
domain: expect.stringMatching(/^web-preview-[a-f0-9]{32}\.eas-simulator\.ngrok\.dev$/),
|
||||
})
|
||||
);
|
||||
expect(tunnel.url).toBe('https://serve-sim.example.test');
|
||||
expect(tunnel.url).toBe('https://web-preview.example.test');
|
||||
await tunnel.stopAsync();
|
||||
await tunnel.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
@@ -360,8 +379,13 @@ describe(fetchServeSimTurnArgsAsync, () => {
|
||||
});
|
||||
|
||||
describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
const durationTimeout = {} as NodeJS.Timeout;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(Sentry).capture.mockReset();
|
||||
jest.mocked(setTimeoutCallback).mockReset();
|
||||
jest.mocked(setTimeoutCallback).mockReturnValue(durationTimeout);
|
||||
jest.mocked(clearTimeoutCallback).mockReset();
|
||||
jest.mocked(setTimeoutAsync).mockReset();
|
||||
jest.mocked(setTimeoutAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
@@ -378,6 +402,57 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(2);
|
||||
expect(logger.info).toHaveBeenCalledWith('Device run session drs-id was stopped.');
|
||||
expect(setTimeoutCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns normally when the maximum duration elapses', async () => {
|
||||
const ctx = createStatusCtxMock([{ status: 'IN_PROGRESS' }]);
|
||||
const logger = createLoggerMock();
|
||||
const waitPromise = waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
maxDurationSeconds: 1,
|
||||
});
|
||||
|
||||
expect(setTimeoutCallback).toHaveBeenCalledWith(expect.any(Function), 1_000);
|
||||
const durationTimeoutCallback = jest.mocked(setTimeoutCallback).mock.calls[0][0];
|
||||
durationTimeoutCallback();
|
||||
await waitPromise;
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(1);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Device run session drs-id reached its maximum duration.'
|
||||
);
|
||||
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
|
||||
});
|
||||
|
||||
it('clears the duration timeout when the session stops first', async () => {
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx: createStatusCtxMock([{ status: 'STOPPED' }]),
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger: createLoggerMock(),
|
||||
maxDurationSeconds: 30,
|
||||
});
|
||||
|
||||
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
|
||||
});
|
||||
|
||||
it('does not poll when the build step is already aborted', async () => {
|
||||
const ctx = createStatusCtxMock([]);
|
||||
const abortController = new AbortController();
|
||||
abortController.abort();
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger: createLoggerMock(),
|
||||
maxDurationSeconds: 30,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
|
||||
expect(setTimeoutCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the device run session errors', async () => {
|
||||
@@ -388,8 +463,28 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger: createLoggerMock(),
|
||||
maxDurationSeconds: 30,
|
||||
})
|
||||
).rejects.toThrow('Device run session drs-id errored.');
|
||||
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
|
||||
});
|
||||
|
||||
it('clears the duration timeout when the build step is aborted', async () => {
|
||||
const ctx = createStatusCtxMock([{ status: 'IN_PROGRESS' }]);
|
||||
const abortController = new AbortController();
|
||||
const waitPromise = waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger: createLoggerMock(),
|
||||
maxDurationSeconds: 30,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
abortController.abort();
|
||||
await waitPromise;
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(1);
|
||||
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
|
||||
});
|
||||
|
||||
it('logs and retries transient polling errors', async () => {
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import {
|
||||
type DeviceRunSessionEventCollection,
|
||||
type DeviceRunSessionEventSource,
|
||||
startDeviceRunSessionEventCollectionAsync,
|
||||
} from './deviceRunSessionEvents';
|
||||
|
||||
const APPIUM_REQUEST_TIMEOUT_MS = 10_000;
|
||||
const POLL_INTERVAL_MS = 1_000;
|
||||
const GET_LOG_EVENTS_COMMAND = 'getLogEvents';
|
||||
|
||||
const AppiumSessionsResponseSchema = z.object({
|
||||
value: z.array(z.object({ id: z.string() }).passthrough()),
|
||||
});
|
||||
|
||||
const AppiumCommandSchema = z.object({
|
||||
appiumSessionId: z.string(),
|
||||
cmd: z.string(),
|
||||
startTime: z.number(),
|
||||
endTime: z.number(),
|
||||
});
|
||||
|
||||
const AppiumEventsResponseSchema = z.object({
|
||||
value: z.object({
|
||||
commands: z.array(AppiumCommandSchema.omit({ appiumSessionId: true })),
|
||||
}),
|
||||
});
|
||||
|
||||
type AppiumCommand = z.infer<typeof AppiumCommandSchema>;
|
||||
|
||||
export async function startAppiumEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
appiumUrl,
|
||||
logger,
|
||||
pollIntervalMs = POLL_INTERVAL_MS,
|
||||
}: {
|
||||
ctx: CustomBuildContext;
|
||||
deviceRunSessionId: string;
|
||||
appiumUrl: string;
|
||||
logger: bunyan;
|
||||
pollIntervalMs?: number;
|
||||
}): Promise<DeviceRunSessionEventCollection> {
|
||||
const eventDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-appium-events-'));
|
||||
const eventFile = path.join(eventDirectory, 'commands.ndjson');
|
||||
await fs.promises.writeFile(eventFile, '');
|
||||
|
||||
let eventCollection: DeviceRunSessionEventCollection;
|
||||
try {
|
||||
eventCollection = await startDeviceRunSessionEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
pollIntervalMs,
|
||||
source: createAppiumEventSource(eventFile),
|
||||
});
|
||||
} catch (error) {
|
||||
await fs.promises.rm(eventDirectory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const observedCommandKeys = new Set<string>();
|
||||
const controller = new AbortController();
|
||||
let didReportCollectionFailure = false;
|
||||
const collectSafelyAsync = async (): Promise<void> => {
|
||||
try {
|
||||
await collectAppiumCommandsAsync({
|
||||
appiumUrl,
|
||||
eventFile,
|
||||
observedCommandKeys,
|
||||
logger,
|
||||
});
|
||||
didReportCollectionFailure = false;
|
||||
} catch (error) {
|
||||
if (!didReportCollectionFailure) {
|
||||
didReportCollectionFailure = true;
|
||||
logger.warn({ err: error }, 'Could not collect Appium events.');
|
||||
}
|
||||
}
|
||||
};
|
||||
const pollingPromise = (async () => {
|
||||
while (!controller.signal.aborted) {
|
||||
await collectSafelyAsync();
|
||||
try {
|
||||
await setTimeoutAsync(pollIntervalMs, undefined, { signal: controller.signal });
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
})().catch(error => {
|
||||
logger.warn({ err: error }, 'Appium event collection poller failed.');
|
||||
});
|
||||
|
||||
return {
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
stopAsync: async () => {
|
||||
controller.abort();
|
||||
await pollingPromise;
|
||||
await collectSafelyAsync();
|
||||
try {
|
||||
await eventCollection.stopAsync();
|
||||
} finally {
|
||||
await fs.promises.rm(eventDirectory, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createAppiumEventSource(eventFile: string): DeviceRunSessionEventSource {
|
||||
return {
|
||||
producer: 'appium',
|
||||
findEventFilesAsync: async () => [eventFile],
|
||||
sourceKeyForFile: file => path.basename(file, path.extname(file)),
|
||||
parseLine: ({ line, sourceKey, sequenceNumber, deviceRunSessionId }) => {
|
||||
if (!line.trim()) {
|
||||
return {};
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
return { failure: 'invalid-json' };
|
||||
}
|
||||
const result = AppiumCommandSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return { failure: 'invalid-event' };
|
||||
}
|
||||
const command = result.data;
|
||||
const operationId = `${sourceKey}:${sequenceNumber}`;
|
||||
return {
|
||||
event: {
|
||||
v: 1,
|
||||
eventId: `appium:${deviceRunSessionId}:${operationId}`,
|
||||
ts: new Date(command.endTime).toISOString(),
|
||||
producer: 'appium',
|
||||
type: 'operation.completed',
|
||||
operationId,
|
||||
durationMs: Math.max(0, command.endTime - command.startTime),
|
||||
summary: command.cmd,
|
||||
data: {
|
||||
command: command.cmd,
|
||||
appiumSessionId: command.appiumSessionId,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function collectAppiumCommandsAsync({
|
||||
appiumUrl,
|
||||
eventFile,
|
||||
observedCommandKeys,
|
||||
logger,
|
||||
}: {
|
||||
appiumUrl: string;
|
||||
eventFile: string;
|
||||
observedCommandKeys: Set<string>;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const response = await turtleFetch(new URL('appium/sessions', appiumUrl).toString(), 'GET', {
|
||||
timeout: APPIUM_REQUEST_TIMEOUT_MS,
|
||||
retries: 0,
|
||||
logger,
|
||||
});
|
||||
const sessions = AppiumSessionsResponseSchema.parse(await response.json()).value;
|
||||
const newCommands: { key: string; command: AppiumCommand }[] = [];
|
||||
const pendingCommandKeys = new Set<string>();
|
||||
|
||||
for (const { id: appiumSessionId } of sessions) {
|
||||
const eventsResponse = await turtleFetch(
|
||||
new URL(`session/${encodeURIComponent(appiumSessionId)}/appium/events`, appiumUrl).toString(),
|
||||
'POST',
|
||||
{
|
||||
json: {},
|
||||
timeout: APPIUM_REQUEST_TIMEOUT_MS,
|
||||
retries: 0,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
const commands = AppiumEventsResponseSchema.parse(await eventsResponse.json()).value.commands;
|
||||
for (const command of commands) {
|
||||
const key = [appiumSessionId, command.cmd, command.startTime, command.endTime].join(':');
|
||||
if (
|
||||
command.cmd === GET_LOG_EVENTS_COMMAND ||
|
||||
observedCommandKeys.has(key) ||
|
||||
pendingCommandKeys.has(key)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
pendingCommandKeys.add(key);
|
||||
newCommands.push({ key, command: { appiumSessionId, ...command } });
|
||||
}
|
||||
}
|
||||
|
||||
if (newCommands.length === 0) {
|
||||
return;
|
||||
}
|
||||
await fs.promises.appendFile(
|
||||
eventFile,
|
||||
`${newCommands.map(({ command }) => JSON.stringify(command)).join('\n')}\n`
|
||||
);
|
||||
for (const { key } of newCommands) {
|
||||
observedCommandKeys.add(key);
|
||||
}
|
||||
}
|
||||
@@ -272,6 +272,10 @@ export type AscApiClientPostApi = {
|
||||
};
|
||||
};
|
||||
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/platform
|
||||
export type AscPlatform =
|
||||
AscApiClientPostApi['/v1/buildUploads']['request']['data']['attributes']['platform'];
|
||||
|
||||
export type AscApiClientPatchApi = {
|
||||
[Path in keyof typeof PatchApi]: {
|
||||
request: z.input<(typeof PatchApi)[Path]['request']>;
|
||||
|
||||
@@ -5,9 +5,44 @@ import {
|
||||
AscApiClientGetApi,
|
||||
AscApiClientPostApi,
|
||||
AscApiRequestError,
|
||||
AscPlatform,
|
||||
} from './AscApiClient';
|
||||
|
||||
export namespace AscApiUtils {
|
||||
/**
|
||||
* Maps a bundle's `DTPlatformName` (from its Info.plist) to the App Store
|
||||
* Connect platform used for a build upload. Unknown or missing values fall
|
||||
* back to `IOS` to preserve the previous default.
|
||||
*/
|
||||
export function ascPlatformFromDtPlatformName(dtPlatformName: string | null): AscPlatform {
|
||||
switch (dtPlatformName) {
|
||||
case 'appletvos':
|
||||
return 'TV_OS';
|
||||
case 'macosx':
|
||||
return 'MAC_OS';
|
||||
case 'xros':
|
||||
return 'VISION_OS';
|
||||
case 'iphoneos':
|
||||
default:
|
||||
return 'IOS';
|
||||
}
|
||||
}
|
||||
|
||||
/** The App Store Connect TestFlight URL path segment for a platform. */
|
||||
export function testFlightPlatformPathSegment(platform: AscPlatform): string {
|
||||
switch (platform) {
|
||||
case 'TV_OS':
|
||||
return 'tvos';
|
||||
case 'MAC_OS':
|
||||
return 'macos';
|
||||
case 'VISION_OS':
|
||||
return 'visionos';
|
||||
case 'IOS':
|
||||
default:
|
||||
return 'ios';
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAppInfoAsync({
|
||||
client,
|
||||
appleAppIdentifier,
|
||||
@@ -61,18 +96,20 @@ export namespace AscApiUtils {
|
||||
appleAppIdentifier,
|
||||
bundleShortVersion,
|
||||
bundleVersion,
|
||||
platform,
|
||||
}: {
|
||||
client: Pick<AscApiClient, 'postAsync'>;
|
||||
appleAppIdentifier: string;
|
||||
bundleShortVersion: string;
|
||||
bundleVersion: string;
|
||||
platform: AscPlatform;
|
||||
}): Promise<AscApiClientPostApi['/v1/buildUploads']['response']> {
|
||||
try {
|
||||
return await client.postAsync('/v1/buildUploads', {
|
||||
data: {
|
||||
type: 'buildUploads',
|
||||
attributes: {
|
||||
platform: 'IOS',
|
||||
platform,
|
||||
cfBundleShortVersionString: bundleShortVersion,
|
||||
cfBundleVersion: bundleVersion,
|
||||
},
|
||||
|
||||
@@ -148,6 +148,7 @@ describe('AscApiUtils', () => {
|
||||
appleAppIdentifier: '1491144534',
|
||||
bundleShortVersion: '1.2.3',
|
||||
bundleVersion: '42',
|
||||
platform: 'IOS',
|
||||
})
|
||||
).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -176,10 +177,40 @@ describe('AscApiUtils', () => {
|
||||
appleAppIdentifier: '1491144534',
|
||||
bundleShortVersion: '1.2.3',
|
||||
bundleVersion: '42',
|
||||
platform: 'IOS',
|
||||
})
|
||||
).resolves.toEqual(response);
|
||||
});
|
||||
|
||||
it('sends the requested platform in the build upload attributes', async () => {
|
||||
const response = {
|
||||
data: {
|
||||
type: 'buildUploads',
|
||||
id: 'fdf9c476-aaa4-4ead-b91c-6e3cc3a47805',
|
||||
},
|
||||
} as const;
|
||||
const client = {
|
||||
postAsync: jest.fn().mockResolvedValue(response),
|
||||
};
|
||||
|
||||
await AscApiUtils.createBuildUploadAsync({
|
||||
client,
|
||||
appleAppIdentifier: '1491144534',
|
||||
bundleShortVersion: '1.2.3',
|
||||
bundleVersion: '42',
|
||||
platform: 'TV_OS',
|
||||
});
|
||||
|
||||
expect(client.postAsync).toHaveBeenCalledWith(
|
||||
'/v1/buildUploads',
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
attributes: expect.objectContaining({ platform: 'TV_OS' }),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rethrows when error payload includes mixed error codes', async () => {
|
||||
const payload = {
|
||||
errors: [
|
||||
@@ -208,8 +239,32 @@ describe('AscApiUtils', () => {
|
||||
appleAppIdentifier: '1491144534',
|
||||
bundleShortVersion: '1.2.3',
|
||||
bundleVersion: '42',
|
||||
platform: 'IOS',
|
||||
})
|
||||
).rejects.toBe(mixedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ascPlatformFromDtPlatformName', () => {
|
||||
it('maps known DTPlatformName values to App Store Connect platforms', () => {
|
||||
expect(AscApiUtils.ascPlatformFromDtPlatformName('iphoneos')).toBe('IOS');
|
||||
expect(AscApiUtils.ascPlatformFromDtPlatformName('appletvos')).toBe('TV_OS');
|
||||
expect(AscApiUtils.ascPlatformFromDtPlatformName('macosx')).toBe('MAC_OS');
|
||||
expect(AscApiUtils.ascPlatformFromDtPlatformName('xros')).toBe('VISION_OS');
|
||||
});
|
||||
|
||||
it('falls back to IOS for unknown or missing values', () => {
|
||||
expect(AscApiUtils.ascPlatformFromDtPlatformName(null)).toBe('IOS');
|
||||
expect(AscApiUtils.ascPlatformFromDtPlatformName('watchos')).toBe('IOS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('testFlightPlatformPathSegment', () => {
|
||||
it('returns the TestFlight URL segment for each platform', () => {
|
||||
expect(AscApiUtils.testFlightPlatformPathSegment('IOS')).toBe('ios');
|
||||
expect(AscApiUtils.testFlightPlatformPathSegment('TV_OS')).toBe('tvos');
|
||||
expect(AscApiUtils.testFlightPlatformPathSegment('MAC_OS')).toBe('macos');
|
||||
expect(AscApiUtils.testFlightPlatformPathSegment('VISION_OS')).toBe('visionos');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { getProxiedDownloadUrl } from '../../../utils/download';
|
||||
|
||||
const DEFAULT_XCLOGPARSER_VERSION = 'v0.2.47';
|
||||
const XCLOGPARSER_DOWNLOAD_URL = 'https://storage.googleapis.com/turtle-v2/xclogparser';
|
||||
@@ -294,24 +295,6 @@ function getXclogparserZipName(version: string): string {
|
||||
return `XCLogParser-macOS-x86-64-arm64-${version}.zip`;
|
||||
}
|
||||
|
||||
function getProxiedDownloadUrl({
|
||||
directUrl,
|
||||
proxyBaseUrl,
|
||||
}: {
|
||||
directUrl: string;
|
||||
proxyBaseUrl?: string;
|
||||
}): string | null {
|
||||
if (!proxyBaseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(directUrl);
|
||||
return directUrl.replace(
|
||||
`${parsedUrl.protocol}//${parsedUrl.host}`,
|
||||
`${proxyBaseUrl}/${parsedUrl.host}`
|
||||
);
|
||||
}
|
||||
|
||||
export function isCompileStep(step: Partial<XcactivitylogStep>): boolean {
|
||||
const detailStepType = step.detailStepType ?? '';
|
||||
const signature = step.signature ?? '';
|
||||
|
||||
@@ -10,6 +10,7 @@ import { z } from 'zod';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import { createServer } from 'node:net';
|
||||
import { clearTimeout, setTimeout } from 'node:timers';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
@@ -18,7 +19,7 @@ import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const SERVE_SIM_PACKAGE_SPEC = '@expo/serve-sim@latest';
|
||||
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
|
||||
const SERVE_SIM_HOST = '127.0.0.1';
|
||||
const SERVE_SIM_MAX_DIMENSION = '1280';
|
||||
const SERVE_SIM_MJPEG_QUALITY = '0.55';
|
||||
@@ -144,77 +145,107 @@ export async function waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal,
|
||||
maxDurationSeconds,
|
||||
signal: cancelSignal,
|
||||
idleTimeout,
|
||||
}: {
|
||||
ctx: CustomBuildContext;
|
||||
deviceRunSessionId: string;
|
||||
logger: bunyan;
|
||||
maxDurationSeconds?: number;
|
||||
signal?: AbortSignal;
|
||||
idleTimeout?: DeviceRunSessionIdleTimeout;
|
||||
}): Promise<void> {
|
||||
logger.info(
|
||||
`Remote session is live. Polling device run session ${deviceRunSessionId} until it is stopped.`
|
||||
);
|
||||
if (idleTimeout) {
|
||||
logger.info(
|
||||
`The session stops automatically after ${idleTimeout.maxIdleTimeMinutes} minute(s) without activity.`
|
||||
);
|
||||
const durationAbortController = new AbortController();
|
||||
const signal = cancelSignal
|
||||
? AbortSignal.any([cancelSignal, durationAbortController.signal])
|
||||
: durationAbortController.signal;
|
||||
// Nothing to wait for if the step was already aborted before we started;
|
||||
// return before logging so we don't claim to be polling a session we never poll.
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
let pollErrorCount = 0;
|
||||
let lastActivityAt = new Date();
|
||||
const durationTimeout =
|
||||
maxDurationSeconds === undefined
|
||||
? undefined
|
||||
: setTimeout(() => {
|
||||
logger.info(`Device run session ${deviceRunSessionId} reached its maximum duration.`);
|
||||
durationAbortController.abort();
|
||||
}, maxDurationSeconds * 1_000);
|
||||
|
||||
while (!signal?.aborted) {
|
||||
try {
|
||||
logger.info(
|
||||
`Remote session is live. Polling device run session ${deviceRunSessionId} until it is stopped.`
|
||||
);
|
||||
if (durationTimeout !== undefined) {
|
||||
logger.info(
|
||||
`The device run session will stop automatically after ${maxDurationSeconds} seconds.`
|
||||
);
|
||||
}
|
||||
if (idleTimeout) {
|
||||
const lastEventObservedAt = idleTimeout.getLastEventObservedAt();
|
||||
if (lastEventObservedAt && lastEventObservedAt > lastActivityAt) {
|
||||
lastActivityAt = lastEventObservedAt;
|
||||
}
|
||||
if (Date.now() - lastActivityAt.getTime() >= idleTimeout.maxIdleTimeMinutes * 60_000) {
|
||||
logger.info(
|
||||
`Device run session ${deviceRunSessionId} had no activity for ` +
|
||||
`${idleTimeout.maxIdleTimeMinutes} minute(s) (max idle time). Stopping the session.`
|
||||
);
|
||||
await ensureDeviceRunSessionStoppedSafelyAsync({ ctx, deviceRunSessionId, logger });
|
||||
return;
|
||||
}
|
||||
logger.info(
|
||||
`The session stops automatically after ${idleTimeout.maxIdleTimeMinutes} minute(s) without activity.`
|
||||
);
|
||||
}
|
||||
try {
|
||||
const result = await ctx.graphqlClient
|
||||
.query(DEVICE_RUN_SESSION_STATUS_QUERY, { deviceRunSessionId })
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
let pollErrorCount = 0;
|
||||
let lastActivityAt = new Date();
|
||||
|
||||
const status = result.data?.deviceRunSessions?.byId?.status;
|
||||
if (!status) {
|
||||
throw new Error(`Device run session ${deviceRunSessionId} status response was missing.`);
|
||||
}
|
||||
pollErrorCount = 0;
|
||||
if (status === 'STOPPED') {
|
||||
logger.info(`Device run session ${deviceRunSessionId} was stopped.`);
|
||||
return;
|
||||
}
|
||||
if (status === 'ERRORED') {
|
||||
throw new SystemError(`Device run session ${deviceRunSessionId} errored.`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof SystemError) {
|
||||
throw err;
|
||||
while (!signal.aborted) {
|
||||
if (idleTimeout) {
|
||||
const lastEventObservedAt = idleTimeout.getLastEventObservedAt();
|
||||
if (lastEventObservedAt && lastEventObservedAt > lastActivityAt) {
|
||||
lastActivityAt = lastEventObservedAt;
|
||||
}
|
||||
if (Date.now() - lastActivityAt.getTime() >= idleTimeout.maxIdleTimeMinutes * 60_000) {
|
||||
logger.info(
|
||||
`Device run session ${deviceRunSessionId} had no activity for ` +
|
||||
`${idleTimeout.maxIdleTimeMinutes} minute(s) (max idle time). Stopping the session.`
|
||||
);
|
||||
await ensureDeviceRunSessionStoppedSafelyAsync({ ctx, deviceRunSessionId, logger });
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await ctx.graphqlClient
|
||||
.query(DEVICE_RUN_SESSION_STATUS_QUERY, { deviceRunSessionId })
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
pollErrorCount += 1;
|
||||
if (pollErrorCount === 1 || pollErrorCount % 5 === 0) {
|
||||
Sentry.capture('Could not poll device run session status', error, { level: 'warning' });
|
||||
logger.warn(
|
||||
{ err: error, failedStatusPollCount: pollErrorCount },
|
||||
'Could not poll device run session status; will retry.'
|
||||
);
|
||||
const status = result.data?.deviceRunSessions?.byId?.status;
|
||||
if (!status) {
|
||||
throw new Error(`Device run session ${deviceRunSessionId} status response was missing.`);
|
||||
}
|
||||
pollErrorCount = 0;
|
||||
if (status === 'STOPPED') {
|
||||
logger.info(`Device run session ${deviceRunSessionId} was stopped.`);
|
||||
return;
|
||||
}
|
||||
if (status === 'ERRORED') {
|
||||
throw new SystemError(`Device run session ${deviceRunSessionId} errored.`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof SystemError) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
pollErrorCount += 1;
|
||||
if (pollErrorCount === 1 || pollErrorCount % 5 === 0) {
|
||||
Sentry.capture('Could not poll device run session status', error, { level: 'warning' });
|
||||
logger.warn(
|
||||
{ err: error, failedStatusPollCount: pollErrorCount },
|
||||
'Could not poll device run session status; will retry.'
|
||||
);
|
||||
}
|
||||
}
|
||||
await sleepUntilAbortedAsync(DEVICE_RUN_SESSION_STATUS_POLL_INTERVAL_MS, signal);
|
||||
}
|
||||
} finally {
|
||||
if (durationTimeout !== undefined) {
|
||||
clearTimeout(durationTimeout);
|
||||
}
|
||||
await sleepUntilAbortedAsync(DEVICE_RUN_SESSION_STATUS_POLL_INTERVAL_MS, signal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,18 +596,24 @@ export function metricsCorsOriginToServeSimArgs(env: BuildStepEnv): string[] {
|
||||
return args;
|
||||
}
|
||||
|
||||
function createServeSimPackageSpec(packageVersion: string | undefined): string {
|
||||
return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
|
||||
}
|
||||
|
||||
export function createServeSimArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
metricsCorsArgs = [],
|
||||
packageVersion,
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
metricsCorsArgs?: string[];
|
||||
packageVersion?: string;
|
||||
}): string[] {
|
||||
return [
|
||||
'--yes',
|
||||
SERVE_SIM_PACKAGE_SPEC,
|
||||
createServeSimPackageSpec(packageVersion),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
@@ -669,20 +706,24 @@ export async function startServeSimWithTunnelAsync(
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
packageVersion,
|
||||
}: {
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
}
|
||||
): Promise<ServeSimPreviewHandle> {
|
||||
const port = await findAvailablePortAsync();
|
||||
logger.info(`Launching ${SERVE_SIM_PACKAGE_SPEC} on ${SERVE_SIM_HOST}:${port}.`);
|
||||
logger.info(
|
||||
`Launching ${createServeSimPackageSpec(packageVersion)} on ${SERVE_SIM_HOST}:${port}.`
|
||||
);
|
||||
const turnArgs = await fetchServeSimTurnArgsAsync(ctx, { env, logger });
|
||||
const metricsCorsArgs = metricsCorsOriginToServeSimArgs(env);
|
||||
const serveSim = spawnDetached({
|
||||
command: 'npx',
|
||||
args: createServeSimArgs({ port, turnArgs, metricsCorsArgs }),
|
||||
args: createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }),
|
||||
env,
|
||||
});
|
||||
|
||||
@@ -691,7 +732,7 @@ export async function startServeSimWithTunnelAsync(
|
||||
await waitForServeSimReadyAsync({ serveSim, port, timeoutMs });
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix: 'serve-sim',
|
||||
subdomainPrefix: 'web-preview',
|
||||
baseDomain,
|
||||
authtoken: getNgrokAuthtokenOrThrow(env),
|
||||
logger,
|
||||
|
||||
@@ -6,11 +6,13 @@ import assert from 'assert';
|
||||
import FastGlob from 'fast-glob';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import { Socket } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Sentry } from '../sentry';
|
||||
import { retryAsync } from './retry';
|
||||
|
||||
/** Android Virtual Device is the device we run. */
|
||||
@@ -305,21 +307,23 @@ export namespace AndroidEmulatorUtils {
|
||||
emulatorPromise: SpawnPromise<SpawnResult>;
|
||||
serialId: AndroidDeviceSerialId;
|
||||
logcatOutputPath: string;
|
||||
emulatorOutputPath: string;
|
||||
}> {
|
||||
let logcatOutputPath: string;
|
||||
let emulatorOutputPath: string;
|
||||
try {
|
||||
await fs.promises.mkdir(logcatDirectory, { recursive: true });
|
||||
const safeDeviceName = deviceName.replace(/[^a-zA-Z0-9_.-]/g, '_');
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
.toString(16)
|
||||
.padStart(8, '0');
|
||||
logcatOutputPath = path.join(
|
||||
logcatDirectory,
|
||||
`${safeDeviceName}-${timestamp}-${randomBytes(2).toString('hex')}.log`
|
||||
);
|
||||
const outputName = `${safeDeviceName}-${timestamp}-${randomBytes(2).toString('hex')}`;
|
||||
logcatOutputPath = path.join(logcatDirectory, `${outputName}-logcat.log`);
|
||||
emulatorOutputPath = path.join(logcatDirectory, `${outputName}-emulator.log`);
|
||||
await fs.promises.writeFile(logcatOutputPath, '');
|
||||
await fs.promises.writeFile(emulatorOutputPath, '');
|
||||
} catch (err) {
|
||||
throw new SystemError(`Failed to prepare Android emulator logcat output for ${deviceName}.`, {
|
||||
throw new SystemError(`Failed to prepare Android emulator output for ${deviceName}.`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
@@ -346,7 +350,8 @@ export namespace AndroidEmulatorUtils {
|
||||
],
|
||||
{
|
||||
detached: true,
|
||||
stdio: 'inherit',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
ignoreStdio: true,
|
||||
env: {
|
||||
...env,
|
||||
// We don't need to wait for emulator to exit gracefully.
|
||||
@@ -354,6 +359,43 @@ export namespace AndroidEmulatorUtils {
|
||||
},
|
||||
}
|
||||
);
|
||||
const emulatorOutputStream = fs.createWriteStream(emulatorOutputPath, { flags: 'a' });
|
||||
let reportedEmulatorOutputError = false;
|
||||
emulatorOutputStream.on('error', err => {
|
||||
process.stderr.write(
|
||||
`Failed to write Android emulator output to ${emulatorOutputPath}: ${err}\n`
|
||||
);
|
||||
if (!reportedEmulatorOutputError) {
|
||||
reportedEmulatorOutputError = true;
|
||||
Sentry.capture('Failed to write Android emulator process output', err, {
|
||||
level: 'warning',
|
||||
tags: {
|
||||
errorCode: (err as NodeJS.ErrnoException).code ?? 'unknown',
|
||||
},
|
||||
extras: {
|
||||
deviceName,
|
||||
emulatorOutputPath,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
// Only into the log file -- this process' stdout/stderr is not watched or uploaded.
|
||||
emulatorPromise.child.stdout?.pipe(emulatorOutputStream, { end: false });
|
||||
emulatorPromise.child.stderr?.pipe(emulatorOutputStream, { end: false });
|
||||
emulatorPromise.child.once('close', () => {
|
||||
emulatorOutputStream.end();
|
||||
});
|
||||
// Piped stdio creates socket handles in this process which, unlike inherited
|
||||
// file descriptors, keep the event loop alive for as long as the emulator runs.
|
||||
// We never stop the emulator explicitly, so without unref-ing these the process
|
||||
// would never exit on its own -- defeating the `detached` + `unref()` below.
|
||||
// Output is still captured for as long as this process lives.
|
||||
if (emulatorPromise.child.stdout instanceof Socket) {
|
||||
emulatorPromise.child.stdout.unref();
|
||||
}
|
||||
if (emulatorPromise.child.stderr instanceof Socket) {
|
||||
emulatorPromise.child.stderr.unref();
|
||||
}
|
||||
// If emulator fails to start, throw its error.
|
||||
if (!emulatorPromise.child.pid) {
|
||||
await emulatorPromise;
|
||||
@@ -382,7 +424,7 @@ export namespace AndroidEmulatorUtils {
|
||||
|
||||
// We don't want to await the SpawnPromise here.
|
||||
// eslint-disable-next-line @typescript-eslint/return-await
|
||||
return { emulatorPromise, serialId, logcatOutputPath };
|
||||
return { emulatorPromise, serialId, logcatOutputPath, emulatorOutputPath };
|
||||
}
|
||||
|
||||
export async function waitForReadyAsync({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -273,7 +273,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
expect(attemptCounter).toBe(2);
|
||||
}, 360_000);
|
||||
|
||||
it('captures native logcat output across an ADB server restart', async () => {
|
||||
it('captures emulator and native logcat output across an ADB server restart', async () => {
|
||||
const testLogcatDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'android-emulator-logcat-e2e-')
|
||||
);
|
||||
@@ -302,6 +302,21 @@ describe('AndroidEmulatorUtils', () => {
|
||||
emulatorPromise = asyncResult(startResult.emulatorPromise);
|
||||
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({ serialId, env: process.env });
|
||||
await retryAsync(
|
||||
async () => {
|
||||
const contents = await fs.promises.readFile(startResult.emulatorOutputPath, 'utf-8');
|
||||
if (!contents.includes('Android emulator version')) {
|
||||
throw new Error('Did not find the emulator version in emulator process output yet.');
|
||||
}
|
||||
},
|
||||
{
|
||||
logger,
|
||||
retryOptions: {
|
||||
retries: 10,
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
);
|
||||
await spawn('adb', ['kill-server'], { env: process.env });
|
||||
await spawn('adb', ['start-server'], { env: process.env });
|
||||
await spawn('adb', ['-s', serialId, 'shell', 'log', '-t', 'EAS_CLI_TEST', marker], {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { EventEmitter, once } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import { Socket } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { AndroidEmulatorUtils, AndroidVirtualDeviceName } from '../AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
|
||||
@@ -17,6 +20,12 @@ jest.mock('../retry', () => ({
|
||||
retryAsync: jest.fn(async fn => await fn(0)),
|
||||
}));
|
||||
|
||||
jest.mock('../../sentry', () => ({
|
||||
Sentry: {
|
||||
capture: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedRetryAsync = jest.mocked(retryAsync);
|
||||
|
||||
@@ -25,11 +34,13 @@ describe('AndroidEmulatorUtils', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
temporaryDirectories = [];
|
||||
jest.mocked(Sentry.capture).mockReset();
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
mockedRetryAsync.mockImplementation(async fn => await fn(0));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
jest.restoreAllMocks();
|
||||
await Promise.all(
|
||||
temporaryDirectories.map(async temporaryDirectory => {
|
||||
await fs.promises.rm(temporaryDirectory, { force: true, recursive: true });
|
||||
@@ -39,7 +50,18 @@ describe('AndroidEmulatorUtils', () => {
|
||||
|
||||
describe(AndroidEmulatorUtils.startAsync, () => {
|
||||
function mockSuccessfulStart(deviceName: AndroidVirtualDeviceName) {
|
||||
const child = { pid: 1234, unref: jest.fn() };
|
||||
// Under `stdio: 'pipe'` these are net.Sockets, which is what startAsync
|
||||
// narrows on before unref-ing them.
|
||||
const stdout = new Socket();
|
||||
const stderr = new Socket();
|
||||
jest.spyOn(stdout, 'unref').mockReturnValue(stdout);
|
||||
jest.spyOn(stderr, 'unref').mockReturnValue(stderr);
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
pid: 1234,
|
||||
unref: jest.fn(),
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
const spawnPromise = Promise.resolve({ stdout: '', stderr: '' }) as any;
|
||||
spawnPromise.child = child;
|
||||
mockedSpawn.mockImplementation(((command: string, args: string[]) => {
|
||||
@@ -57,11 +79,12 @@ describe('AndroidEmulatorUtils', () => {
|
||||
return { child };
|
||||
}
|
||||
|
||||
it('captures logcat through the emulator process', async () => {
|
||||
it('captures logcat and emulator process output', async () => {
|
||||
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'logcat-staging-'));
|
||||
temporaryDirectories.push(outputDir);
|
||||
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
|
||||
const { child } = mockSuccessfulStart(deviceName);
|
||||
const createWriteStreamSpy = jest.spyOn(fs, 'createWriteStream');
|
||||
|
||||
const result = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
@@ -70,13 +93,21 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
|
||||
expect(result.logcatOutputPath).toMatch(
|
||||
new RegExp(`^${outputDir}/eas-simulator-[a-f0-9]{8}-[a-f0-9]{4}\\.log$`)
|
||||
new RegExp(`^${outputDir}/eas-simulator-[a-f0-9]{8}-[a-f0-9]{4}-logcat\\.log$`)
|
||||
);
|
||||
expect(result.emulatorOutputPath).toMatch(
|
||||
new RegExp(`^${outputDir}/eas-simulator-[a-f0-9]{8}-[a-f0-9]{4}-emulator\\.log$`)
|
||||
);
|
||||
await expect(fs.promises.access(result.logcatOutputPath)).resolves.toBeUndefined();
|
||||
await expect(fs.promises.access(result.emulatorOutputPath)).resolves.toBeUndefined();
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/emulator\/emulator$/),
|
||||
expect.arrayContaining(['-logcat', '*:v', '-logcat-output', result.logcatOutputPath]),
|
||||
expect.objectContaining({ detached: true, stdio: 'inherit' })
|
||||
expect.objectContaining({
|
||||
detached: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
ignoreStdio: true,
|
||||
})
|
||||
);
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith(
|
||||
'adb',
|
||||
@@ -84,6 +115,32 @@ describe('AndroidEmulatorUtils', () => {
|
||||
expect.anything()
|
||||
);
|
||||
expect(child.unref).toHaveBeenCalled();
|
||||
// Piped stdio would otherwise keep this process' event loop alive
|
||||
// for as long as the emulator runs.
|
||||
expect(child.stdout.unref).toHaveBeenCalled();
|
||||
expect(child.stderr.unref).toHaveBeenCalled();
|
||||
const emulatorOutputStream = createWriteStreamSpy.mock.results[0].value;
|
||||
const stderrWriteSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
const writeError = Object.assign(new Error('disk full'), { code: 'ENOSPC' });
|
||||
emulatorOutputStream.emit('error', writeError);
|
||||
emulatorOutputStream.emit('error', writeError);
|
||||
expect(stderrWriteSpy).toHaveBeenCalledTimes(2);
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to write Android emulator process output',
|
||||
writeError,
|
||||
{
|
||||
level: 'warning',
|
||||
tags: { errorCode: 'ENOSPC' },
|
||||
extras: {
|
||||
deviceName,
|
||||
emulatorOutputPath: result.emulatorOutputPath,
|
||||
},
|
||||
}
|
||||
);
|
||||
const emulatorOutputStreamClosed = once(emulatorOutputStream, 'close');
|
||||
child.emit('close');
|
||||
await emulatorOutputStreamClosed;
|
||||
});
|
||||
|
||||
it('throws a SystemError when the staging directory cannot be prepared', async () => {
|
||||
@@ -100,7 +157,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
logcatDirectory: outputDir,
|
||||
})
|
||||
).rejects.toEqual(
|
||||
new SystemError('Failed to prepare Android emulator logcat output for eas-simulator.', {
|
||||
new SystemError('Failed to prepare Android emulator output for eas-simulator.', {
|
||||
cause: mkdirError,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -23,6 +23,41 @@ describe('IosSimulatorUtils', () => {
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.getAvailableRuntimesAsync, () => {
|
||||
it('returns available iOS runtimes', async () => {
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
runtimes: [
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
|
||||
isAvailable: true,
|
||||
version: '18.3.1',
|
||||
},
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-17-5',
|
||||
isAvailable: false,
|
||||
version: '17.5',
|
||||
},
|
||||
],
|
||||
}),
|
||||
stderr: '',
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.getAvailableRuntimesAsync({ env: process.env })
|
||||
).resolves.toEqual([
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
|
||||
isAvailable: true,
|
||||
version: '18.3.1',
|
||||
},
|
||||
]);
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('xcrun', ['simctl', 'list', 'runtimes', '--json'], {
|
||||
env: process.env,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.getAvailableDevicesAsync, () => {
|
||||
it('adds a human-readable runtime display name', async () => {
|
||||
mockedSpawn.mockResolvedValue({
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
|
||||
import { getCcacheKeyPrefix } from '../cacheKey';
|
||||
|
||||
describe(getCcacheKeyPrefix, () => {
|
||||
it('uses separate prefixes for iOS device and simulator builds', () => {
|
||||
expect(getCcacheKeyPrefix({ platform: Platform.IOS, simulator: false })).toBe(
|
||||
'ios-device-ccache-'
|
||||
);
|
||||
expect(getCcacheKeyPrefix({ platform: Platform.IOS, simulator: true })).toBe(
|
||||
'ios-simulator-ccache-'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not change the Android prefix', () => {
|
||||
expect(getCcacheKeyPrefix({ platform: Platform.ANDROID })).toBe('android-ccache-');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { getProxiedDownloadUrl } from '../download';
|
||||
|
||||
describe(getProxiedDownloadUrl, () => {
|
||||
it('returns null when no proxy base URL is set', () => {
|
||||
expect(
|
||||
getProxiedDownloadUrl({ directUrl: 'https://storage.googleapis.com/bucket/object' })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rewrites the origin to the proxy base URL', () => {
|
||||
expect(
|
||||
getProxiedDownloadUrl({
|
||||
directUrl: 'https://storage.googleapis.com/bucket/object',
|
||||
proxyBaseUrl: 'https://cache.example.com',
|
||||
})
|
||||
).toBe('https://cache.example.com/storage.googleapis.com/bucket/object');
|
||||
});
|
||||
|
||||
it('normalizes a proxy base URL with a trailing slash', () => {
|
||||
expect(
|
||||
getProxiedDownloadUrl({
|
||||
directUrl: 'https://storage.googleapis.com/bucket/object',
|
||||
proxyBaseUrl: 'https://cache.example.com/',
|
||||
})
|
||||
).toBe('https://cache.example.com/storage.googleapis.com/bucket/object');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import resolveFrom from 'resolve-from';
|
||||
import spawnAsync from '@expo/turtle-spawn';
|
||||
|
||||
import { expoCommandAsync, ExpoCLIModuleNotFoundError } from '../expoCli';
|
||||
import { ExpoCLIModuleNotFoundError, expoCommandAsync } from '../expoCli';
|
||||
|
||||
jest.mock('resolve-from', () => ({
|
||||
__esModule: true,
|
||||
|
||||
@@ -7,17 +7,24 @@ import path from 'path';
|
||||
|
||||
import { findPackagerRootDir } from './packageManager';
|
||||
|
||||
const IOS_CACHE_KEY_PREFIX = 'ios-ccache-';
|
||||
const IOS_DEVICE_CACHE_KEY_PREFIX = 'ios-device-ccache-';
|
||||
const IOS_SIMULATOR_CACHE_KEY_PREFIX = 'ios-simulator-ccache-';
|
||||
const ANDROID_CACHE_KEY_PREFIX = 'android-ccache-';
|
||||
const PUBLIC_IOS_CACHE_KEY_PREFIX = 'public-ios-ccache-';
|
||||
const PUBLIC_ANDROID_CACHE_KEY_PREFIX = 'public-android-ccache-';
|
||||
const DARWIN_CACHE_PATH = 'Library/Caches/ccache';
|
||||
const LINUX_CACHE_PATH = '.cache/ccache';
|
||||
|
||||
export const CACHE_KEY_PREFIX_BY_PLATFORM: Record<Platform, string> = {
|
||||
[Platform.ANDROID]: ANDROID_CACHE_KEY_PREFIX,
|
||||
[Platform.IOS]: IOS_CACHE_KEY_PREFIX,
|
||||
};
|
||||
export type CcacheBuildTarget =
|
||||
| { platform: Platform.ANDROID }
|
||||
| { platform: Platform.IOS; simulator: boolean };
|
||||
|
||||
export function getCcacheKeyPrefix(target: CcacheBuildTarget): string {
|
||||
if (target.platform === Platform.IOS) {
|
||||
return target.simulator ? IOS_SIMULATOR_CACHE_KEY_PREFIX : IOS_DEVICE_CACHE_KEY_PREFIX;
|
||||
}
|
||||
return ANDROID_CACHE_KEY_PREFIX;
|
||||
}
|
||||
|
||||
export const PUBLIC_CACHE_KEY_PREFIX_BY_PLATFORM: Record<Platform, string> = {
|
||||
[Platform.ANDROID]: PUBLIC_ANDROID_CACHE_KEY_PREFIX,
|
||||
@@ -36,7 +43,7 @@ export function getCcachePath(env: Record<string, string | undefined>): string {
|
||||
|
||||
export async function generateDefaultBuildCacheKeyAsync(
|
||||
workingDirectory: string,
|
||||
platform: Platform
|
||||
target: CcacheBuildTarget
|
||||
): Promise<string> {
|
||||
// This will resolve which package manager and use the relevant lock file
|
||||
// The lock file hash is the key and ensures cache is fresh
|
||||
@@ -45,7 +52,7 @@ export async function generateDefaultBuildCacheKeyAsync(
|
||||
const lockPath = path.join(packagerRunDir, manager.lockFile);
|
||||
|
||||
try {
|
||||
return `${CACHE_KEY_PREFIX_BY_PLATFORM[platform]}${hashFiles([lockPath])}`;
|
||||
return `${getCcacheKeyPrefix(target)}${hashFiles([lockPath])}`;
|
||||
} catch (err: any) {
|
||||
throw new Error(`Failed to read lockfile for cache key generation: ${err.message}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export function getProxiedDownloadUrl({
|
||||
directUrl,
|
||||
proxyBaseUrl,
|
||||
}: {
|
||||
directUrl: string;
|
||||
proxyBaseUrl?: string;
|
||||
}): string | null {
|
||||
if (!proxyBaseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(directUrl);
|
||||
return directUrl.replace(
|
||||
`${parsedUrl.protocol}//${parsedUrl.host}`,
|
||||
`${proxyBaseUrl.replace(/\/$/, '')}/${parsedUrl.host}`
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "22.0.0",
|
||||
"version": "22.4.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"prepack": "yarn run clean && yarn run build",
|
||||
"lint": "oxlint --type-aware --tsconfig ../../tsconfig.oxlint.json .",
|
||||
"lint": "oxlint --type-aware --max-warnings=0 --tsconfig ../../tsconfig.oxlint.json .",
|
||||
"test": "echo 'No tests yet.'",
|
||||
"watch": "yarn run build:dev -w",
|
||||
"build:dev": "ncc build ./src/index.ts -o build/",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "22.0.0"
|
||||
"@expo/steps": "22.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
+302
-208
File diff suppressed because it is too large.
Load diff
Generated
+731
-14
@@ -10454,6 +10454,22 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "isEligibleForObserveNotice",
|
||||
"description": "Tells you if the project can show the Observe promotional notification. This field is part of\nthe observe-notification experiment.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": true,
|
||||
"deprecationReason": "This field is deprecated from its first release. It supplies data for the observe-notification promotion on the project page. It will be removed when the promotion ends. Do not rely on it."
|
||||
},
|
||||
{
|
||||
"name": "lastDeletionAttemptTime",
|
||||
"description": null,
|
||||
@@ -14829,6 +14845,39 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "overviewStability",
|
||||
"description": "Crash-free rates with previous-period comparison for the Overview stability tiles.",
|
||||
"args": [
|
||||
{
|
||||
"name": "input",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "AppObserveOverviewStabilityInput",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "AppObserveOverviewStability",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "overviewUpdateComparison",
|
||||
"description": "Per-update comparison within one app version: the embedded bundle plus each OTA update.",
|
||||
@@ -16091,6 +16140,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "symbolicationBuild",
|
||||
"description": "Build whose uploaded source map symbolicated exceptionStacktrace; null when the stack trace is shown as reported.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "OBJECT",
|
||||
"name": "Build",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "timestamp",
|
||||
"description": null,
|
||||
@@ -17816,6 +17877,145 @@
|
||||
],
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "AppObserveErrorPlatformStats",
|
||||
"description": "AppObserveErrorStats for one platform; iOS includes iPadOS and tvOS.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "affectedSessions",
|
||||
"description": "Distinct sessions on this platform that hit any exception.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "affectedUsers",
|
||||
"description": "Distinct users on this platform that hit any exception.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "crashFreeSessions",
|
||||
"description": "Fraction (0..1) of this platform's active sessions without a fatal error.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Float",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "crashFreeUsers",
|
||||
"description": "Fraction (0..1) of this platform's active users without a fatal error.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Float",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "fatalCount",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "nonFatalCount",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "platform",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "ENUM",
|
||||
"name": "AppObservePlatform",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "totalErrors",
|
||||
"description": "Total exception events in range on this platform.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
"interfaces": [],
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "ENUM",
|
||||
"name": "AppObserveErrorSeverity",
|
||||
@@ -19706,7 +19906,7 @@
|
||||
"fields": [
|
||||
{
|
||||
"name": "metrics",
|
||||
"description": "One entry per (metric, platform) with data in range; iOS includes tvOS and iPadOS.",
|
||||
"description": "One entry per (metric, platform) with data in range; one entry per exact device platform.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -19988,8 +20188,8 @@
|
||||
"description": null,
|
||||
"fields": [
|
||||
{
|
||||
"name": "affectedUsers",
|
||||
"description": null,
|
||||
"name": "crashCount",
|
||||
"description": "Fatal exception events in range.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -20035,8 +20235,163 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "crashedUsers",
|
||||
"description": "Distinct users that hit a fatal error in range.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "previousPeriodCrashCount",
|
||||
"description": "Fatal exception events in the equal-length window before startTime.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "previousPeriodCrashFreeSessions",
|
||||
"description": "Same fractions for the equal-length window before startTime; null when it had no activity.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Float",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "previousPeriodCrashFreeUsers",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Float",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "previousPeriodCrashedUsers",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "series",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "AppObserveOverviewStabilityBucket",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "totalErrors",
|
||||
"description": "All exception events in range, for distinguishing quiet apps from crash-free ones.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
"interfaces": [],
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "AppObserveOverviewStabilityBucket",
|
||||
"description": null,
|
||||
"fields": [
|
||||
{
|
||||
"name": "affectedUsers",
|
||||
"description": "Approximate unique users that hit any exception in this bucket. Not summable across buckets.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "bucketStart",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "DateTime",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "crashCount",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
@@ -20057,6 +20412,85 @@
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "AppObserveOverviewStabilityInput",
|
||||
"description": "Stability-tile filters. No release filters: the tiles always span all versions.",
|
||||
"fields": null,
|
||||
"inputFields": [
|
||||
{
|
||||
"name": "bucketIntervalMinutes",
|
||||
"description": "Series bucket size. Defaults to one day.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "endTime",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "DateTime",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "environment",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "platform",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "ENUM",
|
||||
"name": "AppObservePlatform",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "startTime",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "DateTime",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"interfaces": null,
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "AppObserveOverviewUpdate",
|
||||
@@ -20208,15 +20642,23 @@
|
||||
},
|
||||
{
|
||||
"name": "stability",
|
||||
"description": null,
|
||||
"description": "Error stats for this update, one entry per platform with activity; iOS includes iPadOS and tvOS.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "AppObserveOverviewStability",
|
||||
"ofType": null
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "AppObserveErrorPlatformStats",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
@@ -20543,7 +20985,7 @@
|
||||
},
|
||||
{
|
||||
"name": "metrics",
|
||||
"description": "One entry per (metric, platform) with data in range; iOS includes tvOS and iPadOS.",
|
||||
"description": "One entry per (metric, platform) with data in range; each device OS (iOS, iPadOS, tvOS, macOS, Android) is a separate platform.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -20567,15 +21009,23 @@
|
||||
},
|
||||
{
|
||||
"name": "stability",
|
||||
"description": "Error stats for this version, all platforms combined.",
|
||||
"description": "Error stats for this version, one entry per platform with activity; iOS includes iPadOS and tvOS.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "AppObserveOverviewStability",
|
||||
"ofType": null
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "AppObserveErrorPlatformStats",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
@@ -20926,6 +21376,24 @@
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "IPADOS",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "MACOS",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "TVOS",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"possibleTypes": null
|
||||
@@ -24164,6 +24632,30 @@
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "unavailableServiceUsageMetrics",
|
||||
"description": "Services whose estimates could not be computed and are omitted from the other fields.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "UnavailableServiceUsageMetric",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
@@ -25273,6 +25765,61 @@
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "AppiumRunSessionRemoteConfig",
|
||||
"description": null,
|
||||
"fields": [
|
||||
{
|
||||
"name": "appiumUrl",
|
||||
"description": "Appium server URL for the remote device.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "capabilities",
|
||||
"description": "W3C capabilities for the device that backs this session.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "JSONObject",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "webPreviewUrl",
|
||||
"description": "URL of the web preview surface for the session. Null when web previews are\nnot available for the platform (e.g. Android).",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
"interfaces": [],
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "AppleAppIdentifier",
|
||||
@@ -36664,9 +37211,77 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "applicationArchiveUrl",
|
||||
"description": "Application archive URL to download, install, and launch before the simulator session\nbecomes available. Mutually exclusive with buildId and expoGo.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "buildId",
|
||||
"description": "EAS Build to install and launch before the simulator session becomes available.\nMutually exclusive with applicationArchiveUrl and expoGo.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "ID",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "deviceIdentifier",
|
||||
"description": "Identifier of the virtual device to start for the session. On iOS this is a\nSimulator device name or UDID (e.g. \"iPhone 16 Pro\"). On Android this is an\nAVD hardware profile id (e.g. \"pixel_7\"). If omitted, the runner picks a\ndefault device.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"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 and argent sessions. If omitted, the session has no idle\ntimeout.",
|
||||
"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.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
@@ -36678,7 +37293,7 @@
|
||||
},
|
||||
{
|
||||
"name": "maxRunTimeMinutes",
|
||||
"description": "Override for the underlying turtle job run's max run time, in minutes. Must\nbe non-negative and smaller than 120 (2 hours). Only customizable on paid\nplans. If omitted, the default is derived based on the job run's priority.",
|
||||
"description": "Maximum usable session duration, in minutes, starting when the remote session\nis ready. Must be non-negative and no greater than 40 for normal-priority\naccounts or 115 for high-priority accounts. The backing job receives an\nadditional five minutes for cleanup.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
@@ -36700,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\".",
|
||||
@@ -36728,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,
|
||||
@@ -43068,6 +43707,11 @@
|
||||
"name": "AgentDeviceRunSessionRemoteConfig",
|
||||
"ofType": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "AppiumRunSessionRemoteConfig",
|
||||
"ofType": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "ArgentRunSessionRemoteConfig",
|
||||
@@ -43129,6 +43773,12 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "APPIUM",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "ARGENT",
|
||||
"description": null,
|
||||
@@ -74486,6 +75136,49 @@
|
||||
],
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "UnavailableServiceUsageMetric",
|
||||
"description": null,
|
||||
"fields": [
|
||||
{
|
||||
"name": "message",
|
||||
"description": "User-displayable explanation of why the service's estimate is missing.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "service",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "ENUM",
|
||||
"name": "EASService",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
"interfaces": [],
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "UniqueUsersOverTimeData",
|
||||
@@ -79572,6 +80265,30 @@
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "unavailableServiceUsageMetrics",
|
||||
"description": "Services whose estimates could not be computed and are omitted from the other fields.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "UnavailableServiceUsageMetric",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "22.0.0",
|
||||
"version": "22.4.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -64,7 +64,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "22.0.0",
|
||||
"@expo/steps": "22.4.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -72,7 +72,7 @@
|
||||
"@sentry/node": "7.77.0",
|
||||
"@urql/core": "4.0.11",
|
||||
"@urql/exchange-retry": "1.2.0",
|
||||
"agent-cli-detector": "0.1.2",
|
||||
"agent-cli-detector": "0.1.6",
|
||||
"ajv": "8.11.0",
|
||||
"ajv-formats": "2.1.1",
|
||||
"better-opn": "3.0.2",
|
||||
@@ -110,6 +110,7 @@
|
||||
"node-forge": "1.4.0",
|
||||
"node-stream-zip": "1.15.0",
|
||||
"nullthrows": "1.1.1",
|
||||
"open": "8.4.0",
|
||||
"ora": "5.1.0",
|
||||
"pkg-dir": "4.2.0",
|
||||
"pngjs": "7.0.0",
|
||||
@@ -189,6 +190,9 @@
|
||||
"account": {
|
||||
"description": "manage account"
|
||||
},
|
||||
"billing": {
|
||||
"description": "manage account billing and subscriptions"
|
||||
},
|
||||
"branch": {
|
||||
"description": "manage update branches"
|
||||
},
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { getConfigFilePaths } from '@expo/config';
|
||||
|
||||
import { getWorkflowRunUrl } from '../../build/utils/url';
|
||||
import Go from '../../commands/go';
|
||||
import Go, { toRepackTargetSdkVersion } from '../../commands/go';
|
||||
import { WorkflowRunStatus } from '../../graphql/generated';
|
||||
import { WorkflowRunMutation } from '../../graphql/mutations/WorkflowRunMutation';
|
||||
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
|
||||
import Log from '../../log';
|
||||
import { selectAsync } from '../../prompts';
|
||||
import { detectProjectSdkVersionAsync } from '../../project/detectProjectSdkVersionAsync';
|
||||
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
|
||||
import { uploadAccountScopedFileAsync } from '../../project/uploadAccountScopedFileAsync';
|
||||
import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccountScopedProjectSourceAsync';
|
||||
import { ensureActorHasPrimaryAccount } from '../../user/actions';
|
||||
import { detectProjectSdkVersionAsync } from '../../commands/go';
|
||||
import { mockTestCommand } from './utils';
|
||||
|
||||
jest.mock('@expo/config', () => ({
|
||||
@@ -81,6 +81,25 @@ describe('detectProjectSdkVersionAsync', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('toRepackTargetSdkVersion', () => {
|
||||
it.each([
|
||||
['57', '57.0.0'],
|
||||
['57.0.0', '57.0.0'],
|
||||
['57.0.9', '57.0.0'],
|
||||
['57.0.0-canary-20260101', '57.0.0'],
|
||||
])('resolves %s to the %s repack target', (sdkVersion, expected) => {
|
||||
expect(toRepackTargetSdkVersion(sdkVersion)).toBe(expected);
|
||||
});
|
||||
|
||||
it('returns undefined when no version is requested', () => {
|
||||
expect(toRepackTargetSdkVersion(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the requested version unchanged when it holds no version number', () => {
|
||||
expect(toRepackTargetSdkVersion('latest')).toBe('latest');
|
||||
});
|
||||
});
|
||||
|
||||
const mockAccount = { id: 'account-id', name: 'testuser' };
|
||||
const mockActor = {
|
||||
__typename: 'User' as const,
|
||||
@@ -112,7 +131,7 @@ describe('Go command', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function makeCmd(argv: string[] = []) {
|
||||
function makeCmd(argv: string[] = []): Go {
|
||||
const ctx = {
|
||||
loggedIn: { actor: mockActor as any, graphqlClient: {} as any },
|
||||
analytics: {} as any,
|
||||
@@ -149,6 +168,17 @@ describe('Go command', () => {
|
||||
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('Auto-selected'));
|
||||
});
|
||||
|
||||
it('requests the repack configuration with the repack target SDK version', async () => {
|
||||
mockGetConfigFilePaths.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
|
||||
|
||||
await makeCmd(['--sdk-version', '57']).run();
|
||||
|
||||
expect(WorkflowRunQuery.expoGoRepackConfigurationAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ sdkVersion: '57.0.0' })
|
||||
);
|
||||
});
|
||||
|
||||
it('prompts for SDK version when no project config is found', async () => {
|
||||
mockGetConfigFilePaths.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
|
||||
jest.mocked(WorkflowRunQuery.expoGoSupportedSdkVersionsAsync).mockResolvedValue([
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import crypto from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { computeFileMd5Base64Async } from '../uploads';
|
||||
|
||||
describe(computeFileMd5Base64Async, () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-cli-uploads-test-'));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.remove(tmpDir);
|
||||
});
|
||||
|
||||
it('computes the base64 MD5 checksum of a small file', async () => {
|
||||
const file = path.join(tmpDir, 'small.txt');
|
||||
await fs.writeFile(file, 'abc');
|
||||
// md5("abc") = 900150983cd24fb0d6963f7d28e17f72
|
||||
expect(await computeFileMd5Base64Async(file)).toBe('kAFQmDzST7DWlj99KOF/cg==');
|
||||
});
|
||||
|
||||
it('computes the same checksum as a one-shot hash for a file larger than one stream chunk', async () => {
|
||||
const file = path.join(tmpDir, 'large.bin');
|
||||
const data = Buffer.alloc(256 * 1024);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
data[i] = i % 251;
|
||||
}
|
||||
await fs.writeFile(file, data);
|
||||
const expected = crypto.createHash('md5').update(data).digest('base64');
|
||||
expect(await computeFileMd5Base64Async(file)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,18 @@ type FingerprintOrigin = {
|
||||
update?: UpdateFragment;
|
||||
};
|
||||
|
||||
type FingerprintInfo = {
|
||||
fingerprint: Fingerprint;
|
||||
platforms?: AppPlatform[];
|
||||
origin: FingerprintOrigin;
|
||||
/**
|
||||
* Whether the uploaded fingerprint source file could not be downloaded or parsed
|
||||
* (e.g. it expired in GCS or is zero-length). The hash is still valid in that case,
|
||||
* but `fingerprint.sources` is empty and a detailed diff cannot be computed.
|
||||
*/
|
||||
isFingerprintSourceUnavailable: boolean;
|
||||
};
|
||||
|
||||
export default class FingerprintCompare extends EasCommand {
|
||||
static override description = 'compare fingerprints of the current project, builds, and updates';
|
||||
static override strict = false;
|
||||
@@ -170,7 +182,15 @@ export default class FingerprintCompare extends EasCommand {
|
||||
const { fingerprint: secondFingerprint, origin: secondFingerprintOrigin } =
|
||||
secondFingerprintInfo;
|
||||
|
||||
const unavailableSourceHashes = [
|
||||
firstFingerprintInfo.isFingerprintSourceUnavailable ? firstFingerprint.hash : null,
|
||||
secondFingerprintInfo.isFingerprintSourceUnavailable ? secondFingerprint.hash : null,
|
||||
].filter((hash): hash is string => hash !== null);
|
||||
|
||||
if (json) {
|
||||
if (unavailableSourceHashes.length > 0) {
|
||||
throw new Error(getUnavailableSourceMessage(unavailableSourceHashes));
|
||||
}
|
||||
printJsonOnlyOutput({ fingerprint1: firstFingerprint, fingerprint2: secondFingerprint });
|
||||
return;
|
||||
}
|
||||
@@ -190,55 +210,62 @@ export default class FingerprintCompare extends EasCommand {
|
||||
);
|
||||
}
|
||||
|
||||
const fingerprintDiffs = diffFingerprint(projectDir, firstFingerprint, secondFingerprint);
|
||||
if (!fingerprintDiffs) {
|
||||
Log.error('Fingerprint diffs can only be computed for projects with SDK 52 or higher');
|
||||
return;
|
||||
}
|
||||
|
||||
const filePathDiffs = fingerprintDiffs.filter(diff => {
|
||||
let sourceType;
|
||||
if (diff.op === 'added') {
|
||||
sourceType = diff.addedSource.type;
|
||||
} else if (diff.op === 'removed') {
|
||||
sourceType = diff.removedSource.type;
|
||||
} else if (diff.op === 'changed') {
|
||||
sourceType = diff.beforeSource.type;
|
||||
}
|
||||
return sourceType === 'dir' || sourceType === 'file';
|
||||
});
|
||||
if (filePathDiffs.length > 0) {
|
||||
if (unavailableSourceHashes.length > 0) {
|
||||
Log.newLine();
|
||||
Log.log('📁 Paths with native dependencies:');
|
||||
}
|
||||
const fields = [];
|
||||
for (const diff of filePathDiffs) {
|
||||
const field = getDiffFilePathFields(diff);
|
||||
if (!field) {
|
||||
throw new Error(`Unsupported diff: ${JSON.stringify(diff)}`);
|
||||
Log.warn(
|
||||
`A detailed diff cannot be shown. ${getUnavailableSourceMessage(unavailableSourceHashes)}`
|
||||
);
|
||||
} else {
|
||||
const fingerprintDiffs = diffFingerprint(projectDir, firstFingerprint, secondFingerprint);
|
||||
if (!fingerprintDiffs) {
|
||||
Log.error('Fingerprint diffs can only be computed for projects with SDK 52 or higher');
|
||||
return;
|
||||
}
|
||||
fields.push(field);
|
||||
}
|
||||
Log.log(
|
||||
formatFields(fields, {
|
||||
labelFormat: label => ` ${chalk.dim(label)}:`,
|
||||
})
|
||||
);
|
||||
|
||||
const contentDiffs = fingerprintDiffs.filter(diff => {
|
||||
let sourceType;
|
||||
if (diff.op === 'added') {
|
||||
sourceType = diff.addedSource.type;
|
||||
} else if (diff.op === 'removed') {
|
||||
sourceType = diff.removedSource.type;
|
||||
} else if (diff.op === 'changed') {
|
||||
sourceType = diff.beforeSource.type;
|
||||
const filePathDiffs = fingerprintDiffs.filter(diff => {
|
||||
let sourceType;
|
||||
if (diff.op === 'added') {
|
||||
sourceType = diff.addedSource.type;
|
||||
} else if (diff.op === 'removed') {
|
||||
sourceType = diff.removedSource.type;
|
||||
} else if (diff.op === 'changed') {
|
||||
sourceType = diff.beforeSource.type;
|
||||
}
|
||||
return sourceType === 'dir' || sourceType === 'file';
|
||||
});
|
||||
if (filePathDiffs.length > 0) {
|
||||
Log.newLine();
|
||||
Log.log('📁 Paths with native dependencies:');
|
||||
}
|
||||
return sourceType === 'contents';
|
||||
});
|
||||
const fields = [];
|
||||
for (const diff of filePathDiffs) {
|
||||
const field = getDiffFilePathFields(diff);
|
||||
if (!field) {
|
||||
throw new Error(`Unsupported diff: ${JSON.stringify(diff)}`);
|
||||
}
|
||||
fields.push(field);
|
||||
}
|
||||
Log.log(
|
||||
formatFields(fields, {
|
||||
labelFormat: label => ` ${chalk.dim(label)}:`,
|
||||
})
|
||||
);
|
||||
|
||||
for (const diff of contentDiffs) {
|
||||
printContentDiff(diff);
|
||||
const contentDiffs = fingerprintDiffs.filter(diff => {
|
||||
let sourceType;
|
||||
if (diff.op === 'added') {
|
||||
sourceType = diff.addedSource.type;
|
||||
} else if (diff.op === 'removed') {
|
||||
sourceType = diff.removedSource.type;
|
||||
} else if (diff.op === 'changed') {
|
||||
sourceType = diff.beforeSource.type;
|
||||
}
|
||||
return sourceType === 'contents';
|
||||
});
|
||||
|
||||
for (const diff of contentDiffs) {
|
||||
printContentDiff(diff);
|
||||
}
|
||||
}
|
||||
|
||||
if (nonInteractive) {
|
||||
@@ -285,12 +312,8 @@ async function getFingerprintInfoAsync(
|
||||
environmentForProjectFingerprint?: string;
|
||||
nonInteractive: boolean;
|
||||
},
|
||||
firstFingerprintInfo?: {
|
||||
fingerprint: Fingerprint;
|
||||
platforms?: AppPlatform[];
|
||||
origin: FingerprintOrigin;
|
||||
}
|
||||
): Promise<{ fingerprint: Fingerprint; origin: FingerprintOrigin }> {
|
||||
firstFingerprintInfo?: FingerprintInfo
|
||||
): Promise<FingerprintInfo> {
|
||||
if (hash) {
|
||||
return await getFingerprintInfoFromHashAsync(graphqlClient, projectId, hash);
|
||||
} else if (updateId) {
|
||||
@@ -349,13 +372,9 @@ async function getFingerprintInfoInteractiveAsync({
|
||||
projectId: string;
|
||||
vcsClient: Client;
|
||||
getServerSideEnvironmentVariablesAsync: GetServerSideEnvironmentVariablesFn;
|
||||
firstFingerprintInfo?: {
|
||||
fingerprint: Fingerprint;
|
||||
platforms?: AppPlatform[];
|
||||
origin: FingerprintOrigin;
|
||||
};
|
||||
firstFingerprintInfo?: FingerprintInfo;
|
||||
environmentForProjectFingerprint?: string;
|
||||
}): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
|
||||
}): Promise<FingerprintInfo> {
|
||||
const prompt = firstFingerprintInfo
|
||||
? 'Select the second fingerprint to compare against'
|
||||
: 'Select a reference fingerprint for comparison';
|
||||
@@ -449,13 +468,9 @@ async function getFingerprintInfoFromLocalProjectAsync({
|
||||
projectId: string;
|
||||
vcsClient: Client;
|
||||
getServerSideEnvironmentVariablesAsync: GetServerSideEnvironmentVariablesFn;
|
||||
firstFingerprintInfo: {
|
||||
fingerprint: Fingerprint;
|
||||
platforms?: AppPlatform[];
|
||||
origin: FingerprintOrigin;
|
||||
};
|
||||
firstFingerprintInfo: FingerprintInfo;
|
||||
environment?: string;
|
||||
}): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
|
||||
}): Promise<FingerprintInfo> {
|
||||
const firstFingerprintPlatforms = firstFingerprintInfo.platforms;
|
||||
if (!firstFingerprintPlatforms || firstFingerprintPlatforms.length === 0) {
|
||||
throw new Error(
|
||||
@@ -477,22 +492,27 @@ async function getFingerprintInfoFromLocalProjectAsync({
|
||||
firstFingerprintPlatforms,
|
||||
{ env }
|
||||
);
|
||||
return { fingerprint, origin: { type: FingerprintOriginType.Project } };
|
||||
return {
|
||||
fingerprint,
|
||||
origin: { type: FingerprintOriginType.Project },
|
||||
isFingerprintSourceUnavailable: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function getFingerprintFromUpdateFragmentAsync(
|
||||
updateWithFingerprint: UpdateFragment
|
||||
): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
|
||||
): Promise<FingerprintInfo> {
|
||||
if (!updateWithFingerprint.fingerprint) {
|
||||
throw new Error(`Fingerprint for update ${updateWithFingerprint.id} was not computed.`);
|
||||
} else if (!updateWithFingerprint.fingerprint.debugInfoUrl) {
|
||||
throw new Error(`Fingerprint source for update ${updateWithFingerprint.id} was not computed.`);
|
||||
}
|
||||
|
||||
const { fingerprint, isFingerprintSourceUnavailable } =
|
||||
await getFingerprintFromFingerprintFragmentAsync(updateWithFingerprint.fingerprint);
|
||||
return {
|
||||
fingerprint: await getFingerprintFromFingerprintFragmentAsync(
|
||||
updateWithFingerprint.fingerprint
|
||||
),
|
||||
fingerprint,
|
||||
isFingerprintSourceUnavailable,
|
||||
platforms: [stringToAppPlatform(updateWithFingerprint.platform)],
|
||||
origin: {
|
||||
type: FingerprintOriginType.Update,
|
||||
@@ -505,13 +525,14 @@ async function getFingerprintInfoFromHashAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string,
|
||||
hash: string
|
||||
): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
|
||||
): Promise<FingerprintInfo> {
|
||||
const fingerprintFragment = await getFingerprintFragmentFromHashAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
hash
|
||||
);
|
||||
const fingerprint = await getFingerprintFromFingerprintFragmentAsync(fingerprintFragment);
|
||||
const { fingerprint, isFingerprintSourceUnavailable } =
|
||||
await getFingerprintFromFingerprintFragmentAsync(fingerprintFragment);
|
||||
let platforms;
|
||||
const fingerprintBuilds = fingerprintFragment.builds?.edges.map(edge => edge.node) ?? [];
|
||||
const fingerprintUpdates = fingerprintFragment.updates?.edges.map(edge => edge.node) ?? [];
|
||||
@@ -522,6 +543,7 @@ async function getFingerprintInfoFromHashAsync(
|
||||
}
|
||||
return {
|
||||
fingerprint,
|
||||
isFingerprintSourceUnavailable,
|
||||
platforms,
|
||||
origin: {
|
||||
type: FingerprintOriginType.Hash,
|
||||
@@ -534,7 +556,7 @@ async function getFingerprintInfoFromUpdateGroupIdOrUpdateIdAsync(
|
||||
projectId: string,
|
||||
nonInteractive: boolean,
|
||||
updateGroupIdOrUpdateId: string
|
||||
): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
|
||||
): Promise<FingerprintInfo> {
|
||||
// Some people may pass in update group id instead of update id, so add interactive support for that
|
||||
try {
|
||||
const maybeUpdateGroupId = updateGroupIdOrUpdateId;
|
||||
@@ -578,15 +600,18 @@ async function getFingerprintInfoFromUpdateGroupIdOrUpdateIdAsync(
|
||||
async function getFingerprintInfoFromBuildIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
buildId: string
|
||||
): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
|
||||
): Promise<FingerprintInfo> {
|
||||
const buildWithFingerprint = await BuildQuery.withFingerprintByIdAsync(graphqlClient, buildId);
|
||||
if (!buildWithFingerprint.fingerprint) {
|
||||
throw new Error(`Fingerprint for build ${buildId} was not computed.`);
|
||||
} else if (!buildWithFingerprint.fingerprint.debugInfoUrl) {
|
||||
throw new Error(`Fingerprint source for build ${buildId} was not computed.`);
|
||||
}
|
||||
const { fingerprint, isFingerprintSourceUnavailable } =
|
||||
await getFingerprintFromFingerprintFragmentAsync(buildWithFingerprint.fingerprint);
|
||||
return {
|
||||
fingerprint: await getFingerprintFromFingerprintFragmentAsync(buildWithFingerprint.fingerprint),
|
||||
fingerprint,
|
||||
isFingerprintSourceUnavailable,
|
||||
platforms: [buildWithFingerprint.platform],
|
||||
origin: {
|
||||
type: FingerprintOriginType.Build,
|
||||
@@ -611,17 +636,48 @@ async function getFingerprintFragmentFromHashAsync(
|
||||
return fingerprint;
|
||||
}
|
||||
|
||||
async function fetchFingerprintSourceAsync(fingerprintDebugUrl: string): Promise<Fingerprint> {
|
||||
const fingerprintResponse = await fetch(fingerprintDebugUrl);
|
||||
if (!fingerprintResponse.ok) {
|
||||
throw new Error(`Fingerprint source request failed: ${fingerprintResponse.status}`);
|
||||
}
|
||||
const fingerprintSource = (await fingerprintResponse.json()) as Partial<Fingerprint> | null;
|
||||
if (!Array.isArray(fingerprintSource?.sources)) {
|
||||
throw new Error('Fingerprint source file does not contain a list of sources');
|
||||
}
|
||||
return fingerprintSource as Fingerprint;
|
||||
}
|
||||
|
||||
async function getFingerprintFromFingerprintFragmentAsync(
|
||||
fingerprintFragment: Pick<FingerprintFragment, 'debugInfoUrl' | 'hash'>
|
||||
): Promise<Fingerprint> {
|
||||
): Promise<{ fingerprint: Fingerprint; isFingerprintSourceUnavailable: boolean }> {
|
||||
const fingerprintDebugUrl = fingerprintFragment.debugInfoUrl;
|
||||
if (!fingerprintDebugUrl) {
|
||||
throw new Error(
|
||||
`The source for fingerprint hash ${fingerprintFragment.hash} was not computed.`
|
||||
);
|
||||
}
|
||||
const fingerprintResponse = await fetch(fingerprintDebugUrl);
|
||||
return (await fingerprintResponse.json()) as Fingerprint;
|
||||
try {
|
||||
return {
|
||||
fingerprint: await fetchFingerprintSourceAsync(fingerprintDebugUrl),
|
||||
isFingerprintSourceUnavailable: false,
|
||||
};
|
||||
} catch (error) {
|
||||
// Uploaded source files expire after a period of time or may be malformed
|
||||
// (e.g. zero-length). The hash from the API is still valid, so fall back to a
|
||||
// sourceless fingerprint instead of failing the whole comparison.
|
||||
Log.debug(`Failed to fetch source for fingerprint hash ${fingerprintFragment.hash}`, error);
|
||||
return {
|
||||
fingerprint: { hash: fingerprintFragment.hash, sources: [] },
|
||||
isFingerprintSourceUnavailable: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getUnavailableSourceMessage(hashes: string[]): string {
|
||||
return hashes.length === 1
|
||||
? `The uploaded source file for fingerprint ${hashes[0]} has expired or is invalid.`
|
||||
: `The uploaded source files for fingerprints ${hashes.join(' and ')} have expired or are invalid.`;
|
||||
}
|
||||
|
||||
function printContentDiff(diff: FingerprintDiffItem): void {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { App } from '@expo/apple-utils';
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'fs-extra';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import semver from 'semver';
|
||||
import { Analytics } from '../analytics/AnalyticsManager';
|
||||
import EasCommand from '../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
@@ -29,7 +30,7 @@ import { WorkflowRunQuery } from '../graphql/queries/WorkflowRunQuery';
|
||||
import Log, { learnMore } from '../log';
|
||||
import { confirmAsync, selectAsync } from '../prompts';
|
||||
import { ora } from '../ora';
|
||||
import { getPrivateExpoConfigAsync } from '../project/expoConfig';
|
||||
import { detectProjectSdkVersionAsync } from '../project/detectProjectSdkVersionAsync';
|
||||
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
|
||||
import { uploadAccountScopedFileAsync } from '../project/uploadAccountScopedFileAsync';
|
||||
import { uploadAccountScopedProjectSourceAsync } from '../project/uploadAccountScopedProjectSourceAsync';
|
||||
@@ -47,18 +48,9 @@ function deriveBundleIdSlug(bundleId: string): string {
|
||||
return bundleId.split('.').filter(Boolean).pop()!;
|
||||
}
|
||||
|
||||
export async function detectProjectSdkVersionAsync(
|
||||
projectDir: string
|
||||
): Promise<string | undefined> {
|
||||
const paths = getConfigFilePaths(projectDir);
|
||||
if (!paths.staticConfigPath && !paths.dynamicConfigPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
return (await getPrivateExpoConfigAsync(projectDir)).sdkVersion;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
export function toRepackTargetSdkVersion(sdkVersion: string | undefined): string | undefined {
|
||||
const coerced = semver.coerce(sdkVersion);
|
||||
return coerced ? `${coerced.major}.0.0` : sdkVersion;
|
||||
}
|
||||
|
||||
async function setupTestFlightAsync(ascApp: App): Promise<void> {
|
||||
@@ -120,7 +112,8 @@ export default class Go extends EasCommand {
|
||||
default: 'My Expo Go',
|
||||
}),
|
||||
'sdk-version': Flags.string({
|
||||
description: 'Expo Go SDK version to prepare (default: latest)',
|
||||
description:
|
||||
'Expo Go SDK version to prepare, for example 57 (default: the SDK version of the current project)',
|
||||
required: false,
|
||||
}),
|
||||
credentials: Flags.boolean({
|
||||
@@ -157,7 +150,7 @@ export default class Go extends EasCommand {
|
||||
`Current project using SDK ${detectedSdkVersion.split('.')[0]}. Auto-selected same version. To use a different version, pass --sdk-version.`
|
||||
);
|
||||
}
|
||||
let sdkVersion = flags['sdk-version'] ?? detectedSdkVersion;
|
||||
let sdkVersion = toRepackTargetSdkVersion(flags['sdk-version'] ?? detectedSdkVersion);
|
||||
if (!sdkVersion) {
|
||||
({ sdkVersion } = await this.selectSdkVersionAsync(graphqlClient));
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ describe(IntegrationsSupabaseDashboard, () => {
|
||||
|
||||
it('fails the spinner when the browser cannot be opened', async () => {
|
||||
jest.mocked(openBrowserAsync).mockResolvedValue(false as never);
|
||||
const { ora } = jest.requireMock('../../../../ora') as { ora: jest.Mock };
|
||||
const { ora } = jest.requireMock('../../../../ora');
|
||||
const spinner = {
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
@@ -116,7 +116,7 @@ describe(IntegrationsSupabaseDashboard, () => {
|
||||
|
||||
it('fails the spinner and rethrows when opening the browser throws', async () => {
|
||||
jest.mocked(openBrowserAsync).mockRejectedValue(new Error('no browser'));
|
||||
const { ora } = jest.requireMock('../../../../ora') as { ora: jest.Mock };
|
||||
const { ora } = jest.requireMock('../../../../ora');
|
||||
const spinner = {
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
|
||||
@@ -52,12 +52,15 @@ describe(SimulatorAvailability, () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): SimulatorAvailability {
|
||||
function createCommand(
|
||||
argv: string[],
|
||||
{ isExpoAdmin = false }: { isExpoAdmin?: boolean } = {}
|
||||
): SimulatorAvailability {
|
||||
const command = new SimulatorAvailability(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
loggedIn: { actor: { isExpoAdmin }, graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
@@ -101,6 +104,19 @@ describe(SimulatorAvailability, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('emits JSON with available true for Expo admins when the account is gated', async () => {
|
||||
mockByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: false });
|
||||
|
||||
const command = createCommand(['--json'], { isExpoAdmin: true });
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockByAppIdAsync).toHaveBeenCalledWith(graphqlClient, projectId);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
available: true,
|
||||
accountName: 'testuser',
|
||||
});
|
||||
});
|
||||
|
||||
it('logs a graceful message pointing at the waitlist when not enabled', async () => {
|
||||
mockByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: false });
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
loadSimulatorEnvAsync,
|
||||
resetSimulatorEnvAsync,
|
||||
} from '../../../simulator/env';
|
||||
import { resolveExpoGoSdkVersionAsync } from '../../../simulator/expoGo';
|
||||
import Simulator from '../index';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
@@ -45,6 +46,7 @@ jest.mock('../../../simulator/env', () => ({
|
||||
loadSimulatorEnvAsync: jest.fn(),
|
||||
resetSimulatorEnvAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../simulator/expoGo');
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../ora', () => ({
|
||||
ora: jest.fn(() => {
|
||||
@@ -78,6 +80,7 @@ const mockAvailabilityByAppIdAsync = jest.mocked(DeviceRunSessionAvailabilityQue
|
||||
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
|
||||
const mockLoadSimulatorEnvAsync = jest.mocked(loadSimulatorEnvAsync);
|
||||
const mockResetSimulatorEnvAsync = jest.mocked(resetSimulatorEnvAsync);
|
||||
const mockResolveExpoGoSdkVersionAsync = jest.mocked(resolveExpoGoSdkVersionAsync);
|
||||
const mockOra = jest.mocked(ora);
|
||||
const mockPromptAsync = jest.mocked(promptAsync);
|
||||
|
||||
@@ -161,6 +164,7 @@ describe(Simulator, () => {
|
||||
mockByIdAsync.mockResolvedValue(makeDeviceRunSession());
|
||||
mockLoadSimulatorEnvAsync.mockResolvedValue();
|
||||
mockResetSimulatorEnvAsync.mockResolvedValue();
|
||||
mockResolveExpoGoSdkVersionAsync.mockResolvedValue('55.0.0');
|
||||
jest.mocked(fs.writeFile).mockResolvedValue(undefined as never);
|
||||
});
|
||||
|
||||
@@ -252,15 +256,15 @@ describe(Simulator, () => {
|
||||
expect(fs.writeFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
simulatorDotenvPath,
|
||||
SIMULATOR_DOTENV_FILE_HEADER + `${EAS_SIMULATOR_SESSION_ID}="session-123"\n`
|
||||
SIMULATOR_DOTENV_FILE_HEADER + `${EAS_SIMULATOR_SESSION_ID}='session-123'\n`
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
simulatorDotenvPath,
|
||||
SIMULATOR_DOTENV_FILE_HEADER +
|
||||
'AGENT_DEVICE_DAEMON_BASE_URL="https://agent.example.com"\n' +
|
||||
'AGENT_DEVICE_DAEMON_AUTH_TOKEN="token-123"\n' +
|
||||
`${EAS_SIMULATOR_SESSION_ID}="session-123"\n`
|
||||
"AGENT_DEVICE_DAEMON_BASE_URL='https://agent.example.com'\n" +
|
||||
"AGENT_DEVICE_DAEMON_AUTH_TOKEN='token-123'\n" +
|
||||
`${EAS_SIMULATOR_SESSION_ID}='session-123'\n`
|
||||
);
|
||||
expect(jest.mocked(fs.writeFile).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockByIdAsync.mock.invocationCallOrder[0]
|
||||
@@ -282,6 +286,51 @@ describe(Simulator, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('writes the Appium environment and capabilities to the simulator dotenv', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({
|
||||
type: DeviceRunSessionType.Appium,
|
||||
platform: AppPlatform.Ios,
|
||||
remoteConfig: {
|
||||
__typename: 'AppiumRunSessionRemoteConfig',
|
||||
appiumUrl: 'https://appium.example.test',
|
||||
capabilities: {
|
||||
platformName: 'iOS',
|
||||
'appium:automationName': 'XCUITest',
|
||||
'appium:udid': 'simulator-id',
|
||||
},
|
||||
webPreviewUrl: 'https://preview.example.test',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--type',
|
||||
'appium',
|
||||
'--non-interactive',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ type: DeviceRunSessionType.Appium })
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
simulatorDotenvPath,
|
||||
expect.stringContaining(
|
||||
`APPIUM_CAPS='{"platformName":"iOS","appium:automationName":"XCUITest","appium:udid":"simulator-id"}'`
|
||||
)
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('eas simulator:exec <appium-client> [args...]')
|
||||
);
|
||||
expect(Log.log).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('https://appium.example.test')
|
||||
);
|
||||
});
|
||||
|
||||
it('overwrites .env.eas-simulator when outputting dotenv and the file exists', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
@@ -295,15 +344,15 @@ describe(Simulator, () => {
|
||||
expect(fs.writeFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
simulatorDotenvPath,
|
||||
SIMULATOR_DOTENV_FILE_HEADER + `${EAS_SIMULATOR_SESSION_ID}="session-123"\n`
|
||||
SIMULATOR_DOTENV_FILE_HEADER + `${EAS_SIMULATOR_SESSION_ID}='session-123'\n`
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
simulatorDotenvPath,
|
||||
SIMULATOR_DOTENV_FILE_HEADER +
|
||||
'AGENT_DEVICE_DAEMON_BASE_URL="https://agent.example.com"\n' +
|
||||
'AGENT_DEVICE_DAEMON_AUTH_TOKEN="token-123"\n' +
|
||||
`${EAS_SIMULATOR_SESSION_ID}="session-123"\n`
|
||||
"AGENT_DEVICE_DAEMON_BASE_URL='https://agent.example.com'\n" +
|
||||
"AGENT_DEVICE_DAEMON_AUTH_TOKEN='token-123'\n" +
|
||||
`${EAS_SIMULATOR_SESSION_ID}='session-123'\n`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -366,7 +415,7 @@ describe(Simulator, () => {
|
||||
const { command } = createCommand(['--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
});
|
||||
|
||||
it('forwards --name to the create mutation', async () => {
|
||||
@@ -411,6 +460,232 @@ describe(Simulator, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards --device to the create mutation as deviceIdentifier', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--device',
|
||||
'iPhone 16 Pro',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ deviceIdentifier: 'iPhone 16 Pro' })
|
||||
);
|
||||
});
|
||||
|
||||
it('trims --device before sending it', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--device',
|
||||
' iPhone 16 Pro ',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ deviceIdentifier: 'iPhone 16 Pro' })
|
||||
);
|
||||
});
|
||||
|
||||
it('omits a blank --device', async () => {
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive', '--device', ' ']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ deviceIdentifier: undefined })
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards --build-id to the create mutation', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--build-id',
|
||||
' 8d8b713c-1834-4bd3-91e6-46f895422cbc ',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ buildId: '8d8b713c-1834-4bd3-91e6-46f895422cbc' })
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards --application-archive-url to the create mutation', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'android',
|
||||
'--non-interactive',
|
||||
'--application-archive-url',
|
||||
' https://example.test/builds/app.apk ',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({
|
||||
applicationArchiveUrl: 'https://example.test/builds/app.apk',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ios', AppPlatform.Ios],
|
||||
['android', AppPlatform.Android],
|
||||
] as const)(
|
||||
'forwards Expo Go and the project SDK for %s when --expo-go is passed',
|
||||
async (platformFlag, appPlatform) => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
platformFlag,
|
||||
'--non-interactive',
|
||||
'--expo-go',
|
||||
]);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockResolveExpoGoSdkVersionAsync).toHaveBeenCalledWith({
|
||||
projectDir,
|
||||
sdkVersion: undefined,
|
||||
});
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({
|
||||
expoGo: true,
|
||||
sdkVersion: '55.0.0',
|
||||
platform: appPlatform,
|
||||
})
|
||||
);
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.not.objectContaining({ applicationArchiveUrl: expect.anything() })
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('uses --sdk-version to select Expo Go', async () => {
|
||||
mockResolveExpoGoSdkVersionAsync.mockResolvedValueOnce('57.0.0');
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--expo-go',
|
||||
'--sdk-version',
|
||||
'57.0.0',
|
||||
]);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockResolveExpoGoSdkVersionAsync).toHaveBeenCalledWith({
|
||||
projectDir,
|
||||
sdkVersion: '57.0.0',
|
||||
});
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ expoGo: true, sdkVersion: '57.0.0' })
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards repeated launch arguments and a URL to open', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--build-id',
|
||||
'8d8b713c-1834-4bd3-91e6-46f895422cbc',
|
||||
'--launch-arg',
|
||||
'--uitesting',
|
||||
'--launch-arg',
|
||||
'true',
|
||||
'--open-url',
|
||||
' exp://example.test ',
|
||||
]);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({
|
||||
launchArgs: ['--uitesting', 'true'],
|
||||
openUrl: 'exp://example.test',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['--launch-arg', '--uitesting'],
|
||||
['--open-url', 'exp://example.test'],
|
||||
])('rejects %s without an application source', async (launchFlag, launchValue) => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
launchFlag,
|
||||
launchValue,
|
||||
]);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'Launch options require an application source.'
|
||||
);
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects --sdk-version without --expo-go', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--sdk-version',
|
||||
'57',
|
||||
]);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'The --sdk-version flag can only be used with --expo-go.'
|
||||
);
|
||||
expect(mockResolveExpoGoSdkVersionAsync).not.toHaveBeenCalled();
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects passing --build-id and --application-archive-url together', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--build-id',
|
||||
'8d8b713c-1834-4bd3-91e6-46f895422cbc',
|
||||
'--application-archive-url',
|
||||
'https://example.test/builds/app.tar.gz',
|
||||
]);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['--build-id', '8d8b713c-1834-4bd3-91e6-46f895422cbc'],
|
||||
['--application-archive-url', 'https://example.test/builds/app.tar.gz'],
|
||||
])('rejects passing --expo-go with %s', async (sourceFlag, sourceValue) => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--expo-go',
|
||||
sourceFlag,
|
||||
sourceValue,
|
||||
]);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
expect(mockResolveExpoGoSdkVersionAsync).not.toHaveBeenCalled();
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops the simulator session when interrupted before the session is ready', async () => {
|
||||
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation(code => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
@@ -442,7 +717,7 @@ describe(Simulator, () => {
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
expect(process.listeners('SIGINT')).toEqual([...existingSigintListeners]);
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -161,7 +161,7 @@ describe(SimulatorList, () => {
|
||||
'--status',
|
||||
'new',
|
||||
'--type',
|
||||
'argent',
|
||||
'appium',
|
||||
'--platform',
|
||||
'ios',
|
||||
'--name',
|
||||
@@ -179,7 +179,7 @@ describe(SimulatorList, () => {
|
||||
after: 'page-cursor',
|
||||
filter: {
|
||||
statuses: [DeviceRunSessionStatus.InProgress, DeviceRunSessionStatus.New],
|
||||
types: [DeviceRunSessionType.Argent],
|
||||
types: [DeviceRunSessionType.Appium],
|
||||
platforms: [AppPlatform.Ios],
|
||||
name: 'checkout',
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
loadSimulatorEnvAsync,
|
||||
resetSimulatorEnvAsync,
|
||||
} from '../../../simulator/env';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import SimulatorStop from '../stop';
|
||||
@@ -18,6 +19,7 @@ jest.mock('../../../graphql/mutations/DeviceRunSessionMutation');
|
||||
jest.mock('../../../simulator/env', () => ({
|
||||
...jest.requireActual('../../../simulator/env'),
|
||||
loadSimulatorEnvAsync: jest.fn(),
|
||||
resetSimulatorEnvAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../ora', () => ({
|
||||
ora: jest.fn(() => {
|
||||
@@ -40,6 +42,7 @@ const mockEnsureDeviceRunSessionStoppedAsync = jest.mocked(
|
||||
);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockLoadSimulatorEnvironmentVariablesAsync = jest.mocked(loadSimulatorEnvAsync);
|
||||
const mockResetSimulatorEnvAsync = jest.mocked(resetSimulatorEnvAsync);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
function makeStoppedDeviceRunSession(
|
||||
@@ -70,6 +73,7 @@ describe(SimulatorStop, () => {
|
||||
jest.clearAllMocks();
|
||||
mockEnsureDeviceRunSessionStoppedAsync.mockResolvedValue(makeStoppedDeviceRunSession());
|
||||
mockLoadSimulatorEnvironmentVariablesAsync.mockResolvedValue();
|
||||
mockResetSimulatorEnvAsync.mockResolvedValue();
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): {
|
||||
@@ -98,6 +102,7 @@ describe(SimulatorStop, () => {
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
id: 'session-123',
|
||||
status: DeviceRunSessionStatus.Stopped,
|
||||
@@ -120,6 +125,7 @@ describe(SimulatorStop, () => {
|
||||
graphqlClient,
|
||||
'session-from-env'
|
||||
);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-from-env');
|
||||
} finally {
|
||||
if (previousDeviceRunSessionId === undefined) {
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
|
||||
@@ -37,7 +37,7 @@ export default class SimulatorAvailability extends EasCommand {
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
loggedIn: { actor, graphqlClient },
|
||||
} = await this.getContextAsync(SimulatorAvailability, {
|
||||
nonInteractive,
|
||||
});
|
||||
@@ -50,6 +50,7 @@ export default class SimulatorAvailability extends EasCommand {
|
||||
graphqlClient,
|
||||
projectId
|
||||
));
|
||||
available ||= actor.isExpoAdmin;
|
||||
fetchSpinner?.stop();
|
||||
} catch (err) {
|
||||
fetchSpinner?.fail('Failed to check EAS Simulator availability');
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
resetSimulatorEnvAsync,
|
||||
writeSimulatorEnvAsync,
|
||||
} from '../../simulator/env';
|
||||
import { resolveExpoGoSdkVersionAsync } from '../../simulator/expoGo';
|
||||
import {
|
||||
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
|
||||
@@ -68,6 +69,37 @@ export default class Simulator extends EasCommand {
|
||||
description:
|
||||
'Human-readable name for the simulator session, shown in eas simulator:list and on expo.dev. Defaults to unnamed.',
|
||||
}),
|
||||
device: Flags.string({
|
||||
description:
|
||||
'Virtual device to start for the session. On iOS, a Simulator device name or UDID (e.g. "iPhone 16 Pro"). On Android, an AVD hardware profile id (e.g. "pixel_7"). Defaults to a device chosen by the runner.',
|
||||
}),
|
||||
'build-id': Flags.string({
|
||||
description: 'EAS Build to install and launch before the simulator session is ready.',
|
||||
exclusive: ['application-archive-url', 'expo-go'],
|
||||
}),
|
||||
'application-archive-url': Flags.string({
|
||||
description:
|
||||
'Application archive URL to download, install, and launch before the simulator session is ready.',
|
||||
exclusive: ['build-id', 'expo-go'],
|
||||
}),
|
||||
'expo-go': Flags.boolean({
|
||||
description:
|
||||
"Install and launch Expo Go matching the current project's Expo SDK before the simulator session is ready.",
|
||||
exclusive: ['build-id', 'application-archive-url'],
|
||||
}),
|
||||
'sdk-version': Flags.string({
|
||||
description:
|
||||
'Expo SDK version used to select Expo Go when --expo-go is passed. Defaults to the current project SDK.',
|
||||
}),
|
||||
'launch-arg': Flags.string({
|
||||
description:
|
||||
'Argument passed to the installed application when it launches. Repeat for multiple arguments.',
|
||||
multiple: true,
|
||||
}),
|
||||
'open-url': Flags.string({
|
||||
description:
|
||||
'Expo or development-client URL to open in the installed application after it launches.',
|
||||
}),
|
||||
type: Flags.option({
|
||||
description: 'Type of simulator session to create',
|
||||
options: Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES),
|
||||
@@ -134,6 +166,26 @@ export default class Simulator extends EasCommand {
|
||||
// The server rejects blank names, so trim here and treat a whitespace-only
|
||||
// --name as if it had been omitted rather than surfacing a validation error.
|
||||
const name = flags.name?.trim() || undefined;
|
||||
const deviceIdentifier = flags.device?.trim() || undefined;
|
||||
const buildId = flags['build-id']?.trim() || undefined;
|
||||
const applicationArchiveUrlFromFlag = flags['application-archive-url']?.trim() || undefined;
|
||||
const sdkVersionFromFlag = flags['sdk-version']?.trim() || undefined;
|
||||
const launchArgs = flags['launch-arg'];
|
||||
const openUrl = flags['open-url']?.trim() || undefined;
|
||||
|
||||
if (sdkVersionFromFlag && !flags['expo-go']) {
|
||||
throw new EasCommandError('The --sdk-version flag can only be used with --expo-go.');
|
||||
}
|
||||
if (
|
||||
(launchArgs?.length || openUrl) &&
|
||||
!buildId &&
|
||||
!applicationArchiveUrlFromFlag &&
|
||||
!flags['expo-go']
|
||||
) {
|
||||
throw new EasCommandError(
|
||||
'Launch options require an application source. Pass --build-id, --application-archive-url, or --expo-go.'
|
||||
);
|
||||
}
|
||||
|
||||
await loadSimulatorEnvAsync(projectDir);
|
||||
const existingDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
@@ -144,6 +196,9 @@ export default class Simulator extends EasCommand {
|
||||
}
|
||||
|
||||
const platform = await resolvePlatformAsync(flags.platform, nonInteractive);
|
||||
const expoGoSdkVersion = flags['expo-go']
|
||||
? await resolveExpoGoSdkVersionAsync({ projectDir, sdkVersion: sdkVersionFromFlag })
|
||||
: undefined;
|
||||
|
||||
if (existingDeviceRunSessionId) {
|
||||
Log.warn(
|
||||
@@ -165,6 +220,14 @@ export default class Simulator extends EasCommand {
|
||||
platform,
|
||||
type: DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[flags.type],
|
||||
packageVersion: flags['package-version'],
|
||||
deviceIdentifier,
|
||||
...(buildId ? { buildId } : {}),
|
||||
...(applicationArchiveUrlFromFlag
|
||||
? { applicationArchiveUrl: applicationArchiveUrlFromFlag }
|
||||
: {}),
|
||||
...(expoGoSdkVersion ? { expoGo: true, sdkVersion: expoGoSdkVersion } : {}),
|
||||
...(launchArgs?.length ? { launchArgs } : {}),
|
||||
...(openUrl ? { openUrl } : {}),
|
||||
maxRunTimeMinutes: flags['max-duration-minutes'],
|
||||
});
|
||||
deviceRunSessionId = session.id;
|
||||
@@ -400,7 +463,7 @@ async function waitForSessionEndOrInterruptAsync({
|
||||
jobRunStatus === JobRunStatus.Finished
|
||||
) {
|
||||
spinner.succeed(`Simulator session ended. ${link(deviceRunSessionUrl)}`);
|
||||
await resetSimulatorEnvVerboseAsync(projectDir);
|
||||
await resetSimulatorEnvVerboseAsync(projectDir, deviceRunSessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -414,7 +477,7 @@ async function waitForSessionEndOrInterruptAsync({
|
||||
);
|
||||
if (stopped) {
|
||||
spinner.succeed('Simulator session stopped');
|
||||
await resetSimulatorEnvVerboseAsync(projectDir);
|
||||
await resetSimulatorEnvVerboseAsync(projectDir, deviceRunSessionId);
|
||||
} else {
|
||||
spinner.fail(
|
||||
`Could not confirm the simulator session was stopped. Run \`eas simulator:stop --id ${deviceRunSessionId}\` to terminate it and avoid unexpected charges.`
|
||||
@@ -484,7 +547,7 @@ async function stopDeviceRunSessionAfterInterruptAsync({
|
||||
);
|
||||
if (stopped) {
|
||||
spinner.succeed('Simulator session stopped');
|
||||
await resetSimulatorEnvVerboseAsync(projectDir);
|
||||
await resetSimulatorEnvVerboseAsync(projectDir, deviceRunSessionId);
|
||||
} else {
|
||||
spinner.fail(
|
||||
`Could not confirm the simulator session was stopped. Run \`eas simulator:stop --id ${deviceRunSessionId}\` to terminate it and avoid unexpected charges.`
|
||||
@@ -496,9 +559,12 @@ async function stopDeviceRunSessionAfterInterruptAsync({
|
||||
process.exit(130);
|
||||
}
|
||||
|
||||
async function resetSimulatorEnvVerboseAsync(projectDir: string): Promise<void> {
|
||||
async function resetSimulatorEnvVerboseAsync(
|
||||
projectDir: string,
|
||||
deviceRunSessionId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await resetSimulatorEnvAsync(projectDir);
|
||||
await resetSimulatorEnvAsync(projectDir, deviceRunSessionId);
|
||||
} catch (err) {
|
||||
Log.error(`Failed to clean up ${SIMULATOR_DOTENV_FILE_NAME}`);
|
||||
throw err;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
loadSimulatorEnvAsync,
|
||||
resetSimulatorEnvAsync,
|
||||
} from '../../simulator/env';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
@@ -68,6 +69,8 @@ export default class SimulatorStop extends EasCommand {
|
||||
throw err;
|
||||
}
|
||||
|
||||
await resetSimulatorEnvAsync(projectDir, flagId);
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({ id: session.id, status: session.status });
|
||||
}
|
||||
|
||||
@@ -260,7 +260,7 @@ describe(UpdateEmbeddedList, () => {
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
|
||||
});
|
||||
await createCommand(['--non-interactive']).run();
|
||||
expect(mockLogLog.mock.calls.some(c => /ago/.test(String(c[0])))).toBe(true);
|
||||
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes('ago'))).toBe(true);
|
||||
});
|
||||
|
||||
it('puts "All channels" first in the prompt, then channels in order', async () => {
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ import { assignBuildCredentialsAsync, getBuildCredentialsAsync } from '../BuildC
|
||||
import { chooseDevicesAsync } from '../DeviceUtils';
|
||||
import { SetUpAdhocProvisioningProfile, doUDIDsMatch } from '../SetUpAdhocProvisioningProfile';
|
||||
import { getAscApiKeyForAppSubmissionsAsync } from '../../api/GraphqlClient';
|
||||
import { AuthenticationMode, AppleTeamType } from '../../appstore/authenticateTypes';
|
||||
import { AppleTeamType, AuthenticationMode } from '../../appstore/authenticateTypes';
|
||||
import { hasAscEnvVars } from '../../appstore/resolveCredentials';
|
||||
import { AppStoreConnectApiKeyQuery } from '../../../../graphql/queries/AppStoreConnectApiKeyQuery';
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import { promptAsync } from '../../../../prompts';
|
||||
import { AppleTeamType } from '../authenticateTypes';
|
||||
import * as Keychain from '../keychain';
|
||||
import {
|
||||
resolveAppleTeamTypeFromEnvironment,
|
||||
resolveAppleTeamAsync,
|
||||
resolveAppleTeamTypeFromEnvironment,
|
||||
resolveAscApiKeyAsync,
|
||||
resolveUserCredentialsAsync,
|
||||
} from '../resolveCredentials';
|
||||
|
||||
+151
-13
@@ -1472,6 +1472,12 @@ export type App = Project & {
|
||||
internalDistributionBuildPrivacy: AppInternalDistributionBuildPrivacy;
|
||||
/** iOS app credentials for the project */
|
||||
iosAppCredentials: Array<IosAppCredentials>;
|
||||
/**
|
||||
* Tells you if the project can show the Observe promotional notification. This field is part of
|
||||
* the observe-notification experiment.
|
||||
* @deprecated This field is deprecated from its first release. It supplies data for the observe-notification promotion on the project page. It will be removed when the promotion ends. Do not rely on it.
|
||||
*/
|
||||
isEligibleForObserveNotice: Scalars['Boolean']['output'];
|
||||
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
|
||||
/** Time of the last user activity (update, branch, submission). */
|
||||
latestActivity: Scalars['DateTime']['output'];
|
||||
@@ -2185,6 +2191,8 @@ export type AppObserve = {
|
||||
navigationRoutes: AppObserveNavigationRoutesConnection;
|
||||
/** Active users and sessions for the Overview engagement band, across all versions. */
|
||||
overviewEngagement: AppObserveOverviewEngagement;
|
||||
/** Crash-free rates with previous-period comparison for the Overview stability tiles. */
|
||||
overviewStability: AppObserveOverviewStability;
|
||||
/** Per-update comparison within one app version: the embedded bundle plus each OTA update. */
|
||||
overviewUpdateComparison: AppObserveOverviewUpdateComparison;
|
||||
/** Per-version, per-platform metric summaries for the Overview version-comparison matrix. */
|
||||
@@ -2290,6 +2298,11 @@ export type AppObserveOverviewEngagementArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type AppObserveOverviewStabilityArgs = {
|
||||
input: AppObserveOverviewStabilityInput;
|
||||
};
|
||||
|
||||
|
||||
export type AppObserveOverviewUpdateComparisonArgs = {
|
||||
input: AppObserveOverviewUpdateComparisonInput;
|
||||
};
|
||||
@@ -2439,6 +2452,8 @@ export type AppObserveCustomEvent = {
|
||||
sessionId?: Maybe<Scalars['String']['output']>;
|
||||
severityNumber?: Maybe<Scalars['Int']['output']>;
|
||||
severityText?: Maybe<Scalars['String']['output']>;
|
||||
/** Build whose uploaded source map symbolicated exceptionStacktrace; null when the stack trace is shown as reported. */
|
||||
symbolicationBuild?: Maybe<Build>;
|
||||
timestamp: Scalars['DateTime']['output'];
|
||||
};
|
||||
|
||||
@@ -2646,6 +2661,24 @@ export enum AppObserveErrorGroupsOrderBy {
|
||||
MostUsers = 'MOST_USERS'
|
||||
}
|
||||
|
||||
/** AppObserveErrorStats for one platform; iOS includes iPadOS and tvOS. */
|
||||
export type AppObserveErrorPlatformStats = {
|
||||
__typename?: 'AppObserveErrorPlatformStats';
|
||||
/** Distinct sessions on this platform that hit any exception. */
|
||||
affectedSessions: Scalars['Int']['output'];
|
||||
/** Distinct users on this platform that hit any exception. */
|
||||
affectedUsers: Scalars['Int']['output'];
|
||||
/** Fraction (0..1) of this platform's active sessions without a fatal error. */
|
||||
crashFreeSessions: Scalars['Float']['output'];
|
||||
/** Fraction (0..1) of this platform's active users without a fatal error. */
|
||||
crashFreeUsers: Scalars['Float']['output'];
|
||||
fatalCount: Scalars['Int']['output'];
|
||||
nonFatalCount: Scalars['Int']['output'];
|
||||
platform: AppObservePlatform;
|
||||
/** Total exception events in range on this platform. */
|
||||
totalErrors: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
export enum AppObserveErrorSeverity {
|
||||
Error = 'ERROR',
|
||||
Fatal = 'FATAL'
|
||||
@@ -2867,7 +2900,7 @@ export type AppObserveNavigationStat = {
|
||||
|
||||
export type AppObserveOverviewAllReleases = {
|
||||
__typename?: 'AppObserveOverviewAllReleases';
|
||||
/** One entry per (metric, platform) with data in range; iOS includes tvOS and iPadOS. */
|
||||
/** One entry per (metric, platform) with data in range; one entry per exact device platform. */
|
||||
metrics: Array<AppObserveOverviewVersionMetric>;
|
||||
uniqueUserCount: Scalars['Int']['output'];
|
||||
};
|
||||
@@ -2904,14 +2937,43 @@ export type AppObserveOverviewEngagementStat = {
|
||||
|
||||
export type AppObserveOverviewStability = {
|
||||
__typename?: 'AppObserveOverviewStability';
|
||||
affectedUsers: Scalars['Int']['output'];
|
||||
/** Fatal exception events in range. */
|
||||
crashCount: Scalars['Int']['output'];
|
||||
/** Fraction (0..1) of active sessions without a fatal error. */
|
||||
crashFreeSessions: Scalars['Float']['output'];
|
||||
/** Fraction (0..1) of active users without a fatal error. */
|
||||
crashFreeUsers: Scalars['Float']['output'];
|
||||
/** Distinct users that hit a fatal error in range. */
|
||||
crashedUsers: Scalars['Int']['output'];
|
||||
/** Fatal exception events in the equal-length window before startTime. */
|
||||
previousPeriodCrashCount: Scalars['Int']['output'];
|
||||
/** Same fractions for the equal-length window before startTime; null when it had no activity. */
|
||||
previousPeriodCrashFreeSessions?: Maybe<Scalars['Float']['output']>;
|
||||
previousPeriodCrashFreeUsers?: Maybe<Scalars['Float']['output']>;
|
||||
previousPeriodCrashedUsers: Scalars['Int']['output'];
|
||||
series: Array<AppObserveOverviewStabilityBucket>;
|
||||
/** All exception events in range, for distinguishing quiet apps from crash-free ones. */
|
||||
totalErrors: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
export type AppObserveOverviewStabilityBucket = {
|
||||
__typename?: 'AppObserveOverviewStabilityBucket';
|
||||
/** Approximate unique users that hit any exception in this bucket. Not summable across buckets. */
|
||||
affectedUsers: Scalars['Int']['output'];
|
||||
bucketStart: Scalars['DateTime']['output'];
|
||||
crashCount: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
/** Stability-tile filters. No release filters: the tiles always span all versions. */
|
||||
export type AppObserveOverviewStabilityInput = {
|
||||
/** Series bucket size. Defaults to one day. */
|
||||
bucketIntervalMinutes?: InputMaybe<Scalars['Int']['input']>;
|
||||
endTime: Scalars['DateTime']['input'];
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
startTime: Scalars['DateTime']['input'];
|
||||
};
|
||||
|
||||
export type AppObserveOverviewUpdate = {
|
||||
__typename?: 'AppObserveOverviewUpdate';
|
||||
/** Null for the embedded bundle. */
|
||||
@@ -2929,7 +2991,8 @@ export type AppObserveOverviewUpdate = {
|
||||
metrics: Array<AppObserveOverviewUpdateMetric>;
|
||||
/** When the update was published; null for the embedded bundle. */
|
||||
publishedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
stability: AppObserveOverviewStability;
|
||||
/** Error stats for this update, one entry per platform with activity; iOS includes iPadOS and tvOS. */
|
||||
stability: Array<AppObserveErrorPlatformStats>;
|
||||
uniqueUserCount: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
@@ -2967,10 +3030,10 @@ export type AppObserveOverviewVersion = {
|
||||
__typename?: 'AppObserveOverviewVersion';
|
||||
appVersion: Scalars['String']['output'];
|
||||
firstSeenAt: Scalars['DateTime']['output'];
|
||||
/** One entry per (metric, platform) with data in range; iOS includes tvOS and iPadOS. */
|
||||
/** One entry per (metric, platform) with data in range; each device OS (iOS, iPadOS, tvOS, macOS, Android) is a separate platform. */
|
||||
metrics: Array<AppObserveOverviewVersionMetric>;
|
||||
/** Error stats for this version, all platforms combined. */
|
||||
stability: AppObserveOverviewStability;
|
||||
/** Error stats for this version, one entry per platform with activity; iOS includes iPadOS and tvOS. */
|
||||
stability: Array<AppObserveErrorPlatformStats>;
|
||||
uniqueUserCount: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
@@ -3011,7 +3074,10 @@ export type AppObserveOverviewVersionSummary = {
|
||||
|
||||
export enum AppObservePlatform {
|
||||
Android = 'ANDROID',
|
||||
Ios = 'IOS'
|
||||
Ios = 'IOS',
|
||||
Ipados = 'IPADOS',
|
||||
Macos = 'MACOS',
|
||||
Tvos = 'TVOS'
|
||||
}
|
||||
|
||||
export enum AppObservePropertyType {
|
||||
@@ -3438,6 +3504,8 @@ export type AppUsageMetricTotal = {
|
||||
planMetrics: Array<EstimatedUsage>;
|
||||
/** Total cost of overages, in cents */
|
||||
totalCost: Scalars['Float']['output'];
|
||||
/** Services whose estimates could not be computed and are omitted from the other fields. */
|
||||
unavailableServiceUsageMetrics: Array<UnavailableServiceUsageMetric>;
|
||||
};
|
||||
|
||||
export type AppUsageMetrics = {
|
||||
@@ -3600,6 +3668,19 @@ export type AppWorkflowsInsightsWorkflowsArgs = {
|
||||
timespan: WorkflowsInsightsTimespanInput;
|
||||
};
|
||||
|
||||
export type AppiumRunSessionRemoteConfig = {
|
||||
__typename?: 'AppiumRunSessionRemoteConfig';
|
||||
/** Appium server URL for the remote device. */
|
||||
appiumUrl: Scalars['String']['output'];
|
||||
/** W3C capabilities for the device that backs this session. */
|
||||
capabilities: Scalars['JSONObject']['output'];
|
||||
/**
|
||||
* URL of the web preview surface for the session. Null when web previews are
|
||||
* not available for the platform (e.g. Android).
|
||||
*/
|
||||
webPreviewUrl?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type AppleAppIdentifier = {
|
||||
__typename?: 'AppleAppIdentifier';
|
||||
account: Account;
|
||||
@@ -5153,18 +5234,47 @@ export type CreateDeviceRunSessionEventLogUploadSessionResult = {
|
||||
|
||||
export type CreateDeviceRunSessionInput = {
|
||||
appId: Scalars['ID']['input'];
|
||||
/**
|
||||
* Application archive URL to download, install, and launch before the simulator session
|
||||
* becomes available. Mutually exclusive with buildId and expoGo.
|
||||
*/
|
||||
applicationArchiveUrl?: InputMaybe<Scalars['String']['input']>;
|
||||
/**
|
||||
* EAS Build to install and launch before the simulator session becomes available.
|
||||
* Mutually exclusive with applicationArchiveUrl and expoGo.
|
||||
*/
|
||||
buildId?: InputMaybe<Scalars['ID']['input']>;
|
||||
/**
|
||||
* Identifier of the virtual device to start for the session. On iOS this is a
|
||||
* Simulator device name or UDID (e.g. "iPhone 16 Pro"). On Android this is an
|
||||
* AVD hardware profile id (e.g. "pixel_7"). If omitted, the runner picks a
|
||||
* 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
|
||||
* duration (2 hours, or maxRunTimeMinutes when set). Only supported for
|
||||
* agent-device and argent sessions. If omitted, the session has no idle
|
||||
* agent-device, argent, and Appium sessions. If omitted, the session has no idle
|
||||
* timeout.
|
||||
*/
|
||||
maxIdleTimeMinutes?: InputMaybe<Scalars['Int']['input']>;
|
||||
/**
|
||||
* Override for the underlying turtle job run's max run time, in minutes. Must
|
||||
* be non-negative and smaller than 120 (2 hours). Only customizable on paid
|
||||
* plans. If omitted, the default is derived based on the job run's priority.
|
||||
* Maximum usable session duration, in minutes, starting when the remote session
|
||||
* is ready. Must be non-negative and no greater than 40 for normal-priority
|
||||
* accounts or 115 for high-priority accounts. The backing job receives an
|
||||
* additional five minutes for cleanup.
|
||||
*/
|
||||
maxRunTimeMinutes?: InputMaybe<Scalars['Int']['input']>;
|
||||
/**
|
||||
@@ -5172,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;
|
||||
};
|
||||
|
||||
@@ -6054,7 +6174,7 @@ export type DeviceRunSessionQueryByIdArgs = {
|
||||
deviceRunSessionId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type DeviceRunSessionRemoteConfig = AgentDeviceRunSessionRemoteConfig | ArgentRunSessionRemoteConfig | ServeSimRunSessionRemoteConfig;
|
||||
export type DeviceRunSessionRemoteConfig = AgentDeviceRunSessionRemoteConfig | AppiumRunSessionRemoteConfig | ArgentRunSessionRemoteConfig | ServeSimRunSessionRemoteConfig;
|
||||
|
||||
export enum DeviceRunSessionStatus {
|
||||
Errored = 'ERRORED',
|
||||
@@ -6065,6 +6185,7 @@ export enum DeviceRunSessionStatus {
|
||||
|
||||
export enum DeviceRunSessionType {
|
||||
AgentDevice = 'AGENT_DEVICE',
|
||||
Appium = 'APPIUM',
|
||||
Argent = 'ARGENT',
|
||||
ServeSim = 'SERVE_SIM'
|
||||
}
|
||||
@@ -10540,6 +10661,13 @@ export enum TurtleSshTransportType {
|
||||
UptermV1 = 'UPTERM_V1'
|
||||
}
|
||||
|
||||
export type UnavailableServiceUsageMetric = {
|
||||
__typename?: 'UnavailableServiceUsageMetric';
|
||||
/** User-displayable explanation of why the service's estimate is missing. */
|
||||
message: Scalars['String']['output'];
|
||||
service: EasService;
|
||||
};
|
||||
|
||||
export type UniqueUsersOverTimeData = {
|
||||
__typename?: 'UniqueUsersOverTimeData';
|
||||
data: LineChartData;
|
||||
@@ -11218,6 +11346,8 @@ export type UsageMetricTotal = {
|
||||
planMetrics: Array<EstimatedUsage>;
|
||||
/** Total cost of overages, in cents */
|
||||
totalCost: Scalars['Float']['output'];
|
||||
/** Services whose estimates could not be computed and are omitted from the other fields. */
|
||||
unavailableServiceUsageMetrics: Array<UnavailableServiceUsageMetric>;
|
||||
};
|
||||
|
||||
export enum UsageMetricType {
|
||||
@@ -14548,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'];
|
||||
@@ -14576,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'];
|
||||
}>;
|
||||
@@ -14893,6 +15030,7 @@ export type DeviceRunSessionByIdQueryVariables = Exact<{
|
||||
|
||||
export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, name?: string | null, status: DeviceRunSessionStatus, type: DeviceRunSessionType, platform: AppPlatform, createdAt: any, startedAt?: any | null, finishedAt?: any | null, updatedAt: any, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, artifacts: Array<{ __typename?: 'DeviceRunSessionArtifact', id: string, name: string, filename: string, downloadUrl: string, fileSizeBytes?: number | null, metadata?: any | null, createdAt: any, updatedAt: any }>, remoteConfig?:
|
||||
| { __typename: 'AgentDeviceRunSessionRemoteConfig', agentDeviceRemoteSessionUrl: string, agentDeviceRemoteSessionToken: string, webPreviewUrl?: string | null }
|
||||
| { __typename: 'AppiumRunSessionRemoteConfig', appiumUrl: string, capabilities: any, webPreviewUrl?: string | null }
|
||||
| { __typename: 'ArgentRunSessionRemoteConfig', toolsUrl: string, toolsAuthToken?: string | null, webPreviewUrl?: string | null }
|
||||
| { __typename: 'ServeSimRunSessionRemoteConfig', previewUrl: string }
|
||||
| null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } } };
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -92,6 +92,11 @@ export const DeviceRunSessionQuery = {
|
||||
toolsAuthToken
|
||||
webPreviewUrl
|
||||
}
|
||||
... on AppiumRunSessionRemoteConfig {
|
||||
appiumUrl
|
||||
capabilities
|
||||
webPreviewUrl
|
||||
}
|
||||
... on ServeSimRunSessionRemoteConfig {
|
||||
previewUrl
|
||||
}
|
||||
|
||||
@@ -74,10 +74,10 @@ export function mergeEnvContent(rawContent: string, newVars: Record<string, stri
|
||||
continue;
|
||||
}
|
||||
// match[0] runs past the value onto a following comment line, so spans come from the groups.
|
||||
const [keyStart, keyEnd] = match.indices[1]!;
|
||||
const [keyStart, keyEnd] = match.indices[1];
|
||||
const valueRange = match.indices[2];
|
||||
const definitionEnd = valueRange
|
||||
? valueRange[1] - (/\s+$/.exec(match[2]!)?.[0].length ?? 0)
|
||||
? valueRange[1] - (/\s+$/.exec(match[2])?.[0].length ?? 0)
|
||||
: keyEnd + (/^[ \t]*(?:=[ \t]*|:[ \t]+)/.exec(rawContent.slice(keyEnd))?.[0].length ?? 0);
|
||||
|
||||
if (!rewritten.has(key)) {
|
||||
|
||||
@@ -61,7 +61,7 @@ const { AppStoreConnectApiKeyQuery } =
|
||||
|
||||
const mockApp = { id: '123', bundleId: 'com.example.app' };
|
||||
|
||||
function createMockCredentialsCtx(ensureAuthResult?: any) {
|
||||
function createMockCredentialsCtx(ensureAuthResult?: any): any {
|
||||
return {
|
||||
appStore: {
|
||||
ensureAuthenticatedAsync: jest.fn(() => ({
|
||||
@@ -73,7 +73,7 @@ function createMockCredentialsCtx(ensureAuthResult?: any) {
|
||||
} as any;
|
||||
}
|
||||
|
||||
function createBaseArgs(overrides: Record<string, any> = {}) {
|
||||
function createBaseArgs(overrides: Record<string, any> = {}): any {
|
||||
return {
|
||||
projectDir: '/app',
|
||||
profile: { bundleIdentifier: 'com.example.app' } as SubmitProfile,
|
||||
|
||||
@@ -41,7 +41,7 @@ jest.mock('../../prompts', () => ({
|
||||
|
||||
const { confirmAsync } = require('../../prompts') as jest.Mocked<typeof import('../../prompts')>;
|
||||
|
||||
function createArgs(overrides: Record<string, any> = {}) {
|
||||
function createArgs(overrides: Record<string, any> = {}): any {
|
||||
return {
|
||||
projectDir: '/app',
|
||||
profile: { bundleIdentifier: 'com.example.app' } as any,
|
||||
|
||||
@@ -37,7 +37,7 @@ const { loadConfigAsync } = require('../config/resolve') as jest.Mocked<
|
||||
>;
|
||||
const { confirmAsync } = require('../../prompts') as jest.Mocked<typeof import('../../prompts')>;
|
||||
|
||||
function createArgs(overrides: Record<string, any> = {}) {
|
||||
function createArgs(overrides: Record<string, any> = {}): any {
|
||||
return {
|
||||
projectDir: '/app',
|
||||
profile: { metadataPath: 'store.config.json', bundleIdentifier: 'com.example.app' } as any,
|
||||
|
||||
@@ -278,7 +278,7 @@ async function syncPreviewSetAsync(
|
||||
await logAsync(
|
||||
() =>
|
||||
AppPreview.uploadAsync(localization.context, {
|
||||
id: previewSet!.id,
|
||||
id: previewSet.id,
|
||||
filePath: absolutePath,
|
||||
waitForProcessing: true,
|
||||
previewFrameTimeCode: previewConfig.previewFrameTimeCode,
|
||||
|
||||
@@ -234,7 +234,7 @@ async function syncScreenshotSetAsync(
|
||||
const newScreenshot = await logAsync(
|
||||
() =>
|
||||
AppScreenshot.uploadAsync(localization.context, {
|
||||
id: screenshotSet!.id,
|
||||
id: screenshotSet.id,
|
||||
filePath: absolutePath,
|
||||
waitForProcessing: true,
|
||||
}),
|
||||
|
||||
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user