mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74292d2ace | ||
|
|
b0b7cad316 | ||
|
|
926887d14e | ||
|
|
2e6bb2d276 | ||
|
|
30656509ac | ||
|
|
66e3f1ad7b | ||
|
|
9aadf2d467 | ||
|
|
ba80e57753 | ||
|
|
50c76f41e3 | ||
|
|
0658d58efd | ||
|
|
76a700c45c | ||
|
|
5ded193ccf | ||
|
|
93420d1fa0 | ||
|
|
add787f625 | ||
|
|
ec2c2aca5e | ||
|
|
1e86c48f48 | ||
|
|
545c2dbf9b | ||
|
|
3cb029147e | ||
|
|
d48cfbba69 | ||
|
|
07b5c0daa3 | ||
|
|
b78c84af2c | ||
|
|
dfaa2df90d | ||
|
|
2e2aaa4ac9 | ||
|
|
3ef0f74110 | ||
|
|
51ba7198d7 | ||
|
|
e10e6a2235 | ||
|
|
17f84cf7d6 | ||
|
|
f7d9b7b297 | ||
|
|
b287d7a4e0 | ||
|
|
d9d1b91fa5 | ||
|
|
a7436edb50 | ||
|
|
89fe2cb3e1 | ||
|
|
0e8e8cc0aa | ||
|
|
6d7cef80a3 | ||
|
|
5a744b73f5 | ||
|
|
b8b53e262a | ||
|
|
3f74ea369d | ||
|
|
28f80da648 | ||
|
|
a7260dfed3 | ||
|
|
5cd1b5eee3 | ||
|
|
9d627c6917 | ||
|
|
66dbcaf432 | ||
|
|
39c39ad4bf | ||
|
|
1ad3822421 | ||
|
|
0929787398 | ||
|
|
756016a433 | ||
|
|
c6e3c332ac | ||
|
|
7c95a6b4b2 | ||
|
|
16d234b11d | ||
|
|
70066fc8be | ||
|
|
d27691adef | ||
|
|
97d64f4054 | ||
|
|
1fe8f24313 | ||
|
|
790231ea18 | ||
|
|
1f24d00ee9 | ||
|
|
3f8ffac11c | ||
|
|
137bee4db0 |
No files matched your search
@@ -37,20 +37,6 @@ jobs:
|
||||
run: yarn build
|
||||
- name: Publish packages to npm
|
||||
run: yarn run -T lerna publish from-package --yes --no-verify-access
|
||||
- name: Verify eas-cli was published as latest on npm
|
||||
run: |
|
||||
expected="$(jq -r .version lerna.json)"
|
||||
for attempt in $(seq 1 10); do
|
||||
published="$(npm view eas-cli@latest version 2>/dev/null || true)"
|
||||
if [ "$published" = "$expected" ]; then
|
||||
echo "Verified eas-cli@$expected is latest on npm."
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $attempt: npm latest is '$published', expected '$expected'. Retrying in 15s..."
|
||||
sleep 15
|
||||
done
|
||||
echo "eas-cli@$expected was not published as latest on npm; the publish step failed." >&2
|
||||
exit 1
|
||||
release-changelog:
|
||||
name: Update changelog and publish release
|
||||
runs-on: ubuntu-latest
|
||||
@@ -81,10 +67,13 @@ jobs:
|
||||
jq --arg version "$EAS_CLI_VERSION" '.STAGING = $version' \
|
||||
cli-versions.json > cli-versions.json.tmp
|
||||
mv cli-versions.json.tmp cli-versions.json
|
||||
jq --arg version "$EAS_CLI_VERSION" '.PRODUCTION = $version' \
|
||||
cli-versions.json > cli-versions.json.tmp
|
||||
mv cli-versions.json.tmp cli-versions.json
|
||||
git config --global user.email "[email protected]"
|
||||
git config --global user.name "Expo CI"
|
||||
git add cli-versions.json CHANGELOG.md
|
||||
git commit --allow-empty -m "update CHANGELOG.md and bump STAGING in cli-versions.json"
|
||||
git commit --allow-empty -m "update CHANGELOG.md and bump STAGING and PRODUCTION in cli-versions.json"
|
||||
git push
|
||||
- name: Prepare changelog for Slack
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
name: Update Appium command list
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1' # Every Monday at 06:00 UTC
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- .github/workflows/update-appium-commands.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
generate:
|
||||
runs-on: ubuntu-latest
|
||||
name: Regenerate Appium command list
|
||||
outputs:
|
||||
changed: ${{ steps.diff.outputs.changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup tools
|
||||
uses: ./.github/actions/setup-mise
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Regenerate the Appium command list
|
||||
# The generator installs Appium, XCUITest, and UiAutomator2 on demand,
|
||||
# so it always picks up the latest supported command sets.
|
||||
run: yarn workspace @expo/build-tools generate-appium-commands
|
||||
- name: Check whether the command list changed
|
||||
id: diff
|
||||
run: |
|
||||
if git diff --quiet -- packages/build-tools/src/steps/utils/appiumCommands.generated.ts; then
|
||||
echo 'changed=false' >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'changed=true' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Upload the generated command list
|
||||
if: steps.diff.outputs.changed == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: appium-command-list
|
||||
path: packages/build-tools/src/steps/utils/appiumCommands.generated.ts
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
publish:
|
||||
needs: generate
|
||||
if: needs.generate.outputs.changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
name: Publish Appium command list update
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Download the generated command list
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: appium-command-list
|
||||
path: packages/build-tools/src/steps/utils
|
||||
- name: Open a pull request
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
|
||||
run: |
|
||||
branch=bot/update-appium-commands
|
||||
title='[build-tools] Update Appium command list'
|
||||
body=$'Automated update of the Appium, XCUITest, and UiAutomator2 command list.\n\nIf this PR adds new commands, typechecking will fail until a curated summary is added for each of them in `packages/build-tools/src/steps/utils/appiumCommandSummary.ts`.'
|
||||
|
||||
gh auth setup-git
|
||||
if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then
|
||||
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch"
|
||||
fi
|
||||
|
||||
git config user.name 'Expo Bot'
|
||||
git config user.email [email protected]
|
||||
git checkout -B "$branch"
|
||||
git add packages/build-tools/src/steps/utils/appiumCommands.generated.ts
|
||||
git commit -m "$title"
|
||||
git push --force-with-lease origin "$branch"
|
||||
|
||||
if gh pr view "$branch" --json state --jq '.state' 2>/dev/null | grep -q OPEN; then
|
||||
gh pr edit "$branch" --title "$title" --body "$body" --add-reviewer sjchmiela
|
||||
else
|
||||
gh pr create --base main --head "$branch" --title "$title" --body "$body" \
|
||||
--label 'no changelog' --reviewer sjchmiela
|
||||
fi
|
||||
@@ -8,6 +8,103 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--tag` to `eas simulator` and `eas simulator:list` to group and filter simulator sessions by app variant. ([#4318](https://github.com/expo/eas-cli/pull/4318) by [@tchayen](https://github.com/tchayen))
|
||||
- [eas-cli] Update workflow run logs in real time, instead of every 10 seconds. ([#4228](https://github.com/expo/eas-cli/pull/4228) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [build-tools] Add `expo-device-hub` web previews to Android web-preview-only, Agent Device, Argent, and Appium remote sessions. ([#4304](https://github.com/expo/eas-cli/pull/4304) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [eas-cli] Add `eas workflow:ssh <workflow-job-id> [command...]` to open an ssh session on the worker running a workflow job. ([#4032](https://github.com/expo/eas-cli/pull/4032) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `--ssh` (and `--ssh-idle-timeout`) to `eas workflow:run` to enable ssh on the run's VM jobs. ([#4033](https://github.com/expo/eas-cli/pull/4033) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Warn when starting Android EAS Simulator sessions that support is still in development and iOS parity is coming soon. ([#4320](https://github.com/expo/eas-cli/pull/4320) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [23.1.0](https://github.com/expo/eas-cli/releases/tag/v23.1.0) - 2026-08-31
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Prompt for the Expo SDK version in `eas new` and add a `--sdk-version` flag. The template now downloads from npm instead of GitHub to support SDK-specific versions. ([#4301](https://github.com/expo/eas-cli/pull/4301) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Clean up template metadata in projects created with `eas new`: remove the template LICENSE file and reset the package.json name, version, and license fields. ([#4301](https://github.com/expo/eas-cli/pull/4301) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Support `EAS_PNPM_FILTER_WORKSPACE` to install only the selected workspace's dependencies with `pnpm install --filter`. ([#4302](https://github.com/expo/eas-cli/pull/4302) by [@robingullo](https://github.com/robingullo))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Normalize extracted project permissions so source archives cannot leave files unreadable by EAS workers. ([#4287](https://github.com/expo/eas-cli/pull/4287) by [@AbbanMustafa](https://github.com/AbbanMustafa))
|
||||
- [eas-cli] Add dynamic retries to deploy command that scale up more generously, depending on deployment size ([#4299](https://github.com/expo/eas-cli/pull/4299) by [@kitten](https://github.com/kitten))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Add regression coverage for the bundled iOS entitlement introspection fallback. ([#4285](https://github.com/expo/eas-cli/pull/4285) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [local-build-plugin] Replace `bunyan` with `@expo/bunyan` while preserving the Steps formatter binary. ([#4284](https://github.com/expo/eas-cli/pull/4284) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [steps] Replace deprecated `lodash.get` with a local helper. ([#4283](https://github.com/expo/eas-cli/pull/4283) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli][steps] Bump `uuid` to v11 and remove the redundant `@types/uuid` dependency. ([#4282](https://github.com/expo/eas-cli/pull/4282) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Bump `minizlib` to 3.1.0 to drop its deprecated `rimraf@5` transitive dependency. ([#4281](https://github.com/expo/eas-cli/pull/4281) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [23.0.0](https://github.com/expo/eas-cli/releases/tag/v23.0.0) - 2026-08-28
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Rename the EAS Simulator preview-only session type from `--type serve-sim` to `--type web-preview-only`. ([#4298](https://github.com/expo/eas-cli/pull/4298) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Cache CocoaPods dependencies between iOS builds. ([#4266](https://github.com/expo/eas-cli/pull/4266) by [@AbbanMustafa](https://github.com/AbbanMustafa))
|
||||
- [build-tools] Support `EAS_BUN_FILTER_WORKSPACE` to install only the selected workspace's dependencies with `bun install --filter`. ([#4292](https://github.com/expo/eas-cli/pull/4292) by [@konrad-armatys](https://github.com/konrad-armatys))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Prevent user npm minimum release age settings from applying to internal EAS CLI commands. ([#4296](https://github.com/expo/eas-cli/pull/4296) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Reduce serve-sim preview resolution from 1280 px to 960 px to lower streaming bandwidth. ([#4297](https://github.com/expo/eas-cli/pull/4297) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [worker] Use turtle cache upload and download URLs for eas.json build cache restore and save. ([#3860](https://github.com/expo/eas-cli/pull/3860) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [ci] Bump both STAGING and PRODUCTION cli-versions in release workflow. ([#4290](https://github.com/expo/eas-cli/pull/4290) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [22.6.0](https://github.com/expo/eas-cli/releases/tag/v22.6.0) - 2026-08-26
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add an opt-in `--max-idle-time-minutes` flag to `eas simulator`; the session stops automatically after that many minutes without activity. ([#4156](https://github.com/expo/eas-cli/pull/4156) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Add experimental `--resource-class` flag to `eas simulator`. ([#4268](https://github.com/expo/eas-cli/pull/4268) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add worker-side SSH session helpers (upterm relay + session create/report/close). ([#4030](https://github.com/expo/eas-cli/pull/4030) by [@gwdp](https://github.com/gwdp))
|
||||
- [worker] Wire an SSH session into the build lifecycle behind the workflow ssh flag. ([#4031](https://github.com/expo/eas-cli/pull/4031) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Increase the serve-sim video bitrate to 6 Mbps for higher-quality simulator previews. ([#4291](https://github.com/expo/eas-cli/pull/4291) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Preapprove custom URL schemes before opening them in iOS Simulator sessions to avoid the first-use confirmation prompt. ([#4274](https://github.com/expo/eas-cli/pull/4274) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [22.4.0](https://github.com/expo/eas-cli/releases/tag/v22.4.0) - 2026-08-24
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--launch-arg`, `--open-url`, and Expo Go `--sdk-version` options to `eas simulator`. ([#4264](https://github.com/expo/eas-cli/pull/4264) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Pass launch arguments and a URL to open when launching applications in simulator sessions. ([#4263](https://github.com/expo/eas-cli/pull/4263) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Add `eas billing:subscribe <plan>` to start a Stripe checkout for an EAS plan, and `eas billing:manage` to open the Stripe customer portal. ([#4007](https://github.com/expo/eas-cli/pull/4007) by [@jonsamp](https://github.com/jonsamp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Support downloading iOS simulator archives whose app bundle contents are stored at the archive root. ([#4262](https://github.com/expo/eas-cli/pull/4262) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [22.3.0](https://github.com/expo/eas-cli/releases/tag/v22.3.0) - 2026-08-24
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Add `ios_signing_backend` option to the repack step. ([#4239](https://github.com/expo/eas-cli/pull/4239) by [@gabrieldonadel](https://github.com/gabrieldonadel))
|
||||
- [build-tools] Support an optional `package_version` input on `eas/start_serve_sim_remote_session`, so a simulator session can pin the `@expo/serve-sim` version instead of always running `latest`. ([#4253](https://github.com/expo/eas-cli/pull/4253) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add composable custom build functions for downloading, installing, and launching application archives in simulator sessions. ([#4222](https://github.com/expo/eas-cli/pull/4222) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [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] Upload tvOS (and other non-iOS) IPAs to the App Store Connect platform declared in the bundle's `DTPlatformName`, instead of always uploading to the iOS train. ([#4234](https://github.com/expo/eas-cli/pull/4234) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [22.2.0](https://github.com/expo/eas-cli/releases/tag/v22.2.0) - 2026-08-20
|
||||
|
||||
### 🎉 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))
|
||||
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "22.0.0",
|
||||
"PRODUCTION": "21.5.0"
|
||||
"STAGING": "23.1.0",
|
||||
"PRODUCTION": "23.1.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "22.2.0",
|
||||
"version": "23.2.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "22.2.0",
|
||||
"version": "23.2.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,6 +25,7 @@
|
||||
"build": "tsc",
|
||||
"build:record-sim": "mkdir -p bin && record_sim_bin_path=$(swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build --show-bin-path) && swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build && cp \"$record_sim_bin_path/record-sim\" bin/record-sim && chmod +x bin/record-sim",
|
||||
"typecheck": "tsc",
|
||||
"generate-appium-commands": "mise exec [email protected] -- node scripts/generate-appium-commands.js",
|
||||
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
|
||||
"jest-unit": "jest --config jest/unit-config.ts",
|
||||
"jest-integration": "jest --config jest/integration-config.ts",
|
||||
@@ -38,22 +39,23 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "22.0.0",
|
||||
"@expo/eas-build-job": "22.0.0",
|
||||
"@expo/downloader": "23.0.0",
|
||||
"@expo/eas-build-job": "23.1.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "22.0.0",
|
||||
"@expo/logger": "23.1.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "22.1.0",
|
||||
"@expo/template-file": "22.0.0",
|
||||
"@expo/turtle-spawn": "22.0.0",
|
||||
"@expo/steps": "23.1.0",
|
||||
"@expo/template-file": "23.0.0",
|
||||
"@expo/turtle-spawn": "23.1.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@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 +79,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",
|
||||
@@ -89,7 +92,6 @@
|
||||
"@types/retry": "^0.12.5",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"bunyan": "^1.8.15",
|
||||
"jest": "^29.7.0",
|
||||
"memfs": "^4.17.1",
|
||||
"nock": "13.4.0",
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// Regenerates src/steps/utils/appiumCommands.generated.ts from Appium and the
|
||||
// XCUITest and UiAutomator2 drivers, so TypeScript can verify that we handle
|
||||
// every command that can appear in Appium event timings.
|
||||
//
|
||||
// Appium and its drivers are NOT dependencies of this package. Like repack, we
|
||||
// install them on demand into a temp directory just for this script, so nothing
|
||||
// heavy ships with @expo/build-tools.
|
||||
//
|
||||
// Run with: yarn workspace @expo/build-tools generate-appium-commands
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
|
||||
// Track the Appium 3 line, matching the appium@^3 the worker installs for
|
||||
// simulator sessions (see startAppiumRemoteSession.ts).
|
||||
const APPIUM_VERSION = '^3';
|
||||
const DRIVERS = [
|
||||
{
|
||||
packageName: 'appium-xcuitest-driver',
|
||||
exportName: 'XCUITestDriver',
|
||||
typeName: 'XCUITestAppiumCommand',
|
||||
},
|
||||
{
|
||||
packageName: 'appium-uiautomator2-driver',
|
||||
exportName: 'AndroidUiautomator2Driver',
|
||||
typeName: 'UiAutomator2AppiumCommand',
|
||||
},
|
||||
];
|
||||
|
||||
function renderCommandType(typeName, commands) {
|
||||
return `export type ${typeName} =\n${commands.map(command => ` | ${serializeStringLiteral(command)}`).join('\n')};`;
|
||||
}
|
||||
|
||||
function serializeStringLiteral(value) {
|
||||
const jsonContents = JSON.stringify(value).slice(1, -1).replaceAll('\\"', '"');
|
||||
return `'${jsonContents.replaceAll("'", "\\'")}'`;
|
||||
}
|
||||
|
||||
function readRouteCommands(methodMap) {
|
||||
return Object.values(methodMap).flatMap(route =>
|
||||
Object.values(route).map(method => method.command)
|
||||
);
|
||||
}
|
||||
|
||||
async function readDriverCommands({ packageName, exportName }, sandbox) {
|
||||
const packageJsonPath = require.resolve(`${packageName}/package.json`, { paths: [sandbox] });
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
const modulePath = path.join(path.dirname(packageJsonPath), packageJson.exports['.'].import);
|
||||
const driverModule = await import(pathToFileURL(modulePath).href);
|
||||
const Driver = driverModule[exportName];
|
||||
return [
|
||||
...readRouteCommands(Driver.newMethodMap),
|
||||
...Object.values(Driver.executeMethodMap).map(method => method.command),
|
||||
];
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'appium-command-generator-'));
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(sandbox, 'package.json'),
|
||||
`${JSON.stringify({ name: 'appium-command-generator-sandbox', private: true })}\n`
|
||||
);
|
||||
execFileSync(
|
||||
'npm',
|
||||
[
|
||||
'install',
|
||||
'--prefix',
|
||||
sandbox,
|
||||
`appium@${APPIUM_VERSION}`,
|
||||
...DRIVERS.map(({ packageName }) => packageName),
|
||||
],
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { ALL_COMMANDS } = require(require.resolve('@appium/base-driver', { paths: [sandbox] }));
|
||||
const baseDriverCommands = [...new Set(ALL_COMMANDS)].sort();
|
||||
const baseDriverCommandSet = new Set(baseDriverCommands);
|
||||
const driverCommands = await Promise.all(
|
||||
DRIVERS.map(async driver => ({
|
||||
typeName: driver.typeName,
|
||||
commands: [...new Set(await readDriverCommands(driver, sandbox))]
|
||||
.filter(command => !baseDriverCommandSet.has(command))
|
||||
.sort(),
|
||||
}))
|
||||
);
|
||||
const commands = [
|
||||
...new Set([...baseDriverCommands, ...driverCommands.flatMap(({ commands }) => commands)]),
|
||||
];
|
||||
|
||||
const outputPath = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'src',
|
||||
'steps',
|
||||
'utils',
|
||||
'appiumCommands.generated.ts'
|
||||
);
|
||||
const contents = `// AUTO-GENERATED. Do not edit by hand.
|
||||
// Run \`yarn workspace @expo/build-tools generate-appium-commands\` to refresh this
|
||||
// list from Appium, XCUITest, and UiAutomator2 (installed on demand by the generator).
|
||||
${renderCommandType('BaseDriverAppiumCommand', baseDriverCommands)}
|
||||
|
||||
${driverCommands
|
||||
.map(({ typeName, commands: commandsForDriver }) =>
|
||||
renderCommandType(typeName, commandsForDriver)
|
||||
)
|
||||
.join('\n\n')}
|
||||
|
||||
export type AppiumCommand =
|
||||
| BaseDriverAppiumCommand
|
||||
| XCUITestAppiumCommand
|
||||
| UiAutomator2AppiumCommand;
|
||||
`;
|
||||
fs.writeFileSync(outputPath, contents);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Wrote ${commands.length} Appium commands to ${outputPath}`);
|
||||
} finally {
|
||||
fs.rmSync(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import bunyan from 'bunyan';
|
||||
import type { bunyan } from '@expo/logger';
|
||||
|
||||
export function createLogger(): bunyan {
|
||||
const logger = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,8 +18,12 @@ import { downloadApplicationArchiveAsync } from '../ios/resign';
|
||||
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
|
||||
import { Sentry } from '../sentry';
|
||||
import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
|
||||
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import {
|
||||
cacheStatsAsync,
|
||||
restoreCcacheAsync,
|
||||
restoreCocoapodsCacheAsync,
|
||||
} from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync, saveCocoapodsCacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import { uploadApplicationArchive } from '../utils/artifacts';
|
||||
import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
@@ -103,7 +107,13 @@ 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,
|
||||
});
|
||||
await restoreCocoapodsCacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
@@ -245,11 +255,17 @@ 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,
|
||||
});
|
||||
await saveCocoapodsCacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => {
|
||||
|
||||
@@ -70,6 +70,48 @@ describe('easBuildInternal', () => {
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('overrides the minimum release age only for the internal config command', async () => {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
child: jest.fn(),
|
||||
} as any;
|
||||
const ctx = {
|
||||
logger,
|
||||
env: {
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '365',
|
||||
},
|
||||
job: { platform: 'ios', buildProfile: 'production' },
|
||||
} as any;
|
||||
jest.mocked(resolveEasCommandPrefixAndEnvAsync).mockResolvedValueOnce({
|
||||
cmd: 'npx',
|
||||
args: ['-y', 'eas-cli@latest'],
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
jest.mocked(spawn).mockResolvedValueOnce({
|
||||
stdout: Buffer.from(JSON.stringify({ buildProfile: { env: {} } })),
|
||||
} as any);
|
||||
|
||||
await resolveEnvFromBuildProfileAsync(ctx, { cwd: '/tmp/project' });
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'npx',
|
||||
expect.any(Array),
|
||||
expect.objectContaining({
|
||||
env: {
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '0',
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(ctx.env).toEqual({
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '365',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes --refresh-ad-hoc-provisioning-profile to build:internal for iOS jobs with refreshAdHocProvisioningProfile', async () => {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
|
||||
@@ -3,7 +3,10 @@ import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { PackageManager } from '../../utils/packageManager';
|
||||
import { installDependenciesWithNpmCacheFallbackAsync } from '../installDependencies';
|
||||
import {
|
||||
installDependenciesAsync,
|
||||
installDependenciesWithNpmCacheFallbackAsync,
|
||||
} from '../installDependencies';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => jest.fn());
|
||||
jest.mock('../../sentry', () => ({
|
||||
@@ -236,6 +239,85 @@ describe(installDependenciesWithNpmCacheFallbackAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(installDependenciesAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(spawn).mockReturnValue(createSpawnPromise(Promise.resolve(createSpawnResult())));
|
||||
});
|
||||
|
||||
it('installs only the selected workspace when EAS_PNPM_FILTER_WORKSPACE is set', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await installDependenciesAsync({
|
||||
packageManager: PackageManager.PNPM,
|
||||
env: { EAS_PNPM_FILTER_WORKSPACE: 'my-app' },
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: true,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pnpm',
|
||||
['install', '--filter', 'my-app', '--frozen-lockfile'],
|
||||
expect.objectContaining({ cwd: '/tmp/build' })
|
||||
);
|
||||
});
|
||||
|
||||
it('installs all workspaces when EAS_PNPM_FILTER_WORKSPACE is not set', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await installDependenciesAsync({
|
||||
packageManager: PackageManager.PNPM,
|
||||
env: {},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: true,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pnpm',
|
||||
['install', '--frozen-lockfile'],
|
||||
expect.objectContaining({ cwd: '/tmp/build' })
|
||||
);
|
||||
});
|
||||
|
||||
it('installs only the selected workspace when EAS_BUN_FILTER_WORKSPACE is set', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await installDependenciesAsync({
|
||||
packageManager: PackageManager.BUN,
|
||||
env: { EAS_BUN_FILTER_WORKSPACE: 'my-app' },
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: true,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['install', '--filter', 'my-app', '--frozen-lockfile'],
|
||||
expect.objectContaining({ cwd: '/tmp/build' })
|
||||
);
|
||||
});
|
||||
|
||||
it('installs all workspaces when EAS_BUN_FILTER_WORKSPACE is not set', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await installDependenciesAsync({
|
||||
packageManager: PackageManager.BUN,
|
||||
env: {},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: true,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['install', '--frozen-lockfile'],
|
||||
expect.objectContaining({ cwd: '/tmp/build' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createSpawnPromise(result: Promise<SpawnResult>): SpawnPromise<SpawnResult> {
|
||||
return Object.assign(result, {
|
||||
child: {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Platform,
|
||||
Workflow,
|
||||
} from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { randomBytes, randomUUID } from 'crypto';
|
||||
import { vol } from 'memfs';
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
@@ -14,7 +15,7 @@ import { setTimeout } from 'timers/promises';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { shallowCloneRepositoryAsync } from '../git';
|
||||
import { prepareProjectSourcesAsync } from '../projectSources';
|
||||
import { prepareProjectSourcesAsync, unpackTarGzAsync } from '../projectSources';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('node-fetch');
|
||||
@@ -23,6 +24,27 @@ jest.mock('@expo/downloader');
|
||||
jest.mock('@urql/core');
|
||||
|
||||
describe('projectSources', () => {
|
||||
it('makes extracted project sources readable and writable by the worker', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await unpackTarGzAsync({
|
||||
source: '/workingdir/project.tar.gz',
|
||||
destination: '/workingdir/build',
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'tar',
|
||||
['-C', '/workingdir/build', '--strip-components', '1', '-zxf', '/workingdir/project.tar.gz'],
|
||||
{ logger }
|
||||
);
|
||||
expect(spawn).toHaveBeenNthCalledWith(2, 'chmod', ['-R', 'u+rwX', '/workingdir/build'], {
|
||||
logger,
|
||||
});
|
||||
expect(logger.info).toHaveBeenCalledWith('Normalizing project source permissions');
|
||||
});
|
||||
|
||||
it('should use the refreshed repository URL', async () => {
|
||||
const robotAccessToken = randomUUID();
|
||||
const buildId = randomUUID();
|
||||
|
||||
@@ -32,7 +32,11 @@ export async function installDependenciesAsync({
|
||||
break;
|
||||
}
|
||||
case PackageManager.PNPM: {
|
||||
args = ['install', useFrozenLockfile ? '--frozen-lockfile' : '--no-frozen-lockfile'];
|
||||
args = [
|
||||
'install',
|
||||
...(env['EAS_PNPM_FILTER_WORKSPACE'] ? ['--filter', env['EAS_PNPM_FILTER_WORKSPACE']] : []),
|
||||
useFrozenLockfile ? '--frozen-lockfile' : '--no-frozen-lockfile',
|
||||
];
|
||||
break;
|
||||
}
|
||||
case PackageManager.YARN: {
|
||||
@@ -58,7 +62,11 @@ export async function installDependenciesAsync({
|
||||
break;
|
||||
}
|
||||
case PackageManager.BUN:
|
||||
args = ['install', ...(useFrozenLockfile ? ['--frozen-lockfile'] : [])];
|
||||
args = [
|
||||
'install',
|
||||
...(env['EAS_BUN_FILTER_WORKSPACE'] ? ['--filter', env['EAS_BUN_FILTER_WORKSPACE']] : []),
|
||||
...(useFrozenLockfile ? ['--frozen-lockfile'] : []),
|
||||
];
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported package manager: ${packageManager}`);
|
||||
|
||||
@@ -110,7 +110,7 @@ async function prepareProjectSourcesLocallyAsync<TJob extends Job>(
|
||||
});
|
||||
}
|
||||
|
||||
async function unpackTarGzAsync({
|
||||
export async function unpackTarGzAsync({
|
||||
logger,
|
||||
source,
|
||||
destination,
|
||||
@@ -122,6 +122,8 @@ async function unpackTarGzAsync({
|
||||
await spawn('tar', ['-C', destination, '--strip-components', '1', '-zxf', source], {
|
||||
logger,
|
||||
});
|
||||
logger.info('Normalizing project source permissions');
|
||||
await spawn('chmod', ['-R', 'u+rwX', destination], { logger });
|
||||
}
|
||||
|
||||
function uploadProjectMetadataAsFireAndForget(
|
||||
|
||||
@@ -5,6 +5,8 @@ import RemoteLoggerStream from './logging/RemoteLoggerStream';
|
||||
export { Builders, HttpLogStream, RemoteLoggerStream };
|
||||
export { uploadWithSignedUrl } from './storage/uploadWithSignedUrl';
|
||||
export type { SignedUrl, UploadWithSignedUrlParams } from './storage/uploadWithSignedUrl';
|
||||
export { downloadCacheAsync } from './steps/functions/restoreCache';
|
||||
export { uploadCacheAsync } from './steps/functions/saveCache';
|
||||
|
||||
export {
|
||||
ArtifactToUpload,
|
||||
@@ -18,6 +20,9 @@ export {
|
||||
|
||||
export { PackageManager } from './utils/packageManager';
|
||||
|
||||
export * as TurtleSshSession from './utils/turtleSshSession';
|
||||
export { formatSecondsForLog } from './utils/formatDuration';
|
||||
|
||||
export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
|
||||
|
||||
export { Hook, runHookIfPresent } from './utils/hooks';
|
||||
|
||||
@@ -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';
|
||||
@@ -49,7 +51,7 @@ import { createStartAppiumRemoteSessionBuildFunction } from './functions/startAp
|
||||
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
|
||||
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
|
||||
import { createStartIosSimulatorRecordingsBuildFunction } from './functions/startIosSimulatorRecordings';
|
||||
import { createStartServeSimRemoteSessionBuildFunction } from './functions/startServeSimRemoteSession';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/startWebPreviewRemoteSession';
|
||||
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
|
||||
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
|
||||
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
|
||||
@@ -74,6 +76,8 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createPrebuildBuildFunction(),
|
||||
createReadIpaInfoBuildFunction(),
|
||||
createDownloadBuildFunction(ctx),
|
||||
createInstallBuildFunction(),
|
||||
createLaunchApplicationFunction(),
|
||||
createEasExportBuildFunction(),
|
||||
createEasDeployBuildFunction(),
|
||||
createRepackBuildFunction(),
|
||||
@@ -102,7 +106,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createStartIosSimulatorRecordingsBuildFunction(),
|
||||
createFinishIosSimulatorRecordingsBuildFunction(),
|
||||
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
|
||||
createStartServeSimRemoteSessionBuildFunction(ctx),
|
||||
createStartWebPreviewRemoteSessionBuildFunction(ctx),
|
||||
createStartServeSimMetricsBuildFunction(),
|
||||
createCollectServeSimMetricsBuildFunction(ctx),
|
||||
createInstallMaestroBuildFunction(),
|
||||
|
||||
@@ -162,6 +162,28 @@ describe(createEasBuildBuildFunctionGroup, () => {
|
||||
expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('./ios');
|
||||
});
|
||||
|
||||
it('restores and saves caches around an iOS simulator build', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
|
||||
const stepNames = steps.map(step => step.displayName);
|
||||
const restoreCacheIndex = stepNames.indexOf('Restore Cache');
|
||||
const installPodsIndex = stepNames.indexOf('Install Pods');
|
||||
const saveCacheIndex = stepNames.indexOf('Save Cache');
|
||||
|
||||
expect(restoreCacheIndex).toBeGreaterThan(-1);
|
||||
expect(restoreCacheIndex).toBeLessThan(installPodsIndex);
|
||||
expect(saveCacheIndex).toBeGreaterThan(installPodsIndex);
|
||||
|
||||
const restoreCacheStep = steps[restoreCacheIndex];
|
||||
expect(restoreCacheStep.inputs?.find(input => input.id === 'simulator')?.rawValue).toBe(true);
|
||||
});
|
||||
|
||||
it('sets working directory on all steps except checkout (Android with credentials)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.ANDROID,
|
||||
|
||||
@@ -98,6 +98,8 @@ function createStepsForIosSimulatorBuild({
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const evictUsedBefore = new Date();
|
||||
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
@@ -106,6 +108,16 @@ function createStepsForIosSimulatorBuild({
|
||||
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
|
||||
});
|
||||
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
@@ -123,6 +135,16 @@ function createStepsForIosSimulatorBuild({
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
},
|
||||
});
|
||||
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
@@ -138,6 +160,7 @@ function createStepsForIosSimulatorBuild({
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
restoreCache,
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
installPods,
|
||||
configureEASUpdate,
|
||||
@@ -159,6 +182,10 @@ function createStepsForIosSimulatorBuild({
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { spawnAsync } from '@expo/steps';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { Datadog } from '../../../datadog';
|
||||
import {
|
||||
compressCocoapodsCacheAsync,
|
||||
getCocoapodsCachePaths,
|
||||
resolveCocoapodsCacheKeyAsync,
|
||||
restoreCocoapodsCacheArchiveAsync,
|
||||
} from '../../../utils/cocoapodsCache';
|
||||
import { downloadCacheAsync } from '../restoreCache';
|
||||
import { restoreCocoapodsCacheAsync } from '../restoreBuildCache';
|
||||
import { uploadCacheAsync } from '../saveCache';
|
||||
import { saveCocoapodsCacheAsync } from '../saveBuildCache';
|
||||
|
||||
jest.mock('@expo/steps', () => ({
|
||||
...jest.requireActual('@expo/steps'),
|
||||
spawnAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../restoreCache', () => ({
|
||||
decompressCacheAsync: jest.fn(),
|
||||
downloadCacheAsync: jest.fn(),
|
||||
downloadPublicCacheAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../saveCache', () => ({
|
||||
compressCacheAsync: jest.fn(),
|
||||
uploadCacheAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../utils/cocoapodsCache', () => ({
|
||||
compressCocoapodsCacheAsync: jest.fn(),
|
||||
getCocoapodsCachePaths: jest.fn(),
|
||||
resolveCocoapodsCacheKeyAsync: jest.fn(),
|
||||
restoreCocoapodsCacheArchiveAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const logger = createMockLogger();
|
||||
const env = {
|
||||
EAS_PODS_CACHE: '1',
|
||||
EAS_BUILD_ID: 'build-id',
|
||||
__API_SERVER_URL: 'https://api.expo.test',
|
||||
};
|
||||
const secrets = { robotAccessToken: 'robot-token' };
|
||||
|
||||
describe('CocoaPods build cache', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vol.fromJSON(
|
||||
{
|
||||
'/workingdir/ios/Pods/Manifest.lock': 'manifest',
|
||||
'/workingdir/ios/Podfile.lock': 'lockfile',
|
||||
'/tmp/cocoapods-cache.tar.gz': 'archive',
|
||||
},
|
||||
'/'
|
||||
);
|
||||
jest.mocked(getCocoapodsCachePaths).mockReturnValue({
|
||||
iosDirectory: '/workingdir/ios',
|
||||
podsDirectory: '/workingdir/ios/Pods',
|
||||
podfileLockPath: '/workingdir/ios/Podfile.lock',
|
||||
});
|
||||
jest.mocked(resolveCocoapodsCacheKeyAsync).mockResolvedValue({
|
||||
key: 'ios-pods-1.16.2-lock-hash',
|
||||
keyPrefix: 'ios-pods-1.16.2-',
|
||||
});
|
||||
jest.mocked(spawnAsync).mockResolvedValue({ stdout: '1.16.2\n' } as any);
|
||||
});
|
||||
|
||||
it('does nothing when the cache is disabled', async () => {
|
||||
await restoreCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory: '/workingdir',
|
||||
env: {},
|
||||
secrets,
|
||||
});
|
||||
await saveCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory: '/workingdir',
|
||||
env: {},
|
||||
secrets,
|
||||
});
|
||||
|
||||
expect(spawnAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores the newest matching CocoaPods cache', async () => {
|
||||
jest.mocked(downloadCacheAsync).mockResolvedValue({
|
||||
archivePath: '/tmp/cocoapods-cache.tar.gz',
|
||||
matchedKey: 'ios-pods-1.16.2-older-lock-hash',
|
||||
});
|
||||
const datadogLogSpy = jest.spyOn(Datadog, 'log');
|
||||
|
||||
await restoreCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory: '/workingdir',
|
||||
env,
|
||||
secrets,
|
||||
});
|
||||
|
||||
expect(downloadCacheAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'ios-pods-1.16.2-lock-hash',
|
||||
keyPrefixes: ['ios-pods-1.16.2-'],
|
||||
paths: ['/workingdir/ios/Pods'],
|
||||
})
|
||||
);
|
||||
expect(restoreCocoapodsCacheArchiveAsync).toHaveBeenCalledWith({
|
||||
archivePath: '/tmp/cocoapods-cache.tar.gz',
|
||||
workingDirectory: '/workingdir',
|
||||
});
|
||||
expect(datadogLogSpy).toHaveBeenCalledWith('CocoaPods cache restored (prefix_match)', {
|
||||
event: 'cocoapods_cache_restored',
|
||||
cache_hit_type: 'prefix_match',
|
||||
});
|
||||
});
|
||||
|
||||
it('compresses and saves the installed Pods directory', async () => {
|
||||
jest.mocked(compressCocoapodsCacheAsync).mockResolvedValue({
|
||||
archivePath: '/tmp/cocoapods-cache.tar.gz',
|
||||
});
|
||||
|
||||
await saveCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory: '/workingdir',
|
||||
env,
|
||||
secrets,
|
||||
});
|
||||
|
||||
expect(uploadCacheAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
archivePath: '/tmp/cocoapods-cache.tar.gz',
|
||||
key: 'ios-pods-1.16.2-lock-hash',
|
||||
paths: ['/workingdir/ios/Pods'],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,10 @@ import { Client, CombinedError } from '@urql/core';
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import * as tar from 'tar';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
@@ -19,6 +21,9 @@ const APP_TAR_GZ_BUFFER = Buffer.from(
|
||||
'base64'
|
||||
);
|
||||
|
||||
const FLAT_APP_INFO_PLIST_BASE64 =
|
||||
'YnBsaXN0MDDSAQIDBF8QEkNGQnVuZGxlRXhlY3V0YWJsZV8QE0NGQnVuZGxlUGFja2FnZVR5cGVXVGVzdEFwcFRBUFBMCA0iOEAAAAAAAAABAQAAAAAAAAAFAAAAAAAAAAAAAAAAAAAARQ==';
|
||||
|
||||
const APPLICATION_ARCHIVE_URL = `https://expo.dev/artifacts/eas/${randomUUID()}.tar.gz`;
|
||||
|
||||
function createMockGraphqlClient({
|
||||
@@ -51,6 +56,57 @@ function createMockGraphqlClient({
|
||||
} as unknown as Client;
|
||||
}
|
||||
|
||||
function createSuccessfulResponse({
|
||||
body,
|
||||
url,
|
||||
headers,
|
||||
}: {
|
||||
body: Buffer;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}): Response {
|
||||
const normalizedHeaders = Object.fromEntries(
|
||||
Object.entries(headers ?? {}).map(([name, value]) => [name.toLowerCase(), value])
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
body: Readable.from(body),
|
||||
url,
|
||||
headers: {
|
||||
get: (name: string): string | null => normalizedHeaders[name.toLowerCase()] ?? null,
|
||||
},
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
|
||||
const sourceDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'flat-app-source-'));
|
||||
const archivePath = path.join(os.tmpdir(), `${randomUUID()}.tar.gz`);
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
fs.promises.writeFile(path.join(sourceDirectory, 'Info.plist'), FLAT_APP_INFO_PLIST_BASE64, {
|
||||
encoding: 'base64',
|
||||
}),
|
||||
fs.promises.writeFile(path.join(sourceDirectory, 'TestApp'), 'i am executable\n'),
|
||||
]);
|
||||
await tar.create(
|
||||
{
|
||||
cwd: sourceDirectory,
|
||||
file: archivePath,
|
||||
gzip: true,
|
||||
},
|
||||
['Info.plist', 'TestApp']
|
||||
);
|
||||
return await fs.promises.readFile(archivePath);
|
||||
} finally {
|
||||
await Promise.all([
|
||||
fs.promises.rm(sourceDirectory, { recursive: true, force: true }),
|
||||
fs.promises.rm(archivePath, { force: true }),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
describe('downloadBuild', () => {
|
||||
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
|
||||
const buildId = randomUUID();
|
||||
@@ -58,11 +114,12 @@ describe('downloadBuild', () => {
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
body: Readable.from(APP_TAR_GZ_BUFFER),
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
} as unknown as Response);
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
createSuccessfulResponse({
|
||||
body: APP_TAR_GZ_BUFFER,
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
})
|
||||
);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
@@ -82,17 +139,39 @@ describe('downloadBuild', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('downloads an archive containing app bundle contents at its root', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
createSuccessfulResponse({
|
||||
body: await createFlatAppTarGzBufferAsync(),
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
})
|
||||
);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient: createMockGraphqlClient({}),
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
|
||||
expect(path.extname(artifactPath)).toBe('.app');
|
||||
expect(await fs.promises.readFile(path.join(artifactPath, 'TestApp'), 'utf8')).toBe(
|
||||
'i am executable\n'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle a straight-up file', async () => {
|
||||
const applicationArchiveUrl = `https://expo.dev/artifacts/eas/${randomUUID()}.apk`;
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl,
|
||||
});
|
||||
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
body: Readable.from(Buffer.from('hello')),
|
||||
url: applicationArchiveUrl,
|
||||
} as unknown as Response);
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: applicationArchiveUrl })
|
||||
);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
@@ -106,6 +185,128 @@ describe('downloadBuild', () => {
|
||||
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
|
||||
});
|
||||
|
||||
it('downloads a direct application archive URL without querying GraphQL or forwarding the EAS token', async () => {
|
||||
const applicationArchiveUrl = `https://artifacts.example.test/${randomUUID()}.apk`;
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: applicationArchiveUrl })
|
||||
);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl,
|
||||
graphqlClient,
|
||||
robotAccessToken: 'scoped-eas-token',
|
||||
extensions: ['apk'],
|
||||
});
|
||||
|
||||
expect(graphqlClient.query).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
applicationArchiveUrl,
|
||||
expect.objectContaining({ headers: undefined })
|
||||
);
|
||||
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
|
||||
});
|
||||
|
||||
it('uses the Content-Disposition filename for an extensionless URL', async () => {
|
||||
const applicationArchiveUrl = 'https://artifacts.example.test/download';
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
createSuccessfulResponse({
|
||||
body: Buffer.from('hello'),
|
||||
url: applicationArchiveUrl,
|
||||
headers: { 'content-disposition': 'attachment; filename="app.apk"' },
|
||||
})
|
||||
);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl,
|
||||
graphqlClient: createMockGraphqlClient({}),
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
|
||||
expect(path.basename(artifactPath)).toBe('app.apk');
|
||||
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
|
||||
});
|
||||
|
||||
it('uses the expected extension when the URL has no filename', async () => {
|
||||
const applicationArchiveUrl = 'https://artifacts.example.test/';
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: applicationArchiveUrl })
|
||||
);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl,
|
||||
graphqlClient: createMockGraphqlClient({}),
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
|
||||
expect(path.basename(artifactPath)).toBe('application.apk');
|
||||
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
|
||||
});
|
||||
|
||||
it('rejects missing or ambiguous build sources', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
|
||||
await expect(
|
||||
// @ts-expect-error Verify the runtime guard for callers without TypeScript.
|
||||
downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
errorCode: 'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
|
||||
message: 'Pass buildId or applicationArchiveUrl.',
|
||||
});
|
||||
|
||||
await expect(
|
||||
// @ts-expect-error Verify the runtime guard for callers without TypeScript.
|
||||
downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
errorCode: 'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
|
||||
message: 'Pass only one of buildId or applicationArchiveUrl.',
|
||||
});
|
||||
|
||||
expect(graphqlClient.query).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a non-HTTP application archive URL', async () => {
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: 'file:///tmp/app.apk',
|
||||
graphqlClient: createMockGraphqlClient({}),
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
errorCode: 'EAS_DOWNLOAD_BUILD_INVALID_APPLICATION_ARCHIVE_URL',
|
||||
message: 'application_archive_url must be a valid HTTP or HTTPS URL.',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws UserError when the build has no application archive url', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({ applicationArchiveUrl: null });
|
||||
|
||||
@@ -203,11 +404,12 @@ describe('downloadBuild', () => {
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
body: Readable.from(APP_TAR_GZ_BUFFER),
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
} as unknown as Response);
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
createSuccessfulResponse({
|
||||
body: APP_TAR_GZ_BUFFER,
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { createInstallBuildFunction, installBuildAsync } from '../installBuild';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
async function makeTemporaryDirectoryAsync(): Promise<string> {
|
||||
const temporaryDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'install-build-test-')
|
||||
);
|
||||
temporaryDirectories.push(temporaryDirectory);
|
||||
return temporaryDirectory;
|
||||
}
|
||||
|
||||
describe(installBuildAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.map(temporaryDirectory =>
|
||||
fs.promises.rm(temporaryDirectory, { recursive: true, force: true })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('installs an iOS Simulator .app', async () => {
|
||||
const temporaryDirectory = await makeTemporaryDirectoryAsync();
|
||||
const artifactPath = path.join(temporaryDirectory, 'Example.app');
|
||||
await fs.promises.mkdir(artifactPath);
|
||||
const logger = createMockLogger();
|
||||
mockedSpawn.mockResolvedValueOnce({ stdout: 'com.example.app\n', stderr: '' } as any);
|
||||
|
||||
const result = await installBuildAsync({
|
||||
artifactPath,
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ applicationIdentifier: 'com.example.app' });
|
||||
expect(mockedSpawn.mock.calls).toEqual([
|
||||
[
|
||||
'plutil',
|
||||
['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', path.join(artifactPath, 'Info.plist')],
|
||||
{ stdio: 'pipe', env: {} },
|
||||
],
|
||||
['xcrun', ['simctl', 'install', 'booted', artifactPath], { env: {}, logger }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('installs an Android Emulator .apk', async () => {
|
||||
const temporaryDirectory = await makeTemporaryDirectoryAsync();
|
||||
const artifactPath = path.join(temporaryDirectory, 'example.apk');
|
||||
await fs.promises.writeFile(artifactPath, 'apk');
|
||||
const logger = createMockLogger();
|
||||
mockedSpawn.mockResolvedValueOnce({
|
||||
stdout:
|
||||
"package: name='com.example.app'\nlaunchable-activity: name='com.example.app.MainActivity'\n",
|
||||
stderr: '',
|
||||
} as any);
|
||||
|
||||
const result = await installBuildAsync({
|
||||
artifactPath,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
});
|
||||
expect(mockedSpawn.mock.calls).toEqual([
|
||||
['aapt2', ['dump', 'badging', artifactPath], { stdio: 'pipe', env: {} }],
|
||||
['adb', ['install', '-r', artifactPath], { env: {}, logger }],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[BuildRuntimePlatform.DARWIN, 'example.ipa'],
|
||||
[BuildRuntimePlatform.LINUX, 'example.aab'],
|
||||
])('rejects an incompatible %s artifact', async (runtimePlatform, filename) => {
|
||||
const temporaryDirectory = await makeTemporaryDirectoryAsync();
|
||||
const artifactPath = path.join(temporaryDirectory, filename);
|
||||
await fs.promises.writeFile(artifactPath, 'artifact');
|
||||
|
||||
await expect(
|
||||
installBuildAsync({
|
||||
artifactPath,
|
||||
runtimePlatform,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_INSTALL_BUILD_INVALID_ARTIFACT' });
|
||||
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('installs an Android artifact without a launchable activity', async () => {
|
||||
const temporaryDirectory = await makeTemporaryDirectoryAsync();
|
||||
const artifactPath = path.join(temporaryDirectory, 'example.apk');
|
||||
await fs.promises.writeFile(artifactPath, 'apk');
|
||||
const logger = createMockLogger();
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: "package: name='com.example.app'\n",
|
||||
stderr: '',
|
||||
} as any);
|
||||
|
||||
const result = await installBuildAsync({
|
||||
artifactPath,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ applicationIdentifier: 'com.example.app' });
|
||||
expect(mockedSpawn.mock.calls).toEqual([
|
||||
['aapt2', ['dump', 'badging', artifactPath], { stdio: 'pipe', env: {} }],
|
||||
['adb', ['install', '-r', artifactPath], { env: {}, logger }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('exposes the installed application identifier and activity as step outputs', async () => {
|
||||
const temporaryDirectory = await makeTemporaryDirectoryAsync();
|
||||
const artifactPath = path.join(temporaryDirectory, 'example.apk');
|
||||
await fs.promises.writeFile(artifactPath, 'apk');
|
||||
mockedSpawn.mockResolvedValueOnce({
|
||||
stdout:
|
||||
"package: name='com.example.app'\nlaunchable-activity: name='com.example.app.MainActivity'\n",
|
||||
stderr: '',
|
||||
} as any);
|
||||
|
||||
const installBuild = createInstallBuildFunction();
|
||||
const buildStep = installBuild.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
|
||||
{ callInputs: { artifact_path: artifactPath } }
|
||||
);
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.outputById.application_identifier.value).toBe('com.example.app');
|
||||
expect(buildStep.outputById.activity_name.value).toBe('com.example.app.MainActivity');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { Datadog } from '../../../datadog';
|
||||
@@ -11,6 +15,11 @@ jest.mock('@expo/turtle-spawn', () => ({
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@expo/downloader', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../datadog', () => ({
|
||||
Datadog: {
|
||||
distribution: jest.fn(),
|
||||
@@ -18,10 +27,24 @@ jest.mock('../../../datadog', () => ({
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedDownloadFile = jest.mocked(downloadFile);
|
||||
|
||||
async function writeInstalledWdaVersion(homeDirectory: string, version: string): Promise<void> {
|
||||
const wdaDirectory = path.join(
|
||||
homeDirectory,
|
||||
'.maestro-runner',
|
||||
'drivers',
|
||||
'ios',
|
||||
'WebDriverAgent'
|
||||
);
|
||||
await fs.promises.mkdir(wdaDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(path.join(wdaDirectory, 'package.json'), JSON.stringify({ version }));
|
||||
}
|
||||
|
||||
describe('createInstallMaestroBuildFunction', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
// `maestro --version` reports an installed version; `java -version` succeeds.
|
||||
// With no requested version and Maestro present, the step skips installation.
|
||||
mockedSpawn.mockImplementation((async (command: string) => ({
|
||||
@@ -439,4 +462,184 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it('installs the prebuilt WDA cache for each available iOS runtime', async () => {
|
||||
const homeDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'install-maestro-test-')
|
||||
);
|
||||
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'maestro-runner') {
|
||||
return { stdout: 'maestro-runner 1.2.3\n' };
|
||||
}
|
||||
if (command === 'xcrun') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
runtimes: [
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
|
||||
isAvailable: true,
|
||||
version: '18.3.1',
|
||||
},
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-0',
|
||||
isAvailable: true,
|
||||
version: '26.0',
|
||||
},
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-17-5',
|
||||
isAvailable: false,
|
||||
version: '17.5',
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (command === 'xcodebuild') {
|
||||
return { stdout: 'Xcode 26.0\nBuild version 17A324\n' };
|
||||
}
|
||||
if (command === 'tar') {
|
||||
const maestroRunnerHome = args[args.indexOf('-C') + 1];
|
||||
const productsDirectory = path.join(
|
||||
maestroRunnerHome,
|
||||
'cache',
|
||||
'wda-builds',
|
||||
'generic',
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products'
|
||||
);
|
||||
await fs.promises.mkdir(productsDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(productsDirectory, 'WebDriverAgentRunner.xctestrun'),
|
||||
'cached'
|
||||
);
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as any);
|
||||
|
||||
try {
|
||||
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
|
||||
HOME: homeDirectory,
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenCalledWith(
|
||||
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.0-wda-11.1.3.tar.gz',
|
||||
expect.stringMatching(/install_maestro_runner_wda_cache.*\/wda-cache\.tar\.gz$/),
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
);
|
||||
for (const runtimeVersion of ['18.3', '26.0']) {
|
||||
await expect(
|
||||
fs.promises.readFile(
|
||||
path.join(
|
||||
homeDirectory,
|
||||
'.maestro-runner',
|
||||
'cache',
|
||||
'wda-builds',
|
||||
`sim-ios${runtimeVersion}-iphone`,
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products',
|
||||
'WebDriverAgentRunner.xctestrun'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).resolves.toBe('cached');
|
||||
}
|
||||
await expect(
|
||||
fs.promises.access(
|
||||
path.join(homeDirectory, '.maestro-runner', 'cache', 'wda-builds', 'sim-ios17.5-iphone')
|
||||
)
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not download the WDA cache when the installed WDA version is unknown', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => ({
|
||||
stdout:
|
||||
command === 'xcodebuild' ? 'Xcode 26.5\nBuild version 17F90\n' : 'maestro-runner 1.2.3\n',
|
||||
})) as any);
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build', HOME: '/home/expo' });
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
// The installed WDA version is unknown (no package.json under $HOME), so the
|
||||
// cache download is skipped before resolving the Xcode/runtime versions.
|
||||
expect(mockedDownloadFile).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn.mock.calls.map(([command]) => command)).not.toContain('xcrun');
|
||||
});
|
||||
|
||||
it('continues when the prebuilt WDA cache is not available', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
switch (command) {
|
||||
case 'maestro-runner':
|
||||
return { stdout: 'maestro-runner 1.2.3\n' };
|
||||
case 'xcodebuild':
|
||||
return { stdout: 'Xcode 26.5\nBuild version 17F90\n' };
|
||||
case 'xcrun':
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
runtimes: [
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
|
||||
isAvailable: true,
|
||||
version: '26.5',
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
default:
|
||||
return { stdout: '' };
|
||||
}
|
||||
}) as any);
|
||||
mockedDownloadFile.mockRejectedValue(new Error('HTTP 404'));
|
||||
const homeDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'install-maestro-test-')
|
||||
);
|
||||
|
||||
try {
|
||||
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
|
||||
HOME: homeDirectory,
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).resolves.toBeUndefined();
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.2.3');
|
||||
expect(mockedDownloadFile.mock.calls.map(([url]) => url)).toEqual([
|
||||
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
|
||||
'https://storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
|
||||
]);
|
||||
} finally {
|
||||
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
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',
|
||||
'spawn',
|
||||
'booted',
|
||||
'defaults',
|
||||
'write',
|
||||
'com.apple.launchservices.schemeapproval',
|
||||
'com.apple.CoreSimulator.CoreSimulatorBridge-->exp',
|
||||
'-string',
|
||||
'host.exp.Exponent',
|
||||
],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'xcrun',
|
||||
['simctl', 'openurl', 'booted', 'exp://example.test'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
});
|
||||
|
||||
it('opens a web URL on iOS without preapproving its scheme', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
openUrl: 'https://example.test',
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'xcrun',
|
||||
['simctl', 'launch', 'booted', 'com.example.app'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'xcrun',
|
||||
['simctl', 'openurl', 'booted', 'https://example.test'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('opens a custom URL on iOS when scheme preapproval fails', async () => {
|
||||
const logger = createMockLogger();
|
||||
const approvalError = new Error('Scheme approval is unavailable.');
|
||||
mockedSpawn
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any)
|
||||
.mockRejectedValueOnce(approvalError);
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'host.exp.Exponent',
|
||||
openUrl: 'exp://example.test',
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'xcrun',
|
||||
['simctl', 'openurl', 'booted', 'exp://example.test'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: approvalError },
|
||||
'Could not preapprove the exp URL scheme for host.exp.Exponent. Opening the URL anyway; the Simulator might require manual confirmation.'
|
||||
);
|
||||
});
|
||||
|
||||
it('launches an Android Emulator application by package and activity', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'adb',
|
||||
['shell', 'am', 'start', '-n', 'com.example.app/com.example.app.MainActivity'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
});
|
||||
|
||||
it('passes launch arguments and opens a URL on Android', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'host.exp.exponent',
|
||||
activityName: 'host.exp.exponent.MainActivity',
|
||||
launchArgs: ['--ez', 'isTest', 'true'],
|
||||
openUrl: 'exp://example.test',
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'--ez',
|
||||
'isTest',
|
||||
'true',
|
||||
'-n',
|
||||
'host.exp.exponent/host.exp.exponent.MainActivity',
|
||||
],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-a',
|
||||
'android.intent.action.VIEW',
|
||||
'-d',
|
||||
'exp://example.test',
|
||||
'-n',
|
||||
'host.exp.exponent/host.exp.exponent.MainActivity',
|
||||
],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(logger.info).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'Launching host.exp.exponent with arguments ["--ez","isTest","true"].'
|
||||
);
|
||||
});
|
||||
|
||||
it('requires an activity when launching an Android application', async () => {
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_MISSING_ACTIVITY' });
|
||||
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('launches using application metadata passed to the build step', async () => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
|
||||
{
|
||||
callInputs: {
|
||||
application_identifier: 'com.example.app',
|
||||
activity_name: 'com.example.app.MainActivity',
|
||||
launch_args: ['--ez', 'isTest', 'true'],
|
||||
open_url: 'exp://example.test',
|
||||
},
|
||||
}
|
||||
);
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'--ez',
|
||||
'isTest',
|
||||
'true',
|
||||
'-n',
|
||||
'com.example.app/com.example.app.MainActivity',
|
||||
],
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-a',
|
||||
'android.intent.action.VIEW',
|
||||
'-d',
|
||||
'exp://example.test',
|
||||
'-n',
|
||||
'com.example.app/com.example.app.MainActivity',
|
||||
],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it.each([{}, ['valid', 42]])('rejects invalid launch arguments (%j)', async launchArgs => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.DARWIN }),
|
||||
{
|
||||
callInputs: {
|
||||
application_identifier: 'com.example.app',
|
||||
launch_args: launchArgs,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await expect(buildStep.executeAsync()).rejects.toMatchObject({
|
||||
errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
message: 'Input "launch_args" must be an array of strings.',
|
||||
});
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an invalid URL to open', async () => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.DARWIN }),
|
||||
{
|
||||
callInputs: {
|
||||
application_identifier: 'com.example.app',
|
||||
open_url: 'not a URL',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await expect(buildStep.executeAsync()).rejects.toMatchObject({
|
||||
errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
message: 'Input "open_url" must be a valid URL.',
|
||||
});
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['application_identifier', 'activity_name'])(
|
||||
'reports an empty %s input as a user error',
|
||||
async inputName => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
|
||||
{
|
||||
callInputs: {
|
||||
application_identifier: 'com.example.app',
|
||||
activity_name: 'com.example.app.MainActivity',
|
||||
[inputName]: '',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await expect(buildStep.executeAsync()).rejects.toMatchObject({
|
||||
errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
message: `Input "${inputName}" must be a non-empty string. Pass the "${inputName}" output from eas/install_build.`,
|
||||
});
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -26,6 +26,7 @@ describe(readIpaInfoAsync, () => {
|
||||
bundleIdentifier: 'dev.expo.SmallestAppExample',
|
||||
bundleShortVersion: '1.0',
|
||||
bundleVersion: '1',
|
||||
dtPlatformName: 'iphoneos',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import assert from 'node:assert';
|
||||
import fg from 'fast-glob';
|
||||
import { vol } from 'memfs';
|
||||
import path from 'node:path';
|
||||
@@ -193,22 +194,53 @@ describe(resolveIosSigningOptionsAsync, () => {
|
||||
const signingOptions = await resolveIosSigningOptionsAsync({
|
||||
job,
|
||||
logger: mockLogger,
|
||||
tmpDir: '/tmp',
|
||||
});
|
||||
|
||||
expect(signingOptions).not.toBeNull();
|
||||
expect(signingOptions?.keychainPath).toEqual('/tmp/ios_keychain');
|
||||
expect(signingOptions?.signingIdentity).toEqual('Test App Certificate');
|
||||
expect(signingOptions?.provisioningProfile).toEqual({
|
||||
'com.example.testapp': '/tmp/ios_provisioning_profile',
|
||||
expect(signingOptions).toMatchObject({
|
||||
keychainPath: '/tmp/ios_keychain',
|
||||
signingIdentity: 'Test App Certificate',
|
||||
provisioningProfile: {
|
||||
'com.example.testapp': '/tmp/ios_provisioning_profile',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve zsign signing options without touching the keychain', async () => {
|
||||
const job = createTestIosJob();
|
||||
const tmpDir = '/tmp';
|
||||
vol.mkdirSync(tmpDir, { recursive: true });
|
||||
const credentialsManagerSpy = jest.spyOn(IosCredentialsManager.prototype, 'prepare');
|
||||
|
||||
const signingOptions = await resolveIosSigningOptionsAsync({
|
||||
job,
|
||||
logger: mockLogger,
|
||||
backend: 'zsign',
|
||||
tmpDir,
|
||||
});
|
||||
|
||||
expect(credentialsManagerSpy).not.toHaveBeenCalled();
|
||||
expect(signingOptions).toMatchObject({
|
||||
backend: 'zsign',
|
||||
keyPassword: job.secrets?.buildCredentials?.['testapp'].distributionCertificate.password,
|
||||
});
|
||||
assert(signingOptions?.backend === 'zsign');
|
||||
expect(signingOptions.certificatePath).toMatch(/\/tmp\/dist-cert-.*\.p12/);
|
||||
expect(vol.existsSync(signingOptions.certificatePath)).toBe(true);
|
||||
assert(typeof signingOptions.provisioningProfile === 'object');
|
||||
const profilePath = signingOptions.provisioningProfile['testapp'];
|
||||
expect(profilePath).toMatch(/\/tmp\/profile-testapp-.*\.mobileprovision/);
|
||||
expect(vol.existsSync(profilePath)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return undefined if no build credentials are provided', async () => {
|
||||
// @ts-expect-error: createTestIosJob does not support buildCredentials as null
|
||||
const job = createTestIosJob({ buildCredentials: null });
|
||||
const signingOptions = await resolveIosSigningOptionsAsync({
|
||||
job,
|
||||
logger: mockLogger,
|
||||
tmpDir: '/tmp',
|
||||
});
|
||||
expect(signingOptions).toBeUndefined();
|
||||
});
|
||||
@@ -219,6 +251,7 @@ describe(resolveIosSigningOptionsAsync, () => {
|
||||
const signingOptions = await resolveIosSigningOptionsAsync({
|
||||
job,
|
||||
logger: mockLogger,
|
||||
tmpDir: '/tmp',
|
||||
});
|
||||
expect(signingOptions).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import fs from 'fs';
|
||||
import fetch from 'node-fetch';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { compressCacheAsync } from '../saveCache';
|
||||
import { compressCacheAsync, uploadCacheAsync } from '../saveCache';
|
||||
import { decompressCacheAsync } from '../restoreCache';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return { info: jest.fn(), warn: jest.fn(), error: jest.fn() } as unknown as bunyan;
|
||||
}
|
||||
@@ -81,3 +87,71 @@ describe('cache compress/decompress round trip', () => {
|
||||
expect(Math.floor(restoredStat.mtimeMs / 1000)).toBe(Math.floor(mtime.getTime() / 1000));
|
||||
});
|
||||
});
|
||||
|
||||
describe(uploadCacheAsync, () => {
|
||||
afterEach(() => {
|
||||
jest.mocked(fetch).mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['normal upload', undefined, false],
|
||||
['forced upload', true, true],
|
||||
] as const)('sends force for %s', async (_name, force, expectedForce) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'upload-cache-test-'));
|
||||
const archivePath = path.join(tempDir, 'cache.tar.gz');
|
||||
await fs.promises.writeFile(archivePath, 'cache archive');
|
||||
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
headers: {},
|
||||
url: 'https://storage.expo.test/cache',
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
.mockImplementationOnce(async (_url, request) => {
|
||||
const body = request!.body as NodeJS.ReadableStream & AsyncIterable<Buffer>;
|
||||
for await (const _chunk of body) {
|
||||
// Consume the archive stream as a real upload would.
|
||||
}
|
||||
return new Response(undefined, { status: 200 });
|
||||
});
|
||||
|
||||
try {
|
||||
await uploadCacheAsync({
|
||||
logger: createLoggerMock(),
|
||||
jobId: 'build-id',
|
||||
expoApiServerURL: 'https://api.expo.test',
|
||||
robotAccessToken: 'robot-token',
|
||||
paths: ['/cache/path'],
|
||||
key: 'cache-key',
|
||||
archivePath,
|
||||
size: 13,
|
||||
platform: Platform.ANDROID,
|
||||
force,
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
const [uploadSessionUrl, uploadSessionRequest] = jest.mocked(fetch).mock.calls[0];
|
||||
expect(uploadSessionUrl.toString()).toBe(
|
||||
'https://api.expo.test/v2/turtle-builds/caches/upload-sessions'
|
||||
);
|
||||
expect(JSON.parse(uploadSessionRequest!.body as string)).toMatchObject({
|
||||
buildId: 'build-id',
|
||||
force: expectedForce,
|
||||
key: 'cache-key',
|
||||
size: 13,
|
||||
});
|
||||
expect(jest.mocked(fetch).mock.calls[1][0].toString()).toBe(
|
||||
'https://storage.expo.test/cache'
|
||||
);
|
||||
} finally {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
+20
-1
@@ -14,6 +14,7 @@ import {
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
@@ -46,8 +47,8 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
getNgrokTunnelDomainOrThrow: jest.fn(),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
spawnDetached: jest.fn(),
|
||||
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
startServeSimWithTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
@@ -58,6 +59,7 @@ const EXPECTED_EVENT_LOG_PATH = path.join(ARGENT_STATE_DIR, 'tool-server-events.
|
||||
|
||||
const mockStopAsync = jest.fn();
|
||||
const mockTunnelStopAsync = jest.fn();
|
||||
const mockPreviewStopAsync = jest.fn();
|
||||
|
||||
describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
beforeEach(async () => {
|
||||
@@ -68,6 +70,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
jest.mocked(pollArgentArtifactsForUploadAsync).mockResolvedValue(undefined);
|
||||
mockStopAsync.mockResolvedValue(undefined);
|
||||
mockTunnelStopAsync.mockResolvedValue(undefined);
|
||||
mockPreviewStopAsync.mockResolvedValue(undefined);
|
||||
jest.mocked(startArgentEventCollectionAsync).mockResolvedValue({
|
||||
stopAsync: mockStopAsync,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
@@ -86,6 +89,10 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
url: 'https://argent-abc.tunnel.example.com',
|
||||
stopAsync: mockTunnelStopAsync,
|
||||
});
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewUrl: 'https://web-preview.tunnel.example.com',
|
||||
stopAsync: mockPreviewStopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
|
||||
@@ -151,5 +158,17 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
|
||||
);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
webPreviewUrl: 'https://web-preview.tunnel.example.com',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, type BuildStepContext, type BuildStepEnv } from '@expo/steps';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
|
||||
|
||||
jest.mock('../../utils/remoteDeviceRunSession');
|
||||
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const env = {} as BuildStepEnv;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const stopAsync = jest.fn();
|
||||
|
||||
async function runAsync(runtimePlatform: BuildRuntimePlatform): Promise<void> {
|
||||
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx);
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: '1.2.3' },
|
||||
max_duration_seconds: { value: 120 },
|
||||
},
|
||||
outputs: {},
|
||||
env,
|
||||
} as never
|
||||
);
|
||||
}
|
||||
|
||||
describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('device-run-session-id');
|
||||
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewUrl: 'https://web-preview.example.test',
|
||||
stopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
stopAsync.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[BuildRuntimePlatform.DARWIN, true],
|
||||
[BuildRuntimePlatform.LINUX, false],
|
||||
])('starts the web preview for %s', async (runtimePlatform, selectsXcode) => {
|
||||
await runAsync(runtimePlatform);
|
||||
|
||||
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(selectsXcode ? 1 : 0);
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: 'tunnel.example.com',
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 60_000,
|
||||
packageVersion: '1.2.3',
|
||||
});
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
remoteConfig: { previewUrl: 'https://web-preview.example.test' },
|
||||
logger,
|
||||
});
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
logger,
|
||||
maxDurationSeconds: 120,
|
||||
signal: undefined,
|
||||
});
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -8,15 +8,19 @@ import {
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import { Client } from '@urql/core';
|
||||
import contentDisposition from 'content-disposition';
|
||||
import { glob } from 'fast-glob';
|
||||
import { graphql } from 'gql.tada';
|
||||
import fetch from 'node-fetch';
|
||||
import assert from 'node:assert';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import stream from 'stream';
|
||||
import { promisify } from 'util';
|
||||
import { z } from 'zod';
|
||||
import bplistParser from 'bplist-parser';
|
||||
import plist from 'plist';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
@@ -26,6 +30,10 @@ import { pluralize } from '../../utils/strings';
|
||||
|
||||
const streamPipeline = promisify(stream.pipeline);
|
||||
|
||||
type DownloadBuildSource =
|
||||
| { buildId: string; applicationArchiveUrl?: never }
|
||||
| { buildId?: never; applicationArchiveUrl: string };
|
||||
|
||||
const BUILD_BY_ID_QUERY = graphql(`
|
||||
query DownloadBuildByIdQuery($buildId: ID!) {
|
||||
builds {
|
||||
@@ -49,7 +57,12 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'build_id',
|
||||
required: true,
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'application_archive_url',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
@@ -70,12 +83,39 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
|
||||
const extensions = z.array(z.string()).parse(inputs.extensions.value);
|
||||
logger.info(`Expected extensions: [${extensions.join(', ')}]`);
|
||||
const buildId = z.string().uuid().parse(inputs.build_id.value);
|
||||
logger.info(`Downloading build ${buildId}...`);
|
||||
const buildId = inputs.build_id.value
|
||||
? z.string().uuid().parse(inputs.build_id.value)
|
||||
: undefined;
|
||||
const applicationArchiveUrl = inputs.application_archive_url.value
|
||||
? parseHttpApplicationArchiveUrl(inputs.application_archive_url.value)
|
||||
: undefined;
|
||||
|
||||
let source: DownloadBuildSource;
|
||||
if (buildId) {
|
||||
if (applicationArchiveUrl) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
|
||||
'Pass only one of build_id or application_archive_url.'
|
||||
);
|
||||
}
|
||||
source = { buildId };
|
||||
} else {
|
||||
if (!applicationArchiveUrl) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
|
||||
'Pass build_id or application_archive_url.'
|
||||
);
|
||||
}
|
||||
source = { applicationArchiveUrl };
|
||||
}
|
||||
|
||||
logger.info(
|
||||
buildId ? `Downloading build ${buildId}...` : `Downloading application archive...`
|
||||
);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger,
|
||||
buildId,
|
||||
...source,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
robotAccessToken: stepsCtx.global.staticContext.job.secrets?.robotAccessToken ?? null,
|
||||
extensions,
|
||||
@@ -115,39 +155,50 @@ async function fetchApplicationArchiveUrlAsync({
|
||||
return applicationArchiveUrl;
|
||||
}
|
||||
|
||||
export async function downloadBuildAsync({
|
||||
logger,
|
||||
buildId,
|
||||
graphqlClient,
|
||||
robotAccessToken,
|
||||
extensions,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
buildId: string;
|
||||
graphqlClient: Client;
|
||||
robotAccessToken: string | null;
|
||||
extensions: string[];
|
||||
}): Promise<{ artifactPath: string }> {
|
||||
export async function downloadBuildAsync(
|
||||
params: DownloadBuildSource & {
|
||||
logger: bunyan;
|
||||
graphqlClient: Client;
|
||||
robotAccessToken: string | null;
|
||||
extensions: string[];
|
||||
}
|
||||
): Promise<{ artifactPath: string }> {
|
||||
const { logger, graphqlClient, robotAccessToken, extensions } = params;
|
||||
|
||||
let downloadUrl: string;
|
||||
let headers: { Authorization: string } | undefined;
|
||||
if (params.applicationArchiveUrl) {
|
||||
if (params.buildId) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
|
||||
'Pass only one of buildId or applicationArchiveUrl.'
|
||||
);
|
||||
}
|
||||
downloadUrl = parseHttpApplicationArchiveUrl(params.applicationArchiveUrl);
|
||||
headers = undefined;
|
||||
} else if (params.buildId) {
|
||||
const buildId = z.string().uuid().parse(params.buildId);
|
||||
downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient });
|
||||
headers = robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined;
|
||||
} else {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
|
||||
'Pass buildId or applicationArchiveUrl.'
|
||||
);
|
||||
}
|
||||
|
||||
const downloadDestinationDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'download_build-downloaded-')
|
||||
);
|
||||
|
||||
const downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient });
|
||||
|
||||
const response = await retryOnDNSFailure(fetch)(downloadUrl, {
|
||||
headers: robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined,
|
||||
});
|
||||
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
const textResult = await asyncResult(response.text());
|
||||
throw new Error(`Unexpected response from server (${response.status}): ${textResult.value}`);
|
||||
}
|
||||
|
||||
// URL may contain percent-encoded characters, e.g. my%20file.apk
|
||||
// this replaces all non-alphanumeric characters (excluding dot) with underscore
|
||||
const archiveFilename = path
|
||||
.basename(new URL(response.url).pathname)
|
||||
.replace(/([^a-z0-9.-]+)/gi, '_');
|
||||
const archiveFilename = resolveArchiveFilename({ response, extensions });
|
||||
const archivePath = path.join(downloadDestinationDirectory, archiveFilename);
|
||||
|
||||
await streamPipeline(response.body, fs.createWriteStream(archivePath));
|
||||
@@ -177,6 +228,18 @@ export async function downloadBuildAsync({
|
||||
onlyFiles: false,
|
||||
onlyDirectories: false,
|
||||
});
|
||||
let matchingFilesRoot = extractionDirectory;
|
||||
|
||||
if (
|
||||
matchingFiles.length === 0 &&
|
||||
extensions.includes('app') &&
|
||||
(await isIosAppBundleAsync(extractionDirectory))
|
||||
) {
|
||||
const appBundlePath = `${extractionDirectory}.app`;
|
||||
await fs.promises.rename(extractionDirectory, appBundlePath);
|
||||
matchingFiles.push(appBundlePath);
|
||||
matchingFilesRoot = path.dirname(appBundlePath);
|
||||
}
|
||||
|
||||
if (matchingFiles.length === 0) {
|
||||
throw new UserError(
|
||||
@@ -189,8 +252,68 @@ export async function downloadBuildAsync({
|
||||
`Found ${matchingFiles.length} matching ${pluralize(
|
||||
matchingFiles.length,
|
||||
'entry'
|
||||
)}:\n${matchingFiles.map(f => `- ${path.relative(extractionDirectory, f)}`).join('\n')}`
|
||||
)}:\n${matchingFiles.map(f => `- ${path.relative(matchingFilesRoot, f)}`).join('\n')}`
|
||||
);
|
||||
|
||||
return { artifactPath: matchingFiles[0] };
|
||||
}
|
||||
|
||||
async function isIosAppBundleAsync(directory: string): Promise<boolean> {
|
||||
try {
|
||||
const infoPlist = await fs.promises.readFile(path.join(directory, 'Info.plist'));
|
||||
const isBinaryPlist = infoPlist.subarray(0, 8).toString('ascii') === 'bplist00';
|
||||
const parsedInfoPlist = (
|
||||
isBinaryPlist
|
||||
? bplistParser.parseBuffer(infoPlist)[0]
|
||||
: plist.parse(infoPlist.toString('utf8'))
|
||||
) as Record<string, unknown> | undefined;
|
||||
|
||||
return parsedInfoPlist?.CFBundlePackageType === 'APPL';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseHttpApplicationArchiveUrl(value: unknown): string {
|
||||
try {
|
||||
const applicationArchiveUrl = z.string().parse(value);
|
||||
const parsedUrl = new URL(applicationArchiveUrl);
|
||||
assert(parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:');
|
||||
return applicationArchiveUrl;
|
||||
} catch {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_INVALID_APPLICATION_ARCHIVE_URL',
|
||||
'application_archive_url must be a valid HTTP or HTTPS URL.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveArchiveFilename({
|
||||
response,
|
||||
extensions,
|
||||
}: {
|
||||
response: Awaited<ReturnType<typeof fetch>>;
|
||||
extensions: string[];
|
||||
}): string {
|
||||
const contentDispositionHeader = response.headers.get('content-disposition');
|
||||
let headerFilename: string | undefined;
|
||||
if (contentDispositionHeader) {
|
||||
try {
|
||||
headerFilename = contentDisposition.parse(contentDispositionHeader).parameters.filename;
|
||||
} catch {
|
||||
// Ignore malformed Content-Disposition headers and fall back to the response URL.
|
||||
}
|
||||
}
|
||||
|
||||
const urlFilename = path.basename(new URL(response.url).pathname);
|
||||
let archiveFilename = path.basename(headerFilename ?? urlFilename ?? '');
|
||||
if (!archiveFilename || archiveFilename === '.' || archiveFilename === '..') {
|
||||
archiveFilename = 'application';
|
||||
}
|
||||
if (!path.extname(archiveFilename) && extensions.length === 1) {
|
||||
archiveFilename = `${archiveFilename}.${extensions[0]}`;
|
||||
}
|
||||
|
||||
// URL and header filenames may contain percent-encoded or unsafe filesystem characters.
|
||||
return archiveFilename.replace(/([^a-z0-9.-]+)/gi, '_');
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepEnv,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
export function createInstallBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'install_build',
|
||||
name: 'Install build',
|
||||
__metricsId: 'eas/install_build',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'artifact_path',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'application_identifier',
|
||||
required: true,
|
||||
}),
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'activity_name',
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
fn: async ({ global, logger }, { inputs, outputs, env }) => {
|
||||
const artifactPath = z.string().min(1).parse(inputs.artifact_path.value);
|
||||
const { applicationIdentifier, activityName } = await installBuildAsync({
|
||||
artifactPath,
|
||||
runtimePlatform: global.runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
outputs.application_identifier.set(applicationIdentifier);
|
||||
if (activityName) {
|
||||
outputs.activity_name.set(activityName);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function installBuildAsync({
|
||||
artifactPath,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
artifactPath: string;
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<{ applicationIdentifier: string; activityName?: string }> {
|
||||
const artifactStat = await fs.promises.stat(artifactPath).catch(err => {
|
||||
throw new UserError(
|
||||
'EAS_INSTALL_BUILD_INVALID_ARTIFACT',
|
||||
`Build artifact does not exist at ${artifactPath}.`,
|
||||
{ cause: err }
|
||||
);
|
||||
});
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
if (path.extname(artifactPath) !== '.app' || !artifactStat.isDirectory()) {
|
||||
throw new UserError(
|
||||
'EAS_INSTALL_BUILD_INVALID_ARTIFACT',
|
||||
'iOS Simulator sessions require a .app build artifact.'
|
||||
);
|
||||
}
|
||||
|
||||
const infoPlistPath = path.join(artifactPath, 'Info.plist');
|
||||
const { stdout } = await spawn(
|
||||
'plutil',
|
||||
['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', infoPlistPath],
|
||||
{ stdio: 'pipe', env }
|
||||
);
|
||||
const applicationIdentifier = stdout.trim();
|
||||
if (!applicationIdentifier) {
|
||||
throw new UserError(
|
||||
'EAS_INSTALL_BUILD_MISSING_IDENTIFIER',
|
||||
`Could not read CFBundleIdentifier from ${infoPlistPath}.`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Installing ${artifactPath} on the iOS Simulator.`);
|
||||
await spawn('xcrun', ['simctl', 'install', 'booted', artifactPath], { env, logger });
|
||||
return { applicationIdentifier };
|
||||
}
|
||||
|
||||
if (path.extname(artifactPath) !== '.apk' || !artifactStat.isFile()) {
|
||||
throw new UserError(
|
||||
'EAS_INSTALL_BUILD_INVALID_ARTIFACT',
|
||||
'Android Emulator sessions require an .apk build artifact.'
|
||||
);
|
||||
}
|
||||
|
||||
const { stdout } = await spawn('aapt2', ['dump', 'badging', artifactPath], {
|
||||
stdio: 'pipe',
|
||||
env,
|
||||
});
|
||||
const applicationIdentifier = stdout.match(/package: name='([^']+)'/)?.[1];
|
||||
const activityName = stdout.match(/launchable-activity: name='([^']+)'/)?.[1];
|
||||
if (!applicationIdentifier) {
|
||||
throw new UserError(
|
||||
'EAS_INSTALL_BUILD_MISSING_IDENTIFIER',
|
||||
`Could not read an Android application identifier from ${artifactPath}.`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Installing ${artifactPath} on the Android Emulator.`);
|
||||
await spawn('adb', ['install', '-r', artifactPath], { env, logger });
|
||||
return { applicationIdentifier, activityName };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
@@ -19,6 +20,11 @@ import semver from 'semver';
|
||||
|
||||
import { MaestroBackend, resolveMaestroBackend } from './maestroBackend';
|
||||
import { Datadog } from '../../datadog';
|
||||
import { getXcodeVersionAsync } from '../../ios/xcode';
|
||||
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
|
||||
import { getProxiedDownloadUrl } from '../../utils/download';
|
||||
|
||||
const MAESTRO_RUNNER_WDA_CACHE_DOWNLOAD_TIMEOUT_MS = 20_000;
|
||||
|
||||
export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -148,11 +154,13 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
(requestedVersion === undefined &&
|
||||
(!currentMaestroRunnerVersion || semver.gt(currentMaestroRunnerVersion, '1.1.15'))))
|
||||
) {
|
||||
const xcodeVersion = await getXcodeVersion({ env });
|
||||
// `getXcodeVersionAsync` returns the raw version (e.g. "16.4"), which
|
||||
// `semver.coerce` normalizes (e.g. "16.4.0") for comparison and display.
|
||||
const xcodeVersion = semver.coerce(await getXcodeVersionAsync({ env }));
|
||||
|
||||
// maestro-runner 1.1.16 added `arch` to its xcodebuild destination. Xcode versions
|
||||
// below 26 reject that option, so use the last compatible maestro-runner version.
|
||||
if (semver.lt(xcodeVersion, '26.0.0')) {
|
||||
if (xcodeVersion && semver.lt(xcodeVersion, '26.0.0')) {
|
||||
if (requestedMaestroRunnerVersion) {
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_INVALID_INPUT',
|
||||
@@ -190,6 +198,13 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
throw new Error(`Failed to ensure ${backend} is installed.`);
|
||||
}
|
||||
|
||||
if (backend === 'maestro-runner' && global.runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await installMaestroRunnerWdaCache({
|
||||
logger,
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`${backend} ${maestroVersionResult.value} is ready.`);
|
||||
outputs.maestro_version.set(maestroVersionResult.value);
|
||||
|
||||
@@ -201,19 +216,140 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
});
|
||||
}
|
||||
|
||||
async function getXcodeVersion({ env }: { env: BuildStepEnv }): Promise<string> {
|
||||
let stdout: string;
|
||||
try {
|
||||
({ stdout } = await spawn('xcodebuild', ['-version'], { stdio: 'pipe', env }));
|
||||
} catch (error) {
|
||||
throw new SystemError('Failed to get Xcode version', { cause: error });
|
||||
async function installMaestroRunnerWdaCache({
|
||||
logger,
|
||||
env,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<void> {
|
||||
if (!env.HOME) {
|
||||
logger.warn(
|
||||
'Skipping the prebuilt WebDriverAgent cache because the $HOME environment variable is empty.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const maestroRunnerHome = path.join(env.HOME, '.maestro-runner');
|
||||
|
||||
const xcodeVersion = semver.coerce(/^Xcode\s+(\S+)/m.exec(stdout)?.[1])?.version;
|
||||
if (!xcodeVersion) {
|
||||
throw new SystemError(`Failed to parse Xcode version from xcodebuild output: ${stdout.trim()}`);
|
||||
try {
|
||||
const wdaVersion = await getMaestroRunnerWdaVersion({ maestroRunnerHome });
|
||||
if (!wdaVersion) {
|
||||
logger.info(
|
||||
'Skipping the prebuilt WebDriverAgent cache because the installed WDA version is unknown.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const xcodeVersion = await getXcodeVersionAsync({ env });
|
||||
const iosRuntimes = await IosSimulatorUtils.getAvailableRuntimesAsync({ env });
|
||||
const iosRuntimeIdentifierPrefix = 'com.apple.CoreSimulator.SimRuntime.iOS-';
|
||||
// maestro-runner derives its WDA cache key from the runtime identifier, not from the runtime
|
||||
// version. These values can differ for patched runtimes, for example the iOS-26-3 identifier
|
||||
// can report version 26.3.1.
|
||||
const iosRuntimeVersions = [
|
||||
...new Set(
|
||||
iosRuntimes.map(runtime =>
|
||||
runtime.identifier.slice(iosRuntimeIdentifierPrefix.length).replaceAll('-', '.')
|
||||
)
|
||||
),
|
||||
];
|
||||
if (iosRuntimeVersions.length === 0) {
|
||||
logger.info(
|
||||
'Skipping the prebuilt WebDriverAgent cache because no iOS runtime is available.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const tempDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'install_maestro_runner_wda_cache')
|
||||
);
|
||||
try {
|
||||
const archivePath = path.join(tempDirectory, 'wda-cache.tar.gz');
|
||||
const directArchiveUrl =
|
||||
`https://storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/` +
|
||||
`xcode-${encodeURIComponent(xcodeVersion)}-wda-${encodeURIComponent(wdaVersion)}.tar.gz`;
|
||||
const proxiedArchiveUrl = getProxiedDownloadUrl({
|
||||
directUrl: directArchiveUrl,
|
||||
proxyBaseUrl: env.EAS_BUILD_COCOAPODS_CACHE_URL,
|
||||
});
|
||||
|
||||
logger.info(`Downloading the prebuilt WebDriverAgent cache for Xcode ${xcodeVersion}`);
|
||||
if (proxiedArchiveUrl) {
|
||||
try {
|
||||
await downloadFile(proxiedArchiveUrl, archivePath, {
|
||||
retry: 3,
|
||||
timeout: MAESTRO_RUNNER_WDA_CACHE_DOWNLOAD_TIMEOUT_MS,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
{ err },
|
||||
'Failed to download the prebuilt WebDriverAgent cache via the proxy; falling back to the direct URL.'
|
||||
);
|
||||
await downloadFile(directArchiveUrl, archivePath, {
|
||||
retry: 3,
|
||||
timeout: MAESTRO_RUNNER_WDA_CACHE_DOWNLOAD_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await downloadFile(directArchiveUrl, archivePath, {
|
||||
retry: 3,
|
||||
timeout: MAESTRO_RUNNER_WDA_CACHE_DOWNLOAD_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
await fs.promises.mkdir(maestroRunnerHome, { recursive: true });
|
||||
await spawn('tar', ['-xzf', archivePath, '-C', maestroRunnerHome], { logger, env });
|
||||
|
||||
const genericProductsDirectory = path.join(
|
||||
maestroRunnerHome,
|
||||
'cache',
|
||||
'wda-builds',
|
||||
'generic',
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products'
|
||||
);
|
||||
for (const runtimeVersion of iosRuntimeVersions) {
|
||||
const productsDirectory = path.join(
|
||||
maestroRunnerHome,
|
||||
'cache',
|
||||
'wda-builds',
|
||||
`sim-ios${runtimeVersion}-iphone`,
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products'
|
||||
);
|
||||
await fs.promises.cp(genericProductsDirectory, productsDirectory, { recursive: true });
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Installed the prebuilt WebDriverAgent cache for iOS ${iosRuntimeVersions.join(', ')}.`
|
||||
);
|
||||
} finally {
|
||||
await fs.promises.rm(tempDirectory, { force: true, recursive: true });
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Failed to install the prebuilt WebDriverAgent cache. maestro-runner will build WebDriverAgent when it is needed.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function getMaestroRunnerWdaVersion({
|
||||
maestroRunnerHome,
|
||||
}: {
|
||||
maestroRunnerHome: string;
|
||||
}): Promise<string | null> {
|
||||
try {
|
||||
const packageJson = JSON.parse(
|
||||
await fs.promises.readFile(
|
||||
path.join(maestroRunnerHome, 'drivers', 'ios', 'WebDriverAgent', 'package.json'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
return typeof packageJson.version === 'string' ? packageJson.version : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return xcodeVersion;
|
||||
}
|
||||
|
||||
async function getMaestroVersion({
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
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';
|
||||
|
||||
const IOS_URL_SCHEME_APPROVAL_DOMAIN = 'com.apple.launchservices.schemeapproval';
|
||||
const IOS_URL_SCHEME_APPROVAL_KEY_PREFIX = 'com.apple.CoreSimulator.CoreSimulatorBridge-->';
|
||||
|
||||
export function createLaunchApplicationFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
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) {
|
||||
await preapproveIosUrlSchemeAsync({ applicationIdentifier, openUrl, env, logger });
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function preapproveIosUrlSchemeAsync({
|
||||
applicationIdentifier,
|
||||
openUrl,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
applicationIdentifier: string;
|
||||
openUrl: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const urlScheme = new URL(openUrl).protocol.slice(0, -1);
|
||||
if (urlScheme === 'http' || urlScheme === 'https') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await spawn(
|
||||
'xcrun',
|
||||
[
|
||||
'simctl',
|
||||
'spawn',
|
||||
'booted',
|
||||
'defaults',
|
||||
'write',
|
||||
IOS_URL_SCHEME_APPROVAL_DOMAIN,
|
||||
`${IOS_URL_SCHEME_APPROVAL_KEY_PREFIX}${urlScheme}`,
|
||||
'-string',
|
||||
applicationIdentifier,
|
||||
],
|
||||
{ env, logger }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
`Could not preapprove the ${urlScheme} URL scheme for ${applicationIdentifier}. Opening the URL anyway; the Simulator might require manual confirmation.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function logApplicationLaunch(
|
||||
logger: bunyan,
|
||||
applicationIdentifier: string,
|
||||
launchArgs: string[]
|
||||
): void {
|
||||
const argumentsDescription =
|
||||
launchArgs.length > 0 ? ` with arguments ${JSON.stringify(launchArgs)}` : '';
|
||||
logger.info(`Launching ${applicationIdentifier}${argumentsDescription}.`);
|
||||
}
|
||||
|
||||
function parseNonEmptyStringInput(value: unknown, inputName: string): string {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
`Input "${inputName}" must be a non-empty string. Pass the "${inputName}" output from eas/install_build.`
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseLaunchArgsInput(value: unknown): string[] {
|
||||
if (value === undefined) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(value) || !value.every(argument => typeof argument === 'string')) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
'Input "launch_args" must be an array of strings.'
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseOpenUrlInput(value: unknown): string {
|
||||
const openUrl = parseNonEmptyStringInput(value, 'open_url');
|
||||
if (!URL.canParse(openUrl)) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
'Input "open_url" must be a valid URL.'
|
||||
);
|
||||
}
|
||||
return openUrl;
|
||||
}
|
||||
@@ -16,6 +16,10 @@ export type IpaInfo = {
|
||||
bundleIdentifier: string;
|
||||
bundleShortVersion: string;
|
||||
bundleVersion: string;
|
||||
// `DTPlatformName` from the bundle's Info.plist (e.g. `iphoneos`, `appletvos`,
|
||||
// `macosx`, `xros`), or null when absent. Used to pick the App Store Connect
|
||||
// platform for the upload. See `AscApiUtils.ascPlatformFromDtPlatformName`.
|
||||
dtPlatformName: string | null;
|
||||
};
|
||||
|
||||
const INFO_PLIST_PATH_REGEXP = /^Payload\/[^/]+\.app\/Info\.plist$/;
|
||||
@@ -91,10 +95,14 @@ export async function readIpaInfoAsync(ipaPath: string): Promise<IpaInfo> {
|
||||
);
|
||||
}
|
||||
|
||||
const dtPlatformName =
|
||||
typeof infoPlist.DTPlatformName === 'string' ? infoPlist.DTPlatformName : null;
|
||||
|
||||
return {
|
||||
bundleIdentifier,
|
||||
bundleShortVersion,
|
||||
bundleVersion,
|
||||
dtPlatformName,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof UserError) {
|
||||
|
||||
@@ -62,6 +62,12 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'ios_signing_backend',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
allowedValues: ['fastlane', 'zsign'],
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'repack_version',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
@@ -144,12 +150,14 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
iosSigningOptions: await resolveIosSigningOptionsAsync({
|
||||
job: stepsCtx.global.staticContext.job,
|
||||
logger: stepsCtx.logger,
|
||||
backend: inputs.ios_signing_backend.value as 'fastlane' | 'zsign' | undefined,
|
||||
useAppEntitlements: inputs.ios_signing_use_source_app_entitlements.value as
|
||||
| boolean
|
||||
| undefined,
|
||||
entitlementsPath: inputs.ios_signing_app_entitlements_path.value as
|
||||
| string
|
||||
| undefined,
|
||||
tmpDir,
|
||||
}),
|
||||
logger: stepsCtx.logger,
|
||||
spawnAsync: repackSpawnAsync,
|
||||
@@ -297,24 +305,51 @@ export async function resolveAndroidSigningOptionsAsync({
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves iOS signing options from the job secrets.
|
||||
* Resolves iOS signing options from the job secrets, dispatching on the
|
||||
* requested signing backend.
|
||||
*/
|
||||
export async function resolveIosSigningOptionsAsync({
|
||||
job,
|
||||
logger,
|
||||
backend,
|
||||
useAppEntitlements,
|
||||
entitlementsPath,
|
||||
tmpDir,
|
||||
}: {
|
||||
job: Job;
|
||||
logger: bunyan;
|
||||
backend?: 'fastlane' | 'zsign';
|
||||
useAppEntitlements?: boolean;
|
||||
entitlementsPath?: string;
|
||||
tmpDir: string;
|
||||
}): Promise<IosSigningOptions | undefined> {
|
||||
const iosJob = job as Ios.Job;
|
||||
const buildCredentials = iosJob.secrets?.buildCredentials;
|
||||
if (iosJob.simulator || buildCredentials == null) {
|
||||
return undefined;
|
||||
}
|
||||
const commonOptions = { buildCredentials, logger, useAppEntitlements, entitlementsPath };
|
||||
return backend === 'zsign'
|
||||
? await createIosZsignOptionsAsync({ ...commonOptions, tmpDir })
|
||||
: await createIosFastlaneOptionsAsync(commonOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates signing options for the fastlane backend: certificates are imported
|
||||
* into a temporary keychain and provisioning profiles are parsed with the
|
||||
* macOS `security` tool.
|
||||
*/
|
||||
async function createIosFastlaneOptionsAsync({
|
||||
buildCredentials,
|
||||
logger,
|
||||
useAppEntitlements,
|
||||
entitlementsPath,
|
||||
}: {
|
||||
buildCredentials: Ios.BuildCredentials;
|
||||
logger: bunyan;
|
||||
useAppEntitlements?: boolean;
|
||||
entitlementsPath?: string;
|
||||
}): Promise<IosSigningOptions> {
|
||||
const credentialsManager = new IosCredentialsManager(buildCredentials);
|
||||
const credentials = await credentialsManager.prepare(logger);
|
||||
|
||||
@@ -330,3 +365,53 @@ export async function resolveIosSigningOptionsAsync({
|
||||
entitlementsPath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates signing options for the zsign backend. The distribution certificate
|
||||
* secret is already a PKCS#12 file, so it goes to disk as-is together with the
|
||||
* provisioning profiles.
|
||||
*/
|
||||
async function createIosZsignOptionsAsync({
|
||||
buildCredentials,
|
||||
logger,
|
||||
useAppEntitlements,
|
||||
entitlementsPath,
|
||||
tmpDir,
|
||||
}: {
|
||||
buildCredentials: Ios.BuildCredentials;
|
||||
logger: bunyan;
|
||||
useAppEntitlements?: boolean;
|
||||
entitlementsPath?: string;
|
||||
tmpDir: string;
|
||||
}): Promise<IosSigningOptions> {
|
||||
const targets = Object.entries(buildCredentials);
|
||||
const [targetName, targetCredentials] = targets[0];
|
||||
logger.info(`Using the distribution certificate from target '${targetName}' for zsign`);
|
||||
|
||||
const certificatePath = path.join(tmpDir, `dist-cert-${randomUUID()}.p12`);
|
||||
await fs.promises.writeFile(
|
||||
certificatePath,
|
||||
new Uint8Array(Buffer.from(targetCredentials.distributionCertificate.dataBase64, 'base64'))
|
||||
);
|
||||
|
||||
// zsign matches profiles to bundles by the app-id suffix itself, so the
|
||||
// record keys are informational only.
|
||||
const provisioningProfile: Record<string, string> = {};
|
||||
for (const [target, credentials] of targets) {
|
||||
const profilePath = path.join(tmpDir, `profile-${target}-${randomUUID()}.mobileprovision`);
|
||||
await fs.promises.writeFile(
|
||||
profilePath,
|
||||
new Uint8Array(Buffer.from(credentials.provisioningProfileBase64, 'base64'))
|
||||
);
|
||||
provisioningProfile[target] = profilePath;
|
||||
}
|
||||
|
||||
return {
|
||||
backend: 'zsign',
|
||||
certificatePath,
|
||||
keyPassword: targetCredentials.distributionCertificate.password,
|
||||
provisioningProfile,
|
||||
useAppEntitlements,
|
||||
entitlementsPath,
|
||||
};
|
||||
}
|
||||
@@ -15,10 +15,16 @@ 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 {
|
||||
getCocoapodsCachePaths,
|
||||
resolveCocoapodsCacheKeyAsync,
|
||||
restoreCocoapodsCacheArchiveAsync,
|
||||
} from '../../utils/cocoapodsCache';
|
||||
import { Datadog } from '../../datadog';
|
||||
import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
|
||||
import { TurtleFetchError, turtleFetch } from '../../utils/turtleFetch';
|
||||
@@ -35,6 +41,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 +59,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,
|
||||
});
|
||||
@@ -63,6 +84,13 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
} else {
|
||||
await restoreCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -92,13 +120,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 +164,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 +175,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 +203,7 @@ export async function restoreCcacheAsync({
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
paths: [cachePath],
|
||||
platform,
|
||||
platform: target.platform,
|
||||
});
|
||||
await decompressCacheAsync({
|
||||
archivePath,
|
||||
@@ -190,6 +218,67 @@ export async function restoreCcacheAsync({
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
workingDirectory: string;
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
if (env.EAS_PODS_CACHE !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await spawnAsync('pod', ['--version'], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const { key, keyPrefix } = await resolveCocoapodsCacheKeyAsync(workingDirectory, stdout);
|
||||
logger.info(`Restoring CocoaPods cache key: ${key}`);
|
||||
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const robotAccessToken = nullthrows(
|
||||
secrets?.robotAccessToken,
|
||||
'Robot access token is required for cache operations'
|
||||
);
|
||||
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
const { podsDirectory } = getCocoapodsCachePaths(workingDirectory);
|
||||
|
||||
const { archivePath, matchedKey } = await downloadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
paths: [podsDirectory],
|
||||
key,
|
||||
keyPrefixes: [keyPrefix],
|
||||
platform: Platform.IOS,
|
||||
});
|
||||
|
||||
await restoreCocoapodsCacheArchiveAsync({ archivePath, workingDirectory });
|
||||
|
||||
const hitType = matchedKey === key ? 'direct_hit' : 'prefix_match';
|
||||
logger.info(
|
||||
`CocoaPods cache restored to ${podsDirectory} (${hitType === 'direct_hit' ? 'direct hit' : 'prefix match'})`
|
||||
);
|
||||
Datadog.log(`CocoaPods cache restored (${hitType})`, {
|
||||
event: 'cocoapods_cache_restored',
|
||||
cache_hit_type: hitType,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof TurtleFetchError && err.response?.status === 404) {
|
||||
logger.info('No CocoaPods cache found for this key');
|
||||
} else {
|
||||
logger.warn('Failed to restore CocoaPods cache: ', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreGradleCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
|
||||
@@ -15,7 +15,16 @@ 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 {
|
||||
compressCocoapodsCacheAsync,
|
||||
getCocoapodsCachePaths,
|
||||
resolveCocoapodsCacheKeyAsync,
|
||||
} from '../../utils/cocoapodsCache';
|
||||
import { generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
|
||||
|
||||
export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFunction {
|
||||
@@ -30,6 +39,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 +57,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,
|
||||
@@ -59,22 +83,88 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
} else {
|
||||
await saveCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
workingDirectory: string;
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
if (env.EAS_PODS_CACHE !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { podsDirectory, podfileLockPath } = getCocoapodsCachePaths(workingDirectory);
|
||||
try {
|
||||
await Promise.all([fs.promises.access(podsDirectory), fs.promises.access(podfileLockPath)]);
|
||||
} catch {
|
||||
logger.warn('No CocoaPods installation found, skipping cache save');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await spawnAsync('pod', ['--version'], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const { key } = await resolveCocoapodsCacheKeyAsync(workingDirectory, stdout);
|
||||
logger.info(`Saving CocoaPods cache key: ${key}`);
|
||||
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const robotAccessToken = nullthrows(
|
||||
secrets?.robotAccessToken,
|
||||
'Robot access token is required for cache operations'
|
||||
);
|
||||
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
|
||||
logger.info('Compressing CocoaPods cache...');
|
||||
const { archivePath } = await compressCocoapodsCacheAsync({ workingDirectory });
|
||||
const { size } = await fs.promises.stat(archivePath);
|
||||
logger.info(`CocoaPods cache archive size: ${formatBytes(size)}`);
|
||||
|
||||
await uploadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
archivePath,
|
||||
key,
|
||||
paths: [podsDirectory],
|
||||
size,
|
||||
platform: Platform.IOS,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to save CocoaPods cache');
|
||||
}
|
||||
}
|
||||
|
||||
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 +189,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 +233,7 @@ export async function saveCcacheAsync({
|
||||
key: cacheKey,
|
||||
paths: [cachePath],
|
||||
size,
|
||||
platform,
|
||||
platform: target.platform,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to save cache');
|
||||
|
||||
@@ -103,6 +103,7 @@ export async function uploadCacheAsync({
|
||||
archivePath,
|
||||
size,
|
||||
platform,
|
||||
force = false,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
jobId: string;
|
||||
@@ -113,6 +114,7 @@ export async function uploadCacheAsync({
|
||||
archivePath: string;
|
||||
size: number;
|
||||
platform: Platform | undefined;
|
||||
force?: boolean;
|
||||
}): Promise<void> {
|
||||
const routerURL = platform
|
||||
? 'v2/turtle-builds/caches/upload-sessions'
|
||||
@@ -127,12 +129,14 @@ export async function uploadCacheAsync({
|
||||
key,
|
||||
version: getCacheVersion(paths),
|
||||
size,
|
||||
force,
|
||||
})
|
||||
: JSON.stringify({
|
||||
jobRunId: jobId,
|
||||
key,
|
||||
version: getCacheVersion(paths),
|
||||
size,
|
||||
force,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
|
||||
@@ -23,8 +23,8 @@ import {
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
@@ -107,22 +107,19 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
const agentDeviceRemoteSessionUrl = agentDeviceTunnel.url;
|
||||
logger.info(`Tunnel is ready at ${agentDeviceRemoteSessionUrl}.`);
|
||||
|
||||
let serveSim: Awaited<ReturnType<typeof startServeSimWithTunnelAsync>> | undefined;
|
||||
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
|
||||
let eventCollection:
|
||||
| Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>>
|
||||
| undefined;
|
||||
try {
|
||||
// serve-sim is iOS-only — only launch it (and report a webPreviewUrl)
|
||||
// on Darwin. Android sessions go without a preview URL.
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
serveSim = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
logger.info(`Web preview URL: ${serveSim.previewUrl}`);
|
||||
}
|
||||
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
@@ -130,7 +127,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
remoteConfig: {
|
||||
agentDeviceRemoteSessionUrl,
|
||||
agentDeviceRemoteSessionToken: daemonToken,
|
||||
...(serveSim ? { webPreviewUrl: serveSim.previewUrl } : {}),
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
},
|
||||
logger,
|
||||
});
|
||||
@@ -163,8 +160,8 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
if (serveSim) {
|
||||
await serveSim.stopAsync();
|
||||
if (webPreview) {
|
||||
await webPreview.stopAsync();
|
||||
}
|
||||
await agentDeviceTunnel.stopAsync();
|
||||
if (eventCollection) {
|
||||
|
||||
@@ -26,8 +26,8 @@ import {
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
@@ -118,7 +118,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
logger,
|
||||
});
|
||||
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let serveSim: Awaited<ReturnType<typeof startServeSimWithTunnelAsync>> | undefined;
|
||||
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
|
||||
try {
|
||||
appiumTunnel = await startNgrokTunnelAsync({
|
||||
port: APPIUM_PORT,
|
||||
@@ -128,18 +128,15 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
logger,
|
||||
});
|
||||
|
||||
switch (runtimePlatform) {
|
||||
case BuildRuntimePlatform.DARWIN:
|
||||
serveSim = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
break;
|
||||
case BuildRuntimePlatform.LINUX:
|
||||
break;
|
||||
}
|
||||
// expo-device-hub has no serial-selection flag. Device run session workflows must expose
|
||||
// a single booted Android emulator so the Hub and Appium resolve the same device.
|
||||
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
@@ -151,7 +148,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
'appium:automationName': device.automationName,
|
||||
'appium:udid': device.udid,
|
||||
},
|
||||
...(serveSim ? { webPreviewUrl: serveSim.previewUrl } : {}),
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
},
|
||||
logger,
|
||||
});
|
||||
@@ -170,8 +167,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
if (serveSim) {
|
||||
await serveSim.stopAsync();
|
||||
if (webPreview) {
|
||||
await webPreview.stopAsync();
|
||||
}
|
||||
if (appiumTunnel) {
|
||||
await appiumTunnel.stopAsync();
|
||||
|
||||
@@ -26,8 +26,8 @@ import {
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
@@ -186,7 +186,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
});
|
||||
|
||||
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let serveSim: Awaited<ReturnType<typeof startServeSimWithTunnelAsync>> | undefined;
|
||||
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
|
||||
try {
|
||||
toolsTunnel = await startNgrokTunnelAsync({
|
||||
port: toolServerPort,
|
||||
@@ -199,18 +199,14 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
const publicToolsUrl = toolsTunnel.url;
|
||||
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
|
||||
|
||||
// serve-sim is iOS-only — Android sessions go without a preview URL.
|
||||
let webPreviewUrl: string | undefined;
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
serveSim = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
webPreviewUrl = serveSim.previewUrl;
|
||||
logger.info(`Web preview URL: ${webPreviewUrl}`);
|
||||
}
|
||||
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
@@ -218,7 +214,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
remoteConfig: {
|
||||
toolsUrl: publicToolsUrl,
|
||||
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
|
||||
...(webPreviewUrl ? { webPreviewUrl } : {}),
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
},
|
||||
logger,
|
||||
});
|
||||
@@ -238,8 +234,8 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
if (serveSim) {
|
||||
await serveSim.stopAsync();
|
||||
if (webPreview) {
|
||||
await webPreview.stopAsync();
|
||||
}
|
||||
if (toolsTunnel) {
|
||||
await toolsTunnel.stopAsync();
|
||||
|
||||
+21
-11
@@ -10,51 +10,61 @@ import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
|
||||
export function createStartServeSimRemoteSessionBuildFunction(
|
||||
export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_serve_sim_remote_session',
|
||||
name: 'Start serve-sim remote session',
|
||||
name: 'Start web preview remote session',
|
||||
__metricsId: 'eas/start_serve_sim_remote_session',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_duration_seconds',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { inputs, env, signal }) => {
|
||||
fn: async ({ logger, global }, { 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;
|
||||
const { runtimePlatform } = global;
|
||||
|
||||
logger.info('Starting serve-sim remote session.');
|
||||
logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`);
|
||||
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
|
||||
const serveSim = await startServeSimWithTunnelAsync(ctx, {
|
||||
const webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
packageVersion,
|
||||
});
|
||||
logger.info(`Preview URL: ${serveSim.previewUrl}`);
|
||||
logger.info(`Preview URL: ${webPreview.previewUrl}`);
|
||||
|
||||
try {
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: { previewUrl: serveSim.previewUrl },
|
||||
remoteConfig: { previewUrl: webPreview.previewUrl },
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -66,7 +76,7 @@ export function createStartServeSimRemoteSessionBuildFunction(
|
||||
signal,
|
||||
});
|
||||
} finally {
|
||||
await serveSim.stopAsync();
|
||||
await webPreview.stopAsync();
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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,38 @@
|
||||
import { APPIUM_COMMAND_SUMMARIES, humanizeAppiumCommand } from '../appiumCommandSummary';
|
||||
|
||||
// Coverage of the generated Appium and driver command sets is enforced at compile time by the
|
||||
// `Record<AppiumCommand, string>` type in appiumCommandSummary.ts, so it is not
|
||||
// re-checked here.
|
||||
|
||||
describe('APPIUM_COMMAND_SUMMARIES', () => {
|
||||
it('has a non-empty summary that differs from the command name for every entry', () => {
|
||||
for (const [command, summary] of Object.entries(APPIUM_COMMAND_SUMMARIES)) {
|
||||
expect(summary).toBeTruthy();
|
||||
expect(summary).not.toBe(command);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(humanizeAppiumCommand, () => {
|
||||
it.each([
|
||||
['getWindowRect', 'Read the screen size'],
|
||||
['getScreenshot', 'Took a screenshot'],
|
||||
['getPageSource', 'Read the screen contents'],
|
||||
['click', 'Tapped an element'],
|
||||
['setValue', 'Typed into an element'],
|
||||
['createSession', 'Started the session'],
|
||||
['deleteSession', 'Ended the session'],
|
||||
['postAcceptAlert', 'Accepted an alert'],
|
||||
['mobileScroll', 'Scrolled the screen'],
|
||||
['openNotifications', 'Opened notifications'],
|
||||
])('maps the known command %s to a curated summary', (command, expected) => {
|
||||
expect(humanizeAppiumCommand(command)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each(['proxyReqRes', 'mobile: scroll', 'getDeviceInfo', 'toString', 'constructor', ''])(
|
||||
'returns the raw command %p when it has no curated summary',
|
||||
command => {
|
||||
expect(humanizeAppiumCommand(command)).toBe(command);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -58,7 +58,10 @@ describe(startAppiumEventCollectionAsync, () => {
|
||||
producer: 'appium',
|
||||
type: 'operation.completed',
|
||||
durationMs: 25,
|
||||
summary: 'getOrientation',
|
||||
summary: 'Read the screen orientation',
|
||||
data: {
|
||||
command: 'getOrientation',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -67,10 +67,17 @@ describe(listArgentArtifactsAsync, () => {
|
||||
artifacts: [
|
||||
{
|
||||
id: 'artifact-id',
|
||||
kind: 'native-profile-report',
|
||||
filename: 'report.json',
|
||||
mimeType: 'application/json',
|
||||
isDirectory: false,
|
||||
},
|
||||
{
|
||||
id: 'legacy-artifact-id',
|
||||
filename: 'screen.png',
|
||||
mimeType: 'image/png',
|
||||
isDirectory: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
@@ -84,10 +91,17 @@ describe(listArgentArtifactsAsync, () => {
|
||||
expect(artifacts).toEqual([
|
||||
{
|
||||
id: 'artifact-id',
|
||||
kind: 'native-profile-report',
|
||||
filename: 'report.json',
|
||||
mimeType: 'application/json',
|
||||
isDirectory: false,
|
||||
},
|
||||
{
|
||||
id: 'legacy-artifact-id',
|
||||
filename: 'screen.png',
|
||||
mimeType: 'image/png',
|
||||
isDirectory: false,
|
||||
},
|
||||
]);
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith('http://127.0.0.1:1234/artifacts', {
|
||||
headers: { Authorization: 'Bearer tools-token' },
|
||||
@@ -143,6 +157,7 @@ describe(uploadArgentArtifactAsync, () => {
|
||||
|
||||
async function uploadAsync(artifact: {
|
||||
id: string;
|
||||
kind?: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
isDirectory?: boolean;
|
||||
@@ -185,7 +200,42 @@ describe(uploadArgentArtifactAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves directory artifacts unclassified because they are uploaded as a tarball', async () => {
|
||||
it('uses the semantic kind reported by Argent', async () => {
|
||||
const ctx = await uploadAsync({
|
||||
id: 'artifact-id',
|
||||
kind: 'native-profile-report',
|
||||
filename: 'native-profile-report.md',
|
||||
mimeType: 'text/markdown',
|
||||
});
|
||||
|
||||
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
filename: 'native-profile-report.md',
|
||||
kind: 'native-profile-report',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the semantic kind when a directory is uploaded as a tarball', async () => {
|
||||
const ctx = await uploadAsync({
|
||||
id: 'artifact-id',
|
||||
kind: 'native-profile-trace',
|
||||
filename: 'native-profile.trace',
|
||||
mimeType: 'application/octet-stream',
|
||||
isDirectory: true,
|
||||
});
|
||||
|
||||
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
filename: 'native-profile.trace.tar.gz',
|
||||
kind: 'native-profile-trace',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves an older unclassified directory artifact without a kind', async () => {
|
||||
const ctx = await uploadAsync({
|
||||
id: 'artifact-id',
|
||||
filename: 'screenshots',
|
||||
|
||||
@@ -13,14 +13,16 @@ import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { sleepAsync } from '../../../utils/retry';
|
||||
import {
|
||||
createExpoDeviceHubArgs,
|
||||
createServeSimArgs,
|
||||
ensureFfmpegInstalledAsync,
|
||||
fetchServeSimTurnArgsAsync,
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
metricsCorsOriginToServeSimArgs,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
turnIceServersToServeSimArgs,
|
||||
turnIceServersToWebPreviewArgs,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForServeSimReadyAsync,
|
||||
waitForWebPreviewReadyAsync,
|
||||
} from '../remoteDeviceRunSession';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
@@ -132,11 +134,11 @@ describe(createServeSimArgs, () => {
|
||||
'--webrtc-codec',
|
||||
'vp8',
|
||||
'--max-dimension',
|
||||
'1280',
|
||||
'960',
|
||||
'--mjpeg-quality',
|
||||
'0.55',
|
||||
'--video-bitrate',
|
||||
'3000000',
|
||||
'6000000',
|
||||
'--video-fps',
|
||||
'60',
|
||||
'--turn-url',
|
||||
@@ -157,6 +159,63 @@ 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(createExpoDeviceHubArgs, () => {
|
||||
it('uses the latest Expo package and applies the EAS Android streaming policy', () => {
|
||||
expect(
|
||||
createExpoDeviceHubArgs({
|
||||
port: 4321,
|
||||
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
|
||||
})
|
||||
).toEqual([
|
||||
'--yes',
|
||||
'expo-device-hub@latest',
|
||||
'--port',
|
||||
'4321',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--platform',
|
||||
'android',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'h264',
|
||||
'--webrtc-ice-policy',
|
||||
'all',
|
||||
'--max-dimension',
|
||||
'1280',
|
||||
'--video-bitrate',
|
||||
'6000000',
|
||||
'--video-fps',
|
||||
'60',
|
||||
'--hide-sidebar',
|
||||
'--hide-boot-device',
|
||||
'--turn-url',
|
||||
'turns:turn.example.test:443',
|
||||
]);
|
||||
});
|
||||
|
||||
it('pins the requested package version', () => {
|
||||
expect(createExpoDeviceHubArgs({ port: 4321, packageVersion: '0.7.0' }).slice(0, 2)).toEqual([
|
||||
'--yes',
|
||||
'[email protected]',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(metricsCorsOriginToServeSimArgs, () => {
|
||||
@@ -186,7 +245,7 @@ describe(metricsCorsOriginToServeSimArgs, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(waitForServeSimReadyAsync, () => {
|
||||
describe(waitForWebPreviewReadyAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
jest.mocked(sleepAsync).mockReset();
|
||||
@@ -201,8 +260,9 @@ describe(waitForServeSimReadyAsync, () => {
|
||||
json: async () => ({ status: 'ready', device: 'DEVICE-A' }),
|
||||
} as unknown as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
|
||||
await waitForServeSimReadyAsync({
|
||||
serveSim: { pid: undefined, getOutput: () => '' },
|
||||
await waitForWebPreviewReadyAsync({
|
||||
previewServer: { pid: undefined, getOutput: () => '' },
|
||||
serverName: 'expo-device-hub',
|
||||
port: 4321,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
@@ -247,14 +307,125 @@ describe(startNgrokTunnelAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(turnIceServersToServeSimArgs, () => {
|
||||
describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
const baseDomain = 'eas-simulator.ngrok.dev';
|
||||
const turnArgs = [
|
||||
'--turn-url',
|
||||
'turns:turn.example.test:443',
|
||||
'--turn-username',
|
||||
'turn-user',
|
||||
'--turn-credential',
|
||||
'turn-credential',
|
||||
];
|
||||
const metricsCorsArgs = ['--metrics-cors-origin', 'https://metrics.expo.test'];
|
||||
const env = {
|
||||
DEVICE_RUN_SESSION_ID: 'drs-id',
|
||||
EAS_SIMULATOR_METRICS_CORS_ORIGIN: 'https://metrics.expo.test',
|
||||
NGROK_AUTHTOKEN: 'ngrok-token',
|
||||
} as unknown as BuildStepEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(spawn).mockReset();
|
||||
jest.mocked(ngrok.forward).mockReset();
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
|
||||
const spawnPromise = Object.assign(Promise.resolve(undefined), {
|
||||
child: {
|
||||
pid: undefined,
|
||||
unref: jest.fn(),
|
||||
},
|
||||
});
|
||||
jest.mocked(spawn).mockReturnValue(spawnPromise as never);
|
||||
|
||||
jest.mocked(turtleFetch).mockImplementation(async url => {
|
||||
if (url.endsWith('/turn-ice-servers')) {
|
||||
return {
|
||||
json: async () => ({
|
||||
data: {
|
||||
iceServers: [
|
||||
{
|
||||
urls: ['turns:turn.example.test:443'],
|
||||
username: 'turn-user',
|
||||
credential: 'turn-credential',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
} as unknown as Awaited<ReturnType<typeof turtleFetch>>;
|
||||
}
|
||||
return {
|
||||
json: async () => ({ status: 'ready', device: 'device-id' }),
|
||||
} as unknown as Awaited<ReturnType<typeof turtleFetch>>;
|
||||
});
|
||||
});
|
||||
|
||||
it('starts expo-device-hub for Linux and cleans up the preview resources', async () => {
|
||||
const packageVersion = '1.2.3';
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://android-preview.example.test',
|
||||
close,
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion,
|
||||
});
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
expect(port).toBeGreaterThan(0);
|
||||
expect(command).toBe('npx');
|
||||
expect(args).toEqual(createExpoDeviceHubArgs({ port, turnArgs, packageVersion }));
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(preview.previewUrl).toBe('https://android-preview.example.test');
|
||||
|
||||
await preview.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('starts serve-sim for Darwin with its metrics policy and cleans up the preview resources', async () => {
|
||||
const packageVersion = '4.5.6';
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://ios-preview.example.test',
|
||||
close,
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion,
|
||||
});
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
expect(port).toBeGreaterThan(0);
|
||||
expect(command).toBe('npx');
|
||||
expect(args).toEqual(createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }));
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(preview.previewUrl).toBe('https://ios-preview.example.test');
|
||||
|
||||
await preview.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe(turnIceServersToWebPreviewArgs, () => {
|
||||
it('returns no args for an empty ICE server list', () => {
|
||||
expect(turnIceServersToServeSimArgs([])).toEqual([]);
|
||||
expect(turnIceServersToWebPreviewArgs([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('builds --stun-url and --turn-url flags from Cloudflare ICE servers', () => {
|
||||
expect(
|
||||
turnIceServersToServeSimArgs([
|
||||
turnIceServersToWebPreviewArgs([
|
||||
{ urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.cloudflare.com:53'] },
|
||||
{
|
||||
urls: [
|
||||
@@ -279,7 +450,7 @@ describe(turnIceServersToServeSimArgs, () => {
|
||||
|
||||
it('emits only --turn-url flags when no credential-less (STUN) entry is present', () => {
|
||||
expect(
|
||||
turnIceServersToServeSimArgs([
|
||||
turnIceServersToWebPreviewArgs([
|
||||
{
|
||||
urls: ['turns:turn.cloudflare.com:443?transport=tcp'],
|
||||
username: 'u',
|
||||
@@ -297,19 +468,19 @@ describe(turnIceServersToServeSimArgs, () => {
|
||||
});
|
||||
|
||||
it('emits only --stun-url when there is no credentialed TURN entry', () => {
|
||||
expect(turnIceServersToServeSimArgs([{ urls: ['stun:stun.cloudflare.com:3478'] }])).toEqual([
|
||||
expect(turnIceServersToWebPreviewArgs([{ urls: ['stun:stun.cloudflare.com:3478'] }])).toEqual([
|
||||
'--stun-url',
|
||||
'stun:stun.cloudflare.com:3478',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(fetchServeSimTurnArgsAsync, () => {
|
||||
describe(fetchWebPreviewTurnArgsAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
it('requests TURN ICE servers from the device run session endpoint and returns serve-sim args', async () => {
|
||||
it('requests TURN ICE servers from the device run session endpoint and returns web preview args', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValue({
|
||||
json: async () => ({
|
||||
data: {
|
||||
@@ -325,7 +496,7 @@ describe(fetchServeSimTurnArgsAsync, () => {
|
||||
}),
|
||||
} as unknown as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
|
||||
const args = await fetchServeSimTurnArgsAsync(createCtxMock(), {
|
||||
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), {
|
||||
env: createEnvMock(),
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
@@ -349,11 +520,11 @@ describe(fetchServeSimTurnArgsAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('returns [] and warns when the request fails so serve-sim falls back to P2P/STUN', async () => {
|
||||
it('returns [] and warns when the request fails so the web preview falls back to P2P/STUN', async () => {
|
||||
jest.mocked(turtleFetch).mockRejectedValue(new Error('boom'));
|
||||
const logger = createLoggerMock();
|
||||
|
||||
const args = await fetchServeSimTurnArgsAsync(createCtxMock(), {
|
||||
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), {
|
||||
env: createEnvMock(),
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
import { type AppiumCommand } from './appiumCommands.generated';
|
||||
|
||||
export const APPIUM_COMMAND_SUMMARIES: Record<AppiumCommand, string> = {
|
||||
// Session lifecycle
|
||||
createSession: 'Started the session',
|
||||
deleteSession: 'Ended the session',
|
||||
getSession: 'Read the session details',
|
||||
getStatus: 'Checked the server status',
|
||||
getAppiumSessions: 'Listed Appium sessions',
|
||||
getAppiumSessionCapabilities: 'Read the session capabilities',
|
||||
getTimeouts: 'Read timeouts',
|
||||
timeouts: 'Set timeouts',
|
||||
getSettings: 'Read Appium settings',
|
||||
updateSettings: 'Updated Appium settings',
|
||||
listCommands: 'Listed available commands',
|
||||
listExtensions: 'Listed available extensions',
|
||||
receiveAsyncResponse: 'Received an async response',
|
||||
|
||||
// Screen and media
|
||||
getScreenshot: 'Took a screenshot',
|
||||
getElementScreenshot: 'Took a screenshot of an element',
|
||||
getPageSource: 'Read the screen contents',
|
||||
printPage: 'Printed the page',
|
||||
|
||||
// Window and orientation
|
||||
getWindowRect: 'Read the screen size',
|
||||
setWindowRect: 'Resized the window',
|
||||
maximizeWindow: 'Maximized the window',
|
||||
minimizeWindow: 'Minimized the window',
|
||||
fullScreenWindow: 'Made the window full screen',
|
||||
getWindowHandle: 'Read the current window',
|
||||
getWindowHandles: 'Listed open windows',
|
||||
setWindow: 'Switched window',
|
||||
closeWindow: 'Closed the window',
|
||||
createNewWindow: 'Opened a new window',
|
||||
setFrame: 'Switched to a frame',
|
||||
switchToParentFrame: 'Switched to the parent frame',
|
||||
getOrientation: 'Read the screen orientation',
|
||||
setOrientation: 'Changed the screen orientation',
|
||||
getRotation: 'Read the device rotation',
|
||||
setRotation: 'Rotated the device',
|
||||
|
||||
// Device
|
||||
getDeviceTime: 'Read the device time',
|
||||
getGeoLocation: 'Read the device location',
|
||||
setGeoLocation: 'Set the device location',
|
||||
getNetworkConnection: 'Read the network connection',
|
||||
setNetworkConnection: 'Changed the network connection',
|
||||
setDevicePosture: 'Set the device posture',
|
||||
clearDevicePosture: 'Cleared the device posture',
|
||||
setPermissions: 'Set app permissions',
|
||||
|
||||
// Element discovery
|
||||
findElement: 'Found an element',
|
||||
findElements: 'Found elements',
|
||||
findElementFromElement: 'Found a nested element',
|
||||
findElementsFromElement: 'Found nested elements',
|
||||
findElementFromShadowRoot: 'Found an element in a shadow root',
|
||||
findElementsFromShadowRoot: 'Found elements in a shadow root',
|
||||
elementShadowRoot: 'Read an element shadow root',
|
||||
active: 'Read the focused element',
|
||||
|
||||
// Element interaction
|
||||
click: 'Tapped an element',
|
||||
clear: 'Cleared an element',
|
||||
setValue: 'Typed into an element',
|
||||
getText: 'Read element text',
|
||||
getName: 'Read an element tag',
|
||||
getAttribute: 'Read an element attribute',
|
||||
getProperty: 'Read an element property',
|
||||
getCssProperty: 'Read an element style',
|
||||
getComputedLabel: 'Read an element label',
|
||||
getComputedRole: 'Read an element role',
|
||||
getElementRect: 'Read an element position and size',
|
||||
elementDisplayed: 'Checked if an element was visible',
|
||||
elementEnabled: 'Checked if an element was enabled',
|
||||
elementSelected: 'Checked if an element was selected',
|
||||
|
||||
// Gestures
|
||||
performActions: 'Performed a gesture',
|
||||
releaseActions: 'Finished a gesture',
|
||||
|
||||
// App management
|
||||
installApp: 'Installed an app',
|
||||
removeApp: 'Removed an app',
|
||||
isAppInstalled: 'Checked if an app was installed',
|
||||
activateApp: 'Activated an app',
|
||||
terminateApp: 'Terminated an app',
|
||||
queryAppState: 'Read the app state',
|
||||
background: 'Sent the app to the background',
|
||||
|
||||
// Contexts
|
||||
getContexts: 'Listed available contexts',
|
||||
getCurrentContext: 'Read the current context',
|
||||
setContext: 'Switched context',
|
||||
|
||||
// Web navigation
|
||||
setUrl: 'Opened a URL',
|
||||
getUrl: 'Read the current URL',
|
||||
back: 'Navigated back',
|
||||
forward: 'Navigated forward',
|
||||
refresh: 'Refreshed the page',
|
||||
title: 'Read the page title',
|
||||
|
||||
// Cookies
|
||||
getCookie: 'Read a cookie',
|
||||
getCookies: 'Read all cookies',
|
||||
setCookie: 'Set a cookie',
|
||||
deleteCookie: 'Deleted a cookie',
|
||||
deleteCookies: 'Deleted all cookies',
|
||||
|
||||
// Alerts
|
||||
getAlertText: 'Read an alert',
|
||||
setAlertText: 'Typed into an alert',
|
||||
postAcceptAlert: 'Accepted an alert',
|
||||
postDismissAlert: 'Dismissed an alert',
|
||||
|
||||
// Keyboard and input methods
|
||||
hideKeyboard: 'Hid the keyboard',
|
||||
isKeyboardShown: 'Checked if the keyboard was shown',
|
||||
activateIMEEngine: 'Activated an input method',
|
||||
deactivateIMEEngine: 'Deactivated the input method',
|
||||
getActiveIMEEngine: 'Read the active input method',
|
||||
availableIMEEngines: 'Listed input methods',
|
||||
isIMEActivated: 'Checked if an input method was active',
|
||||
|
||||
// Clipboard (driver-level, not declared by base-driver)
|
||||
getClipboard: 'Read the clipboard',
|
||||
setClipboard: 'Wrote to the clipboard',
|
||||
|
||||
// Files
|
||||
pushFile: 'Pushed a file to the device',
|
||||
pullFile: 'Pulled a file from the device',
|
||||
pullFolder: 'Pulled a folder from the device',
|
||||
|
||||
// Logs
|
||||
getLog: 'Read device logs',
|
||||
getLogTypes: 'Listed log types',
|
||||
getLogEvents: 'Read logged events',
|
||||
logCustomEvent: 'Logged a custom event',
|
||||
generateTestReport: 'Generated a test report',
|
||||
|
||||
// Scripts
|
||||
execute: 'Ran a script command',
|
||||
executeAsync: 'Ran an async script command',
|
||||
executeCdp: 'Ran a Chrome DevTools command',
|
||||
|
||||
// Web authentication (virtual authenticators)
|
||||
addVirtualAuthenticator: 'Added a virtual authenticator',
|
||||
removeVirtualAuthenticator: 'Removed a virtual authenticator',
|
||||
addAuthCredential: 'Added an auth credential',
|
||||
getAuthCredential: 'Read auth credentials',
|
||||
removeAuthCredential: 'Removed an auth credential',
|
||||
removeAllAuthCredentials: 'Removed all auth credentials',
|
||||
setUserAuthVerified: 'Set the user verification state',
|
||||
|
||||
// Federated sign-in (FedCM)
|
||||
fedCMGetAccounts: 'Listed federated sign-in accounts',
|
||||
fedCMSelectAccount: 'Selected a federated sign-in account',
|
||||
fedCMGetDialogType: 'Read the federated sign-in dialog type',
|
||||
fedCMGetTitle: 'Read the federated sign-in dialog title',
|
||||
fedCMClickDialogButton: 'Clicked a federated sign-in dialog button',
|
||||
fedCMCancelDialog: 'Canceled the federated sign-in dialog',
|
||||
fedCMResetCooldown: 'Reset the federated sign-in cooldown',
|
||||
fedCMSetDelayEnabled: 'Toggled the federated sign-in delay',
|
||||
|
||||
// Virtual sensors and pressure sources
|
||||
createVirtualSensor: 'Created a virtual sensor',
|
||||
updateVirtualSensorReading: 'Updated a virtual sensor reading',
|
||||
getVirtualSensorInfo: 'Read virtual sensor info',
|
||||
deleteVirtualSensor: 'Removed a virtual sensor',
|
||||
createVirtualPressureSource: 'Created a virtual pressure source',
|
||||
updateVirtualPressureSource: 'Updated a virtual pressure source',
|
||||
deleteVirtualPressureSource: 'Removed a virtual pressure source',
|
||||
|
||||
// Privacy / experimental web platform
|
||||
getGlobalPrivacyControl: 'Read the Global Privacy Control setting',
|
||||
setGlobalPrivacyControl: 'Set the Global Privacy Control setting',
|
||||
setStorageAccess: 'Set storage access',
|
||||
setRPHRegistrationMode: 'Set the protocol handler registration mode',
|
||||
setSPCTransactionMode: 'Set the payment transaction mode',
|
||||
|
||||
// Driver routes and compatibility commands
|
||||
asyncScriptTimeout: 'Set the async script timeout',
|
||||
closeApp: 'Closed the app',
|
||||
disableConditionInducer: 'Disabled the condition inducer',
|
||||
enableConditionInducer: 'Enabled the condition inducer',
|
||||
fingerprint: 'Simulated a fingerprint',
|
||||
getCurrentActivity: 'Read the current activity',
|
||||
getCurrentPackage: 'Read the current package',
|
||||
getDisplayDensity: 'Read the display density',
|
||||
getLocation: 'Read an element location',
|
||||
getLocationInView: 'Read an element location in view',
|
||||
getPerformanceData: 'Read performance data',
|
||||
getPerformanceDataTypes: 'Listed performance data types',
|
||||
getScreenInfo: 'Read screen information',
|
||||
getSize: 'Read an element size',
|
||||
getStrings: 'Read app strings',
|
||||
getSystemBars: 'Read system bar information',
|
||||
getViewportRect: 'Read the viewport size',
|
||||
getViewportScreenshot: 'Took a viewport screenshot',
|
||||
getWindowSize: 'Read the window size',
|
||||
gsmCall: 'Simulated a phone call',
|
||||
gsmSignal: 'Changed the cellular signal',
|
||||
gsmVoice: 'Changed the cellular voice state',
|
||||
implicitWait: 'Set the element lookup timeout',
|
||||
isLocationServicesEnabled: 'Checked if location services were enabled',
|
||||
isLocked: 'Checked if the device was locked',
|
||||
keyevent: 'Sent a key event',
|
||||
keys: 'Sent keyboard input',
|
||||
launchApp: 'Launched the app',
|
||||
listConditionInducers: 'Listed condition inducers',
|
||||
lock: 'Locked the device',
|
||||
longPressKeyCode: 'Long-pressed a key',
|
||||
networkSpeed: 'Changed the network speed',
|
||||
openNotifications: 'Opened notifications',
|
||||
powerAC: 'Changed the charging state',
|
||||
powerCapacity: 'Changed the battery level',
|
||||
pressKeyCode: 'Pressed a key',
|
||||
replaceValue: 'Replaced an element value',
|
||||
reset: 'Reset the app',
|
||||
sendSMS: 'Simulated a text message',
|
||||
sensorSet: 'Changed a sensor value',
|
||||
setStylusHandwriting: 'Changed stylus handwriting mode',
|
||||
setValueImmediate: 'Typed into an element immediately',
|
||||
startActivity: 'Started an activity',
|
||||
startAudioRecording: 'Started audio recording',
|
||||
startRecordingScreen: 'Started screen recording',
|
||||
stopAudioRecording: 'Stopped audio recording',
|
||||
stopRecordingScreen: 'Stopped screen recording',
|
||||
submit: 'Submitted an element',
|
||||
toggleData: 'Toggled mobile data',
|
||||
toggleEnrollTouchId: 'Changed Touch ID enrollment',
|
||||
toggleFlightMode: 'Toggled airplane mode',
|
||||
toggleLocationServices: 'Toggled location services',
|
||||
toggleWiFi: 'Toggled Wi-Fi',
|
||||
touchId: 'Simulated Touch ID',
|
||||
unlock: 'Unlocked the device',
|
||||
|
||||
// Driver mobile commands
|
||||
mobileAcceptAlert: 'Accepted an alert',
|
||||
mobileActivateApp: 'Activated an app',
|
||||
mobileBackgroundApp: 'Sent the app to the background',
|
||||
mobileBluetooth: 'Changed Bluetooth state',
|
||||
mobileBroadcast: 'Sent an Android broadcast',
|
||||
mobileCalibrateWebToRealCoordinatesTranslation: 'Calibrated web coordinates',
|
||||
mobileChangePermissions: 'Changed app permissions',
|
||||
mobileClearApp: 'Cleared app data',
|
||||
mobileClearKeychains: 'Cleared keychains',
|
||||
mobileClickGesture: 'Performed a click gesture',
|
||||
mobileConfigureLocalization: 'Changed device localization',
|
||||
mobileDeepLink: 'Opened a deep link',
|
||||
mobileDeleteFile: 'Deleted a device file',
|
||||
mobileDeleteFolder: 'Deleted a device folder',
|
||||
mobileDeviceidle: 'Changed device idle state',
|
||||
mobileDisableVoiceOver: 'Disabled VoiceOver',
|
||||
mobileDismissAlert: 'Dismissed an alert',
|
||||
mobileDoubleClickGesture: 'Performed a double-click gesture',
|
||||
mobileDoubleTap: 'Double-tapped the screen',
|
||||
mobileDragFromToForDuration: 'Dragged between screen points',
|
||||
mobileDragFromToWithVelocity: 'Dragged with a set velocity',
|
||||
mobileDragGesture: 'Performed a drag gesture',
|
||||
mobileEnableVoiceOver: 'Enabled VoiceOver',
|
||||
mobileEnrollBiometric: 'Changed biometric enrollment',
|
||||
mobileExecEmuConsoleCommand: 'Ran an emulator console command',
|
||||
mobileExpectNotification: 'Waited for a notification',
|
||||
mobileFingerprint: 'Simulated a fingerprint',
|
||||
mobileFlingGesture: 'Performed a fling gesture',
|
||||
mobileForcePress: 'Force-pressed the screen',
|
||||
mobileGetActionHistory: 'Read scheduled action history',
|
||||
mobileGetActiveAppInfo: 'Read active app information',
|
||||
mobileGetAppearance: 'Read the device appearance',
|
||||
mobileGetBatteryInfo: 'Read battery information',
|
||||
mobileGetChromeCapabilities: 'Read Chrome capabilities',
|
||||
mobileGetConnectivity: 'Read device connectivity',
|
||||
mobileGetContentSize: 'Read scrollable content size',
|
||||
mobileGetContexts: 'Listed available contexts',
|
||||
mobileGetDeviceInfo: 'Read device information',
|
||||
mobileGetDeviceTime: 'Read the device time',
|
||||
mobileGetDeclaredOrientation: 'Read the declared device orientation',
|
||||
mobileGetGeolocation: 'Read the device location',
|
||||
mobileGetIncreaseContrast: 'Read the Increase Contrast setting',
|
||||
mobileGetNotifications: 'Read notifications',
|
||||
mobileGetPasteboard: 'Read the pasteboard',
|
||||
mobileGetPerformanceData: 'Read app performance data',
|
||||
mobileGetPermission: 'Read an app permission',
|
||||
mobileGetPermissions: 'Read app permissions',
|
||||
mobileGetSimulatedLocation: 'Read the simulated location',
|
||||
mobileGetSource: 'Read the screen contents',
|
||||
mobileGetUiMode: 'Read the Android UI mode',
|
||||
mobileGetXctestScreenRecordingInfo: 'Read XCTest screen recording information',
|
||||
mobileGsmCall: 'Simulated a phone call',
|
||||
mobileGsmSignal: 'Changed the cellular signal',
|
||||
mobileGsmVoice: 'Changed the cellular voice state',
|
||||
mobileHandleAlert: 'Handled an alert',
|
||||
mobileHideKeyboard: 'Hid the keyboard',
|
||||
mobileInjectEmulatorCameraImage: 'Injected an emulator camera image',
|
||||
mobileInstallApp: 'Installed an app',
|
||||
mobileInstallCertificate: 'Installed a certificate',
|
||||
mobileInstallMultipleApks: 'Installed multiple APKs',
|
||||
mobileInstallXCTestBundle: 'Installed an XCTest bundle',
|
||||
mobileIsAppInstalled: 'Checked if an app was installed',
|
||||
mobileIsBiometricEnrolled: 'Checked biometric enrollment',
|
||||
mobileIsMediaProjectionRecordingRunning: 'Checked media projection recording',
|
||||
mobileIsVoiceOverEnabled: 'Checked if VoiceOver was enabled',
|
||||
mobileKeys: 'Sent keyboard input',
|
||||
mobileKillApp: 'Stopped an app',
|
||||
mobileLaunchApp: 'Launched an app',
|
||||
mobileListApps: 'Listed installed apps',
|
||||
mobileListCertificates: 'Listed certificates',
|
||||
mobileListDisplays: 'Listed displays',
|
||||
mobileListSms: 'Listed text messages',
|
||||
mobileListWindows: 'Listed app windows',
|
||||
mobileListXCTestBundles: 'Listed XCTest bundles',
|
||||
mobileLongClickGesture: 'Performed a long-click gesture',
|
||||
mobileNetworkSpeed: 'Changed the network speed',
|
||||
mobileNfc: 'Changed NFC state',
|
||||
mobilePerformAccessibilityAudit: 'Ran an accessibility audit',
|
||||
mobilePerformEditorAction: 'Performed a keyboard editor action',
|
||||
mobilePerformHandGesture: 'Performed a hand gesture',
|
||||
mobilePerformIndigoHidEvent: 'Performed an Indigo HID event',
|
||||
mobilePerformIoHidEvent: 'Performed an IOHID event',
|
||||
mobilePerformStatusBarCommand: 'Performed a status bar command',
|
||||
mobilePinch: 'Pinched the screen',
|
||||
mobilePinchCloseGesture: 'Performed a pinch-close gesture',
|
||||
mobilePinchOpenGesture: 'Performed a pinch-open gesture',
|
||||
mobilePowerAc: 'Changed the charging state',
|
||||
mobilePowerCapacity: 'Changed the battery level',
|
||||
mobilePressButton: 'Pressed a device button',
|
||||
mobilePressKey: 'Pressed a key',
|
||||
mobilePullFile: 'Pulled a file from the device',
|
||||
mobilePullFolder: 'Pulled a folder from the device',
|
||||
mobilePushFile: 'Pushed a file to the device',
|
||||
mobilePushNotification: 'Sent a push notification',
|
||||
mobileQueryAppState: 'Read the app state',
|
||||
mobileRefreshGpsCache: 'Refreshed the GPS cache',
|
||||
mobileRemoveApp: 'Removed an app',
|
||||
mobileRemoveCertificate: 'Removed a certificate',
|
||||
mobileReplaceElementValue: 'Replaced an element value',
|
||||
mobileResetAccessibilityCache: 'Reset the accessibility cache',
|
||||
mobileResetGeolocation: 'Reset the device location',
|
||||
mobileResetLocationService: 'Reset location services',
|
||||
mobileResetPermission: 'Reset an app permission',
|
||||
mobileResetSimulatedLocation: 'Reset the simulated location',
|
||||
mobileRotateDigitalCrown: 'Rotated the Digital Crown',
|
||||
mobileRotateElement: 'Rotated an element',
|
||||
mobileRunXCTest: 'Ran an XCTest bundle',
|
||||
mobileScheduleAction: 'Scheduled a device action',
|
||||
mobileScreenshots: 'Took display screenshots',
|
||||
mobileScroll: 'Scrolled the screen',
|
||||
mobileScrollBackTo: 'Scrolled back to an element',
|
||||
mobileScrollGesture: 'Performed a scroll gesture',
|
||||
mobileScrollToElement: 'Scrolled to an element',
|
||||
mobileSelectPickerWheelValue: 'Changed a picker wheel value',
|
||||
mobileSendBiometricMatch: 'Simulated biometric authentication',
|
||||
mobileSendMemoryWarning: 'Sent a memory warning',
|
||||
mobileSendSms: 'Simulated a text message',
|
||||
mobileSendTrimMemory: 'Sent an Android memory trim event',
|
||||
mobileSetAppearance: 'Changed the device appearance',
|
||||
mobileSetConnectivity: 'Changed device connectivity',
|
||||
mobileSetContentSize: 'Changed the content size',
|
||||
mobileSetGeolocation: 'Set the device location',
|
||||
mobileSetIncreaseContrast: 'Changed the Increase Contrast setting',
|
||||
mobileSetPasteboard: 'Wrote to the pasteboard',
|
||||
mobileSetPermissions: 'Set app permissions',
|
||||
mobileSetSimulatedLocation: 'Set a simulated location',
|
||||
mobileSetUiMode: 'Changed the Android UI mode',
|
||||
mobileShake: 'Shook the device',
|
||||
mobileShell: 'Ran an Android shell command',
|
||||
mobileSimctl: 'Ran a simctl command',
|
||||
mobileSiriCommand: 'Sent a Siri command',
|
||||
mobileStartActivity: 'Started an activity',
|
||||
mobileStartLogsBroadcast: 'Started log streaming',
|
||||
mobileStartMediaProjectionRecording: 'Started media projection recording',
|
||||
mobileStartNetworkMonitor: 'Started network monitoring',
|
||||
mobileStartPerfRecord: 'Started performance recording',
|
||||
mobileStartScreenRecording: 'Started screen recording',
|
||||
mobileStartScreenStreaming: 'Started screen streaming',
|
||||
mobileStartService: 'Started a service',
|
||||
mobileStartSystemMonitor: 'Started system monitoring',
|
||||
mobileStartXctestScreenRecording: 'Started XCTest screen recording',
|
||||
mobileStopLogsBroadcast: 'Stopped log streaming',
|
||||
mobileStopMediaProjectionRecording: 'Stopped media projection recording',
|
||||
mobileStopNetworkMonitor: 'Stopped network monitoring',
|
||||
mobileStopPerfRecord: 'Stopped performance recording',
|
||||
mobileStopScreenRecording: 'Stopped screen recording',
|
||||
mobileStopScreenStreaming: 'Stopped screen streaming',
|
||||
mobileStopService: 'Stopped a service',
|
||||
mobileStopSystemMonitor: 'Stopped system monitoring',
|
||||
mobileStopXctestScreenRecording: 'Stopped XCTest screen recording',
|
||||
mobileSwipe: 'Swiped the screen',
|
||||
mobileSwipeGesture: 'Performed a swipe gesture',
|
||||
mobileTap: 'Tapped the screen',
|
||||
mobileTapWithNumberOfTaps: 'Tapped the screen multiple times',
|
||||
mobileTerminateApp: 'Terminated an app',
|
||||
mobileTouchAndHold: 'Touched and held the screen',
|
||||
mobileTwoFingerTap: 'Tapped with two fingers',
|
||||
mobileType: 'Typed text',
|
||||
mobileUnlock: 'Unlocked the device',
|
||||
mobileUnscheduleAction: 'Canceled a scheduled action',
|
||||
mobileUpdateSafariPreferences: 'Updated Safari preferences',
|
||||
mobileViewPortRect: 'Read the viewport size',
|
||||
mobileViewportScreenshot: 'Took a viewport screenshot',
|
||||
mobileVoiceOverCurrentSpeech: 'Read current VoiceOver speech',
|
||||
mobileVoiceOverMove: 'Moved the VoiceOver cursor',
|
||||
};
|
||||
|
||||
/**
|
||||
* Translate a raw Appium command name into a short, human-readable summary.
|
||||
*
|
||||
* If a command has no curated summary, the raw command name is returned
|
||||
* unchanged — we intentionally do not guess a phrasing.
|
||||
*/
|
||||
export function humanizeAppiumCommand(command: string): string {
|
||||
return Object.hasOwn(APPIUM_COMMAND_SUMMARIES, command)
|
||||
? (APPIUM_COMMAND_SUMMARIES as Record<string, string>)[command]
|
||||
: command;
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
// AUTO-GENERATED. Do not edit by hand.
|
||||
// Run `yarn workspace @expo/build-tools generate-appium-commands` to refresh this
|
||||
// list from Appium, XCUITest, and UiAutomator2 (installed on demand by the generator).
|
||||
export type BaseDriverAppiumCommand =
|
||||
| 'activateApp'
|
||||
| 'activateIMEEngine'
|
||||
| 'active'
|
||||
| 'addAuthCredential'
|
||||
| 'addVirtualAuthenticator'
|
||||
| 'availableIMEEngines'
|
||||
| 'back'
|
||||
| 'clear'
|
||||
| 'clearDevicePosture'
|
||||
| 'click'
|
||||
| 'closeWindow'
|
||||
| 'createNewWindow'
|
||||
| 'createSession'
|
||||
| 'createVirtualPressureSource'
|
||||
| 'createVirtualSensor'
|
||||
| 'deactivateIMEEngine'
|
||||
| 'deleteCookie'
|
||||
| 'deleteCookies'
|
||||
| 'deleteSession'
|
||||
| 'deleteVirtualPressureSource'
|
||||
| 'deleteVirtualSensor'
|
||||
| 'elementDisplayed'
|
||||
| 'elementEnabled'
|
||||
| 'elementSelected'
|
||||
| 'elementShadowRoot'
|
||||
| 'execute'
|
||||
| 'executeAsync'
|
||||
| 'executeCdp'
|
||||
| 'fedCMCancelDialog'
|
||||
| 'fedCMClickDialogButton'
|
||||
| 'fedCMGetAccounts'
|
||||
| 'fedCMGetDialogType'
|
||||
| 'fedCMGetTitle'
|
||||
| 'fedCMResetCooldown'
|
||||
| 'fedCMSelectAccount'
|
||||
| 'fedCMSetDelayEnabled'
|
||||
| 'findElement'
|
||||
| 'findElementFromElement'
|
||||
| 'findElementFromShadowRoot'
|
||||
| 'findElements'
|
||||
| 'findElementsFromElement'
|
||||
| 'findElementsFromShadowRoot'
|
||||
| 'forward'
|
||||
| 'fullScreenWindow'
|
||||
| 'generateTestReport'
|
||||
| 'getActiveIMEEngine'
|
||||
| 'getAlertText'
|
||||
| 'getAppiumSessionCapabilities'
|
||||
| 'getAppiumSessions'
|
||||
| 'getAttribute'
|
||||
| 'getAuthCredential'
|
||||
| 'getComputedLabel'
|
||||
| 'getComputedRole'
|
||||
| 'getContexts'
|
||||
| 'getCookie'
|
||||
| 'getCookies'
|
||||
| 'getCssProperty'
|
||||
| 'getCurrentContext'
|
||||
| 'getDeviceTime'
|
||||
| 'getElementRect'
|
||||
| 'getElementScreenshot'
|
||||
| 'getGeoLocation'
|
||||
| 'getGlobalPrivacyControl'
|
||||
| 'getLog'
|
||||
| 'getLogEvents'
|
||||
| 'getLogTypes'
|
||||
| 'getName'
|
||||
| 'getNetworkConnection'
|
||||
| 'getOrientation'
|
||||
| 'getPageSource'
|
||||
| 'getProperty'
|
||||
| 'getRotation'
|
||||
| 'getScreenshot'
|
||||
| 'getSession'
|
||||
| 'getSettings'
|
||||
| 'getStatus'
|
||||
| 'getText'
|
||||
| 'getTimeouts'
|
||||
| 'getUrl'
|
||||
| 'getVirtualSensorInfo'
|
||||
| 'getWindowHandle'
|
||||
| 'getWindowHandles'
|
||||
| 'getWindowRect'
|
||||
| 'hideKeyboard'
|
||||
| 'installApp'
|
||||
| 'isAppInstalled'
|
||||
| 'isIMEActivated'
|
||||
| 'isKeyboardShown'
|
||||
| 'listCommands'
|
||||
| 'listExtensions'
|
||||
| 'logCustomEvent'
|
||||
| 'maximizeWindow'
|
||||
| 'minimizeWindow'
|
||||
| 'performActions'
|
||||
| 'postAcceptAlert'
|
||||
| 'postDismissAlert'
|
||||
| 'printPage'
|
||||
| 'pullFile'
|
||||
| 'pullFolder'
|
||||
| 'pushFile'
|
||||
| 'queryAppState'
|
||||
| 'receiveAsyncResponse'
|
||||
| 'refresh'
|
||||
| 'releaseActions'
|
||||
| 'removeAllAuthCredentials'
|
||||
| 'removeApp'
|
||||
| 'removeAuthCredential'
|
||||
| 'removeVirtualAuthenticator'
|
||||
| 'setAlertText'
|
||||
| 'setContext'
|
||||
| 'setCookie'
|
||||
| 'setDevicePosture'
|
||||
| 'setFrame'
|
||||
| 'setGeoLocation'
|
||||
| 'setGlobalPrivacyControl'
|
||||
| 'setNetworkConnection'
|
||||
| 'setOrientation'
|
||||
| 'setPermissions'
|
||||
| 'setRPHRegistrationMode'
|
||||
| 'setRotation'
|
||||
| 'setSPCTransactionMode'
|
||||
| 'setStorageAccess'
|
||||
| 'setUrl'
|
||||
| 'setUserAuthVerified'
|
||||
| 'setValue'
|
||||
| 'setWindow'
|
||||
| 'setWindowRect'
|
||||
| 'switchToParentFrame'
|
||||
| 'terminateApp'
|
||||
| 'timeouts'
|
||||
| 'title'
|
||||
| 'updateSettings'
|
||||
| 'updateVirtualPressureSource'
|
||||
| 'updateVirtualSensorReading';
|
||||
|
||||
export type XCUITestAppiumCommand =
|
||||
| 'asyncScriptTimeout'
|
||||
| 'background'
|
||||
| 'closeApp'
|
||||
| 'disableConditionInducer'
|
||||
| 'enableConditionInducer'
|
||||
| 'getClipboard'
|
||||
| 'getLocation'
|
||||
| 'getLocationInView'
|
||||
| 'getScreenInfo'
|
||||
| 'getSize'
|
||||
| 'getStrings'
|
||||
| 'getViewportRect'
|
||||
| 'getViewportScreenshot'
|
||||
| 'getWindowSize'
|
||||
| 'implicitWait'
|
||||
| 'isLocked'
|
||||
| 'keys'
|
||||
| 'launchApp'
|
||||
| 'listConditionInducers'
|
||||
| 'lock'
|
||||
| 'mobileActivateApp'
|
||||
| 'mobileCalibrateWebToRealCoordinatesTranslation'
|
||||
| 'mobileClearApp'
|
||||
| 'mobileClearKeychains'
|
||||
| 'mobileConfigureLocalization'
|
||||
| 'mobileDeepLink'
|
||||
| 'mobileDeleteFile'
|
||||
| 'mobileDeleteFolder'
|
||||
| 'mobileDisableVoiceOver'
|
||||
| 'mobileDoubleTap'
|
||||
| 'mobileDragFromToForDuration'
|
||||
| 'mobileDragFromToWithVelocity'
|
||||
| 'mobileEnableVoiceOver'
|
||||
| 'mobileEnrollBiometric'
|
||||
| 'mobileExpectNotification'
|
||||
| 'mobileForcePress'
|
||||
| 'mobileGetActiveAppInfo'
|
||||
| 'mobileGetAppearance'
|
||||
| 'mobileGetBatteryInfo'
|
||||
| 'mobileGetContentSize'
|
||||
| 'mobileGetContexts'
|
||||
| 'mobileGetDeviceInfo'
|
||||
| 'mobileGetDeviceTime'
|
||||
| 'mobileGetIncreaseContrast'
|
||||
| 'mobileGetPasteboard'
|
||||
| 'mobileGetPermission'
|
||||
| 'mobileGetSimulatedLocation'
|
||||
| 'mobileGetSource'
|
||||
| 'mobileGetXctestScreenRecordingInfo'
|
||||
| 'mobileHandleAlert'
|
||||
| 'mobileHideKeyboard'
|
||||
| 'mobileInstallApp'
|
||||
| 'mobileInstallCertificate'
|
||||
| 'mobileInstallXCTestBundle'
|
||||
| 'mobileIsAppInstalled'
|
||||
| 'mobileIsBiometricEnrolled'
|
||||
| 'mobileIsVoiceOverEnabled'
|
||||
| 'mobileKeys'
|
||||
| 'mobileKillApp'
|
||||
| 'mobileLaunchApp'
|
||||
| 'mobileListApps'
|
||||
| 'mobileListCertificates'
|
||||
| 'mobileListXCTestBundles'
|
||||
| 'mobilePerformAccessibilityAudit'
|
||||
| 'mobilePerformHandGesture'
|
||||
| 'mobilePerformIndigoHidEvent'
|
||||
| 'mobilePerformIoHidEvent'
|
||||
| 'mobilePinch'
|
||||
| 'mobilePressButton'
|
||||
| 'mobilePullFile'
|
||||
| 'mobilePullFolder'
|
||||
| 'mobilePushFile'
|
||||
| 'mobilePushNotification'
|
||||
| 'mobileQueryAppState'
|
||||
| 'mobileRemoveApp'
|
||||
| 'mobileRemoveCertificate'
|
||||
| 'mobileResetLocationService'
|
||||
| 'mobileResetPermission'
|
||||
| 'mobileResetSimulatedLocation'
|
||||
| 'mobileRotateDigitalCrown'
|
||||
| 'mobileRotateElement'
|
||||
| 'mobileRunXCTest'
|
||||
| 'mobileScroll'
|
||||
| 'mobileScrollToElement'
|
||||
| 'mobileSelectPickerWheelValue'
|
||||
| 'mobileSendBiometricMatch'
|
||||
| 'mobileSendMemoryWarning'
|
||||
| 'mobileSetAppearance'
|
||||
| 'mobileSetContentSize'
|
||||
| 'mobileSetIncreaseContrast'
|
||||
| 'mobileSetPasteboard'
|
||||
| 'mobileSetPermissions'
|
||||
| 'mobileSetSimulatedLocation'
|
||||
| 'mobileShake'
|
||||
| 'mobileSimctl'
|
||||
| 'mobileSiriCommand'
|
||||
| 'mobileStartLogsBroadcast'
|
||||
| 'mobileStartNetworkMonitor'
|
||||
| 'mobileStartPerfRecord'
|
||||
| 'mobileStartScreenRecording'
|
||||
| 'mobileStartSystemMonitor'
|
||||
| 'mobileStartXctestScreenRecording'
|
||||
| 'mobileStopLogsBroadcast'
|
||||
| 'mobileStopNetworkMonitor'
|
||||
| 'mobileStopPerfRecord'
|
||||
| 'mobileStopScreenRecording'
|
||||
| 'mobileStopSystemMonitor'
|
||||
| 'mobileStopXctestScreenRecording'
|
||||
| 'mobileSwipe'
|
||||
| 'mobileTap'
|
||||
| 'mobileTapWithNumberOfTaps'
|
||||
| 'mobileTerminateApp'
|
||||
| 'mobileTouchAndHold'
|
||||
| 'mobileTwoFingerTap'
|
||||
| 'mobileUpdateSafariPreferences'
|
||||
| 'mobileVoiceOverCurrentSpeech'
|
||||
| 'mobileVoiceOverMove'
|
||||
| 'reset'
|
||||
| 'setClipboard'
|
||||
| 'setValueImmediate'
|
||||
| 'startAudioRecording'
|
||||
| 'startRecordingScreen'
|
||||
| 'stopAudioRecording'
|
||||
| 'stopRecordingScreen'
|
||||
| 'submit'
|
||||
| 'toggleEnrollTouchId'
|
||||
| 'touchId'
|
||||
| 'unlock';
|
||||
|
||||
export type UiAutomator2AppiumCommand =
|
||||
| 'background'
|
||||
| 'fingerprint'
|
||||
| 'getClipboard'
|
||||
| 'getCurrentActivity'
|
||||
| 'getCurrentPackage'
|
||||
| 'getDisplayDensity'
|
||||
| 'getLocation'
|
||||
| 'getLocationInView'
|
||||
| 'getPerformanceData'
|
||||
| 'getPerformanceDataTypes'
|
||||
| 'getSize'
|
||||
| 'getStrings'
|
||||
| 'getSystemBars'
|
||||
| 'getWindowSize'
|
||||
| 'gsmCall'
|
||||
| 'gsmSignal'
|
||||
| 'gsmVoice'
|
||||
| 'implicitWait'
|
||||
| 'isLocationServicesEnabled'
|
||||
| 'isLocked'
|
||||
| 'keyevent'
|
||||
| 'keys'
|
||||
| 'lock'
|
||||
| 'longPressKeyCode'
|
||||
| 'mobileAcceptAlert'
|
||||
| 'mobileBackgroundApp'
|
||||
| 'mobileBluetooth'
|
||||
| 'mobileBroadcast'
|
||||
| 'mobileChangePermissions'
|
||||
| 'mobileClearApp'
|
||||
| 'mobileClickGesture'
|
||||
| 'mobileDeepLink'
|
||||
| 'mobileDeleteFile'
|
||||
| 'mobileDeviceidle'
|
||||
| 'mobileDismissAlert'
|
||||
| 'mobileDoubleClickGesture'
|
||||
| 'mobileDragGesture'
|
||||
| 'mobileExecEmuConsoleCommand'
|
||||
| 'mobileFingerprint'
|
||||
| 'mobileFlingGesture'
|
||||
| 'mobileGetActionHistory'
|
||||
| 'mobileGetBatteryInfo'
|
||||
| 'mobileGetChromeCapabilities'
|
||||
| 'mobileGetConnectivity'
|
||||
| 'mobileGetContexts'
|
||||
| 'mobileGetDeclaredOrientation'
|
||||
| 'mobileGetDeviceInfo'
|
||||
| 'mobileGetDeviceTime'
|
||||
| 'mobileGetGeolocation'
|
||||
| 'mobileGetNotifications'
|
||||
| 'mobileGetPerformanceData'
|
||||
| 'mobileGetPermissions'
|
||||
| 'mobileGetUiMode'
|
||||
| 'mobileGsmCall'
|
||||
| 'mobileGsmSignal'
|
||||
| 'mobileGsmVoice'
|
||||
| 'mobileInjectEmulatorCameraImage'
|
||||
| 'mobileInstallApp'
|
||||
| 'mobileInstallMultipleApks'
|
||||
| 'mobileIsAppInstalled'
|
||||
| 'mobileIsMediaProjectionRecordingRunning'
|
||||
| 'mobileListApps'
|
||||
| 'mobileListDisplays'
|
||||
| 'mobileListSms'
|
||||
| 'mobileListWindows'
|
||||
| 'mobileLongClickGesture'
|
||||
| 'mobileNetworkSpeed'
|
||||
| 'mobileNfc'
|
||||
| 'mobilePerformEditorAction'
|
||||
| 'mobilePerformStatusBarCommand'
|
||||
| 'mobilePinchCloseGesture'
|
||||
| 'mobilePinchOpenGesture'
|
||||
| 'mobilePowerAc'
|
||||
| 'mobilePowerCapacity'
|
||||
| 'mobilePressKey'
|
||||
| 'mobileRefreshGpsCache'
|
||||
| 'mobileRemoveApp'
|
||||
| 'mobileReplaceElementValue'
|
||||
| 'mobileResetAccessibilityCache'
|
||||
| 'mobileResetGeolocation'
|
||||
| 'mobileScheduleAction'
|
||||
| 'mobileScreenshots'
|
||||
| 'mobileScroll'
|
||||
| 'mobileScrollBackTo'
|
||||
| 'mobileScrollGesture'
|
||||
| 'mobileSendSms'
|
||||
| 'mobileSendTrimMemory'
|
||||
| 'mobileSetConnectivity'
|
||||
| 'mobileSetGeolocation'
|
||||
| 'mobileSetUiMode'
|
||||
| 'mobileShell'
|
||||
| 'mobileStartActivity'
|
||||
| 'mobileStartLogsBroadcast'
|
||||
| 'mobileStartMediaProjectionRecording'
|
||||
| 'mobileStartScreenStreaming'
|
||||
| 'mobileStartService'
|
||||
| 'mobileStopLogsBroadcast'
|
||||
| 'mobileStopMediaProjectionRecording'
|
||||
| 'mobileStopScreenStreaming'
|
||||
| 'mobileStopService'
|
||||
| 'mobileSwipeGesture'
|
||||
| 'mobileTerminateApp'
|
||||
| 'mobileType'
|
||||
| 'mobileUnlock'
|
||||
| 'mobileUnscheduleAction'
|
||||
| 'mobileViewPortRect'
|
||||
| 'mobileViewportScreenshot'
|
||||
| 'networkSpeed'
|
||||
| 'openNotifications'
|
||||
| 'powerAC'
|
||||
| 'powerCapacity'
|
||||
| 'pressKeyCode'
|
||||
| 'replaceValue'
|
||||
| 'sendSMS'
|
||||
| 'sensorSet'
|
||||
| 'setClipboard'
|
||||
| 'setStylusHandwriting'
|
||||
| 'setValueImmediate'
|
||||
| 'startActivity'
|
||||
| 'startRecordingScreen'
|
||||
| 'stopRecordingScreen'
|
||||
| 'toggleData'
|
||||
| 'toggleFlightMode'
|
||||
| 'toggleLocationServices'
|
||||
| 'toggleWiFi'
|
||||
| 'unlock';
|
||||
|
||||
export type AppiumCommand =
|
||||
| BaseDriverAppiumCommand
|
||||
| XCUITestAppiumCommand
|
||||
| UiAutomator2AppiumCommand;
|
||||
@@ -7,6 +7,7 @@ import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { humanizeAppiumCommand } from './appiumCommandSummary';
|
||||
import {
|
||||
type DeviceRunSessionEventCollection,
|
||||
type DeviceRunSessionEventSource,
|
||||
@@ -146,7 +147,7 @@ function createAppiumEventSource(eventFile: string): DeviceRunSessionEventSource
|
||||
type: 'operation.completed',
|
||||
operationId,
|
||||
durationMs: Math.max(0, command.endTime - command.startTime),
|
||||
summary: command.cmd,
|
||||
summary: humanizeAppiumCommand(command.cmd),
|
||||
data: {
|
||||
command: command.cmd,
|
||||
appiumSessionId: command.appiumSessionId,
|
||||
|
||||
@@ -18,11 +18,11 @@ const ARGENT_ARTIFACT_UPLOAD_POLL_INTERVAL_MS = 5_000;
|
||||
const ARGENT_ARTIFACT_UPLOAD_CLEANUP_TIMEOUT_MS = 30_000;
|
||||
const ARGENT_ARTIFACT_FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
// Kinds the EAS dashboard groups device run session artifacts by. Only media types the dashboard
|
||||
// renders specially are mapped; anything else stays unclassified and lands in its "Other" group.
|
||||
// Note that these kinds only affect grouping and labelling. The dashboard gates its inline video
|
||||
// player on the `__eas_screen_recording` metadata flag, which Argent artifacts deliberately do not
|
||||
// set, so a `screen-recording` kind here does not add a player to the session page.
|
||||
// Fallback kinds for Argent versions that do not include a semantic artifact kind. Only media types
|
||||
// the EAS dashboard renders specially can be inferred safely. Note that these kinds only affect
|
||||
// grouping and labelling. The dashboard gates its inline video player on the
|
||||
// `__eas_screen_recording` metadata flag, which Argent artifacts deliberately do not set, so a
|
||||
// `screen-recording` kind here does not add a player to the session page.
|
||||
const ARGENT_ARTIFACT_KIND_BY_MIME_TYPE = new Map<string, string>([
|
||||
['image/png', 'screenshot'],
|
||||
['image/jpeg', 'screenshot'],
|
||||
@@ -32,6 +32,9 @@ const ARGENT_ARTIFACT_KIND_BY_MIME_TYPE = new Map<string, string>([
|
||||
|
||||
const ArgentArtifactSchema = z.object({
|
||||
id: z.string(),
|
||||
// Optional for compatibility with older Argent tool servers. Keep this open to new kinds so a
|
||||
// newer Argent version can add a category without requiring an EAS worker release first.
|
||||
kind: z.string().optional(),
|
||||
filename: z.string(),
|
||||
mimeType: z.string(),
|
||||
isDirectory: z.boolean().optional(),
|
||||
@@ -43,8 +46,12 @@ const ArgentArtifactsListResponseSchema = z.object({
|
||||
type ArgentArtifact = z.infer<typeof ArgentArtifactSchema>;
|
||||
|
||||
function getArgentArtifactKind(artifact: ArgentArtifact): string | undefined {
|
||||
if (artifact.kind) {
|
||||
return artifact.kind;
|
||||
}
|
||||
// Directories are repackaged as a tarball before upload, so the reported media type describes the
|
||||
// contents rather than the file we actually store.
|
||||
// contents rather than the file we actually store. A semantic kind above remains valid because it
|
||||
// describes what the artifact represents, not its transport format.
|
||||
if (artifact.isDirectory) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -272,6 +272,10 @@ export type AscApiClientPostApi = {
|
||||
};
|
||||
};
|
||||
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/platform
|
||||
export type AscPlatform =
|
||||
AscApiClientPostApi['/v1/buildUploads']['request']['data']['attributes']['platform'];
|
||||
|
||||
export type AscApiClientPatchApi = {
|
||||
[Path in keyof typeof PatchApi]: {
|
||||
request: z.input<(typeof PatchApi)[Path]['request']>;
|
||||
|
||||
@@ -5,9 +5,44 @@ import {
|
||||
AscApiClientGetApi,
|
||||
AscApiClientPostApi,
|
||||
AscApiRequestError,
|
||||
AscPlatform,
|
||||
} from './AscApiClient';
|
||||
|
||||
export namespace AscApiUtils {
|
||||
/**
|
||||
* Maps a bundle's `DTPlatformName` (from its Info.plist) to the App Store
|
||||
* Connect platform used for a build upload. Unknown or missing values fall
|
||||
* back to `IOS` to preserve the previous default.
|
||||
*/
|
||||
export function ascPlatformFromDtPlatformName(dtPlatformName: string | null): AscPlatform {
|
||||
switch (dtPlatformName) {
|
||||
case 'appletvos':
|
||||
return 'TV_OS';
|
||||
case 'macosx':
|
||||
return 'MAC_OS';
|
||||
case 'xros':
|
||||
return 'VISION_OS';
|
||||
case 'iphoneos':
|
||||
default:
|
||||
return 'IOS';
|
||||
}
|
||||
}
|
||||
|
||||
/** The App Store Connect TestFlight URL path segment for a platform. */
|
||||
export function testFlightPlatformPathSegment(platform: AscPlatform): string {
|
||||
switch (platform) {
|
||||
case 'TV_OS':
|
||||
return 'tvos';
|
||||
case 'MAC_OS':
|
||||
return 'macos';
|
||||
case 'VISION_OS':
|
||||
return 'visionos';
|
||||
case 'IOS':
|
||||
default:
|
||||
return 'ios';
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAppInfoAsync({
|
||||
client,
|
||||
appleAppIdentifier,
|
||||
@@ -61,18 +96,20 @@ export namespace AscApiUtils {
|
||||
appleAppIdentifier,
|
||||
bundleShortVersion,
|
||||
bundleVersion,
|
||||
platform,
|
||||
}: {
|
||||
client: Pick<AscApiClient, 'postAsync'>;
|
||||
appleAppIdentifier: string;
|
||||
bundleShortVersion: string;
|
||||
bundleVersion: string;
|
||||
platform: AscPlatform;
|
||||
}): Promise<AscApiClientPostApi['/v1/buildUploads']['response']> {
|
||||
try {
|
||||
return await client.postAsync('/v1/buildUploads', {
|
||||
data: {
|
||||
type: 'buildUploads',
|
||||
attributes: {
|
||||
platform: 'IOS',
|
||||
platform,
|
||||
cfBundleShortVersionString: bundleShortVersion,
|
||||
cfBundleVersion: bundleVersion,
|
||||
},
|
||||
|
||||
@@ -148,6 +148,7 @@ describe('AscApiUtils', () => {
|
||||
appleAppIdentifier: '1491144534',
|
||||
bundleShortVersion: '1.2.3',
|
||||
bundleVersion: '42',
|
||||
platform: 'IOS',
|
||||
})
|
||||
).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -176,10 +177,40 @@ describe('AscApiUtils', () => {
|
||||
appleAppIdentifier: '1491144534',
|
||||
bundleShortVersion: '1.2.3',
|
||||
bundleVersion: '42',
|
||||
platform: 'IOS',
|
||||
})
|
||||
).resolves.toEqual(response);
|
||||
});
|
||||
|
||||
it('sends the requested platform in the build upload attributes', async () => {
|
||||
const response = {
|
||||
data: {
|
||||
type: 'buildUploads',
|
||||
id: 'fdf9c476-aaa4-4ead-b91c-6e3cc3a47805',
|
||||
},
|
||||
} as const;
|
||||
const client = {
|
||||
postAsync: jest.fn().mockResolvedValue(response),
|
||||
};
|
||||
|
||||
await AscApiUtils.createBuildUploadAsync({
|
||||
client,
|
||||
appleAppIdentifier: '1491144534',
|
||||
bundleShortVersion: '1.2.3',
|
||||
bundleVersion: '42',
|
||||
platform: 'TV_OS',
|
||||
});
|
||||
|
||||
expect(client.postAsync).toHaveBeenCalledWith(
|
||||
'/v1/buildUploads',
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
attributes: expect.objectContaining({ platform: 'TV_OS' }),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rethrows when error payload includes mixed error codes', async () => {
|
||||
const payload = {
|
||||
errors: [
|
||||
@@ -208,8 +239,32 @@ describe('AscApiUtils', () => {
|
||||
appleAppIdentifier: '1491144534',
|
||||
bundleShortVersion: '1.2.3',
|
||||
bundleVersion: '42',
|
||||
platform: 'IOS',
|
||||
})
|
||||
).rejects.toBe(mixedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ascPlatformFromDtPlatformName', () => {
|
||||
it('maps known DTPlatformName values to App Store Connect platforms', () => {
|
||||
expect(AscApiUtils.ascPlatformFromDtPlatformName('iphoneos')).toBe('IOS');
|
||||
expect(AscApiUtils.ascPlatformFromDtPlatformName('appletvos')).toBe('TV_OS');
|
||||
expect(AscApiUtils.ascPlatformFromDtPlatformName('macosx')).toBe('MAC_OS');
|
||||
expect(AscApiUtils.ascPlatformFromDtPlatformName('xros')).toBe('VISION_OS');
|
||||
});
|
||||
|
||||
it('falls back to IOS for unknown or missing values', () => {
|
||||
expect(AscApiUtils.ascPlatformFromDtPlatformName(null)).toBe('IOS');
|
||||
expect(AscApiUtils.ascPlatformFromDtPlatformName('watchos')).toBe('IOS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('testFlightPlatformPathSegment', () => {
|
||||
it('returns the TestFlight URL segment for each platform', () => {
|
||||
expect(AscApiUtils.testFlightPlatformPathSegment('IOS')).toBe('ios');
|
||||
expect(AscApiUtils.testFlightPlatformPathSegment('TV_OS')).toBe('tvos');
|
||||
expect(AscApiUtils.testFlightPlatformPathSegment('MAC_OS')).toBe('macos');
|
||||
expect(AscApiUtils.testFlightPlatformPathSegment('VISION_OS')).toBe('visionos');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { getProxiedDownloadUrl } from '../../../utils/download';
|
||||
|
||||
const DEFAULT_XCLOGPARSER_VERSION = 'v0.2.47';
|
||||
const XCLOGPARSER_DOWNLOAD_URL = 'https://storage.googleapis.com/turtle-v2/xclogparser';
|
||||
@@ -294,24 +295,6 @@ function getXclogparserZipName(version: string): string {
|
||||
return `XCLogParser-macOS-x86-64-arm64-${version}.zip`;
|
||||
}
|
||||
|
||||
function getProxiedDownloadUrl({
|
||||
directUrl,
|
||||
proxyBaseUrl,
|
||||
}: {
|
||||
directUrl: string;
|
||||
proxyBaseUrl?: string;
|
||||
}): string | null {
|
||||
if (!proxyBaseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(directUrl);
|
||||
return directUrl.replace(
|
||||
`${parsedUrl.protocol}//${parsedUrl.host}`,
|
||||
`${proxyBaseUrl}/${parsedUrl.host}`
|
||||
);
|
||||
}
|
||||
|
||||
export function isCompileStep(step: Partial<XcactivitylogStep>): boolean {
|
||||
const detailStepType = step.detailStepType ?? '';
|
||||
const signature = step.signature ?? '';
|
||||
|
||||
@@ -19,12 +19,16 @@ 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_HOST = '127.0.0.1';
|
||||
const SERVE_SIM_MAX_DIMENSION = '1280';
|
||||
const WEB_PREVIEW_HOST = '127.0.0.1';
|
||||
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
|
||||
const SERVE_SIM_MAX_DIMENSION = '960';
|
||||
const SERVE_SIM_MJPEG_QUALITY = '0.55';
|
||||
const SERVE_SIM_VIDEO_BITRATE = '3000000';
|
||||
const SERVE_SIM_VIDEO_BITRATE = '6000000';
|
||||
const SERVE_SIM_VIDEO_FPS = '60';
|
||||
const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub';
|
||||
const EXPO_DEVICE_HUB_MAX_DIMENSION = '1280';
|
||||
const EXPO_DEVICE_HUB_VIDEO_BITRATE = '6000000';
|
||||
const EXPO_DEVICE_HUB_VIDEO_FPS = '60';
|
||||
|
||||
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
|
||||
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
|
||||
@@ -390,11 +394,12 @@ const TurnIceServersResponseSchema = z.object({
|
||||
});
|
||||
|
||||
/**
|
||||
* Translate Cloudflare ICE servers into serve-sim CLI flags: `--stun-url` (the
|
||||
* Translate Cloudflare ICE servers into web preview CLI flags: `--stun-url` (the
|
||||
* credential-less entries) and `--turn-url`/`--turn-username`/`--turn-credential`
|
||||
* (the entry carrying the short-lived credentials).
|
||||
* (the entry carrying the short-lived credentials). serve-sim and expo-device-hub
|
||||
* intentionally expose the same ICE flag contract.
|
||||
*/
|
||||
export function turnIceServersToServeSimArgs(iceServers: TurnIceServers): string[] {
|
||||
export function turnIceServersToWebPreviewArgs(iceServers: TurnIceServers): string[] {
|
||||
const stunUrls = iceServers
|
||||
.filter(server => !server.username && !server.credential)
|
||||
.flatMap(server => server.urls);
|
||||
@@ -420,14 +425,14 @@ export function turnIceServersToServeSimArgs(iceServers: TurnIceServers): string
|
||||
/**
|
||||
* Fetch short-lived Cloudflare TURN ICE servers for this job run from www
|
||||
* (minted on demand, mirroring how the worker fetches project clone URLs) and
|
||||
* translate them into serve-sim CLI flags.
|
||||
* translate them into web preview CLI flags.
|
||||
*
|
||||
* Best-effort: on any failure we log and return [] so serve-sim falls back to
|
||||
* its built-in P2P/STUN behavior. The credential is passed to serve-sim as a
|
||||
* process arg and deliberately not logged (turtle-spawn never logs argv and the
|
||||
* worker is single-tenant).
|
||||
* Best-effort: on any failure we log and return [] so the preview server falls
|
||||
* back to its built-in P2P/STUN behavior. The credential is passed as a process
|
||||
* arg and deliberately not logged (turtle-spawn never logs argv and the worker
|
||||
* is single-tenant).
|
||||
*/
|
||||
export async function fetchServeSimTurnArgsAsync(
|
||||
export async function fetchWebPreviewTurnArgsAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
|
||||
): Promise<string[]> {
|
||||
@@ -456,9 +461,9 @@ export async function fetchServeSimTurnArgsAsync(
|
||||
);
|
||||
|
||||
const { data } = TurnIceServersResponseSchema.parse(await response.json());
|
||||
const args = turnIceServersToServeSimArgs(data.iceServers);
|
||||
const args = turnIceServersToWebPreviewArgs(data.iceServers);
|
||||
if (args.length > 0) {
|
||||
logger.info('Configured serve-sim with Cloudflare TURN ICE servers.');
|
||||
logger.info('Configured the web preview with Cloudflare TURN ICE servers.');
|
||||
}
|
||||
return args;
|
||||
} catch (err) {
|
||||
@@ -466,7 +471,7 @@ export async function fetchServeSimTurnArgsAsync(
|
||||
Sentry.capture('Could not fetch Cloudflare TURN ICE servers', error, { level: 'warning' });
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Could not fetch Cloudflare TURN ICE servers; serve-sim will fall back to P2P/STUN.'
|
||||
'Could not fetch Cloudflare TURN ICE servers; the web preview will fall back to P2P/STUN.'
|
||||
);
|
||||
return [];
|
||||
}
|
||||
@@ -596,22 +601,32 @@ export function metricsCorsOriginToServeSimArgs(env: BuildStepEnv): string[] {
|
||||
return args;
|
||||
}
|
||||
|
||||
function createServeSimPackageSpec(packageVersion: string | undefined): string {
|
||||
return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
|
||||
}
|
||||
|
||||
function createExpoDeviceHubPackageSpec(packageVersion: string | undefined): string {
|
||||
return `${EXPO_DEVICE_HUB_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',
|
||||
SERVE_SIM_HOST,
|
||||
WEB_PREVIEW_HOST,
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
@@ -629,44 +644,84 @@ export function createServeSimArgs({
|
||||
];
|
||||
}
|
||||
|
||||
export function createExpoDeviceHubArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
packageVersion,
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
packageVersion?: string;
|
||||
}): string[] {
|
||||
return [
|
||||
'--yes',
|
||||
createExpoDeviceHubPackageSpec(packageVersion),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
WEB_PREVIEW_HOST,
|
||||
'--platform',
|
||||
'android',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'h264',
|
||||
'--webrtc-ice-policy',
|
||||
'all',
|
||||
'--max-dimension',
|
||||
EXPO_DEVICE_HUB_MAX_DIMENSION,
|
||||
'--video-bitrate',
|
||||
EXPO_DEVICE_HUB_VIDEO_BITRATE,
|
||||
'--video-fps',
|
||||
EXPO_DEVICE_HUB_VIDEO_FPS,
|
||||
'--hide-sidebar',
|
||||
'--hide-boot-device',
|
||||
...turnArgs,
|
||||
];
|
||||
}
|
||||
|
||||
async function findAvailablePortAsync(): Promise<number> {
|
||||
const server = createServer();
|
||||
server.unref();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, SERVE_SIM_HOST, () => resolve());
|
||||
server.listen(0, WEB_PREVIEW_HOST, () => resolve());
|
||||
});
|
||||
const address = server.address();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close(err => (err ? reject(err) : resolve()));
|
||||
});
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new SystemError('Could not allocate a local port for serve-sim.');
|
||||
throw new SystemError('Could not allocate a local port for the web preview.');
|
||||
}
|
||||
return address.port;
|
||||
}
|
||||
|
||||
const ServeSimReadyResponseSchema = z.object({
|
||||
const WebPreviewReadyResponseSchema = z.object({
|
||||
status: z.literal('ready'),
|
||||
device: z.string(),
|
||||
});
|
||||
|
||||
export async function waitForServeSimReadyAsync({
|
||||
serveSim,
|
||||
export async function waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
}: {
|
||||
serveSim: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
|
||||
previewServer: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
|
||||
serverName: string;
|
||||
port: number;
|
||||
timeoutMs: number;
|
||||
}): Promise<void> {
|
||||
const readyUrl = `http://${SERVE_SIM_HOST}:${port}/readyz`;
|
||||
const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
if (serveSim.pid !== undefined && !isProcessRunning(serveSim.pid)) {
|
||||
if (previewServer.pid !== undefined && !isProcessRunning(previewServer.pid)) {
|
||||
throw new SystemError(
|
||||
`serve-sim exited before becoming ready. Last output:\n${serveSim.getOutput() || '<empty>'}`
|
||||
`${serverName} exited before becoming ready. Last output:\n${
|
||||
previewServer.getOutput() || '<empty>'
|
||||
}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
@@ -674,7 +729,7 @@ export async function waitForServeSimReadyAsync({
|
||||
retries: 0,
|
||||
timeout: 2_000,
|
||||
});
|
||||
ServeSimReadyResponseSchema.parse(await response.json());
|
||||
WebPreviewReadyResponseSchema.parse(await response.json());
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
@@ -682,44 +737,51 @@ export async function waitForServeSimReadyAsync({
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
throw new SystemError(
|
||||
`Timed out waiting for serve-sim readiness at ${readyUrl}${
|
||||
`Timed out waiting for ${serverName} readiness at ${readyUrl}${
|
||||
lastError instanceof Error ? `: ${lastError.message}` : ''
|
||||
}. Last output:\n${serveSim.getOutput() || '<empty>'}`
|
||||
}. Last output:\n${previewServer.getOutput() || '<empty>'}`
|
||||
);
|
||||
}
|
||||
|
||||
export type ServeSimPreviewHandle = {
|
||||
export type DeviceWebPreviewHandle = {
|
||||
previewUrl: string;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
export async function startServeSimWithTunnelAsync(
|
||||
export type ServeSimPreviewHandle = DeviceWebPreviewHandle;
|
||||
|
||||
async function startWebPreviewWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
serverName,
|
||||
packageSpec,
|
||||
createArgs,
|
||||
}: {
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
serverName: string;
|
||||
packageSpec: string;
|
||||
createArgs: (port: number, turnArgs: string[]) => string[];
|
||||
}
|
||||
): Promise<ServeSimPreviewHandle> {
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
const port = await findAvailablePortAsync();
|
||||
logger.info(`Launching ${SERVE_SIM_PACKAGE_SPEC} on ${SERVE_SIM_HOST}:${port}.`);
|
||||
const turnArgs = await fetchServeSimTurnArgsAsync(ctx, { env, logger });
|
||||
const metricsCorsArgs = metricsCorsOriginToServeSimArgs(env);
|
||||
const serveSim = spawnDetached({
|
||||
logger.info(`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port}.`);
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
|
||||
const previewServer = spawnDetached({
|
||||
command: 'npx',
|
||||
args: createServeSimArgs({ port, turnArgs, metricsCorsArgs }),
|
||||
args: createArgs(port, turnArgs),
|
||||
env,
|
||||
});
|
||||
|
||||
try {
|
||||
logger.info('Waiting for serve-sim to become ready.');
|
||||
await waitForServeSimReadyAsync({ serveSim, port, timeoutMs });
|
||||
logger.info(`Waiting for ${serverName} to become ready.`);
|
||||
await waitForWebPreviewReadyAsync({ previewServer, serverName, port, timeoutMs });
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix: 'web-preview',
|
||||
@@ -730,20 +792,98 @@ export async function startServeSimWithTunnelAsync(
|
||||
return {
|
||||
previewUrl: tunnel.url,
|
||||
stopAsync: async () => {
|
||||
const results = await Promise.allSettled([tunnel.stopAsync(), serveSim.stopAsync()]);
|
||||
const results = await Promise.allSettled([tunnel.stopAsync(), previewServer.stopAsync()]);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
logger.warn({ err: result.reason }, 'Could not stop a serve-sim preview resource.');
|
||||
logger.warn({ err: result.reason }, `Could not stop a ${serverName} preview resource.`);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await serveSim.stopAsync();
|
||||
await previewServer.stopAsync();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startServeSimWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
packageVersion,
|
||||
}: {
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
}
|
||||
): Promise<ServeSimPreviewHandle> {
|
||||
const metricsCorsArgs = metricsCorsOriginToServeSimArgs(env);
|
||||
return await startWebPreviewWithTunnelAsync(ctx, {
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
serverName: 'serve-sim',
|
||||
packageSpec: createServeSimPackageSpec(packageVersion),
|
||||
createArgs: (port, turnArgs) =>
|
||||
createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function startExpoDeviceHubWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
packageVersion,
|
||||
}: {
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
}
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
return await startWebPreviewWithTunnelAsync(ctx, {
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
serverName: 'expo-device-hub',
|
||||
packageSpec: createExpoDeviceHubPackageSpec(packageVersion),
|
||||
createArgs: (port, turnArgs) => createExpoDeviceHubArgs({ port, turnArgs, packageVersion }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function startDeviceWebPreviewWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
runtimePlatform,
|
||||
...options
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
}
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
switch (runtimePlatform) {
|
||||
case BuildRuntimePlatform.DARWIN:
|
||||
return await startServeSimWithTunnelAsync(ctx, options);
|
||||
case BuildRuntimePlatform.LINUX:
|
||||
return await startExpoDeviceHubWithTunnelAsync(ctx, options);
|
||||
}
|
||||
}
|
||||
|
||||
export type NgrokTunnelHandle = {
|
||||
url: string;
|
||||
stopAsync: () => Promise<void>;
|
||||
|
||||
@@ -44,6 +44,32 @@ export namespace IosSimulatorUtils {
|
||||
};
|
||||
};
|
||||
|
||||
export type IosSimulatorRuntime = {
|
||||
identifier: string;
|
||||
isAvailable?: boolean;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
type XcrunSimctlListRuntimesJsonOutput = {
|
||||
runtimes: (Omit<IosSimulatorRuntime, 'identifier'> & { identifier?: string })[];
|
||||
};
|
||||
|
||||
export async function getAvailableRuntimesAsync({
|
||||
env,
|
||||
}: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<IosSimulatorRuntime[]> {
|
||||
const { stdout } = await spawn('xcrun', ['simctl', 'list', 'runtimes', '--json'], { env });
|
||||
const { runtimes } = JSON.parse(stdout) as XcrunSimctlListRuntimesJsonOutput;
|
||||
|
||||
return runtimes.flatMap(runtime =>
|
||||
runtime.isAvailable !== false &&
|
||||
runtime.identifier?.startsWith('com.apple.CoreSimulator.SimRuntime.iOS-')
|
||||
? [{ ...runtime, identifier: runtime.identifier }]
|
||||
: []
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAvailableDevicesAsync({
|
||||
env,
|
||||
filter,
|
||||
|
||||
@@ -23,6 +23,41 @@ describe('IosSimulatorUtils', () => {
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.getAvailableRuntimesAsync, () => {
|
||||
it('returns available iOS runtimes', async () => {
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
runtimes: [
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
|
||||
isAvailable: true,
|
||||
version: '18.3.1',
|
||||
},
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-17-5',
|
||||
isAvailable: false,
|
||||
version: '17.5',
|
||||
},
|
||||
],
|
||||
}),
|
||||
stderr: '',
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.getAvailableRuntimesAsync({ env: process.env })
|
||||
).resolves.toEqual([
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
|
||||
isAvailable: true,
|
||||
version: '18.3.1',
|
||||
},
|
||||
]);
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('xcrun', ['simctl', 'list', 'runtimes', '--json'], {
|
||||
env: process.env,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.getAvailableDevicesAsync, () => {
|
||||
it('adds a human-readable runtime display name', async () => {
|
||||
mockedSpawn.mockResolvedValue({
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
|
||||
import { getCcacheKeyPrefix } from '../cacheKey';
|
||||
|
||||
describe(getCcacheKeyPrefix, () => {
|
||||
it('uses separate prefixes for iOS device and simulator builds', () => {
|
||||
expect(getCcacheKeyPrefix({ platform: Platform.IOS, simulator: false })).toBe(
|
||||
'ios-device-ccache-'
|
||||
);
|
||||
expect(getCcacheKeyPrefix({ platform: Platform.IOS, simulator: true })).toBe(
|
||||
'ios-simulator-ccache-'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not change the Android prefix', () => {
|
||||
expect(getCcacheKeyPrefix({ platform: Platform.ANDROID })).toBe('android-ccache-');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
compressCocoapodsCacheAsync,
|
||||
getCocoapodsCachePaths,
|
||||
resolveCocoapodsCacheKeyAsync,
|
||||
restoreCocoapodsCacheArchiveAsync,
|
||||
} from '../cocoapodsCache';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
describe('CocoaPods cache utilities', () => {
|
||||
let workingDirectory: string;
|
||||
const cocoapodsVersion = '1.16.2';
|
||||
|
||||
beforeEach(async () => {
|
||||
workingDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'cocoapods-cache-test-'));
|
||||
await fs.promises.mkdir(path.join(workingDirectory, 'ios'), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(workingDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('uses a CocoaPods version prefix when no Podfile.lock exists', async () => {
|
||||
await expect(
|
||||
resolveCocoapodsCacheKeyAsync(workingDirectory, cocoapodsVersion)
|
||||
).resolves.toEqual({
|
||||
key: 'ios-pods-1.16.2-',
|
||||
keyPrefix: 'ios-pods-1.16.2-',
|
||||
});
|
||||
});
|
||||
|
||||
it('includes the Podfile.lock hash when it exists', async () => {
|
||||
const { podfileLockPath } = getCocoapodsCachePaths(workingDirectory);
|
||||
await fs.promises.writeFile(podfileLockPath, 'PODS:\n - Expo (55.0.0)\n');
|
||||
|
||||
const firstResult = await resolveCocoapodsCacheKeyAsync(workingDirectory, cocoapodsVersion);
|
||||
expect(firstResult.key).toMatch(/^ios-pods-1\.16\.2-[a-f0-9]+$/);
|
||||
expect(firstResult.keyPrefix).toBe('ios-pods-1.16.2-');
|
||||
|
||||
await fs.promises.writeFile(podfileLockPath, 'PODS:\n - Expo (56.0.0)\n');
|
||||
const secondResult = await resolveCocoapodsCacheKeyAsync(workingDirectory, cocoapodsVersion);
|
||||
expect(secondResult.key).not.toBe(firstResult.key);
|
||||
});
|
||||
|
||||
it('preserves symlinks and executable permissions through an archive round trip', async () => {
|
||||
const { podsDirectory } = getCocoapodsCachePaths(workingDirectory);
|
||||
const frameworkVersionsDirectory = path.join(podsDirectory, 'Example.framework', 'Versions');
|
||||
const frameworkVersionDirectory = path.join(frameworkVersionsDirectory, 'A');
|
||||
await fs.promises.mkdir(frameworkVersionDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(path.join(frameworkVersionDirectory, 'Example'), 'binary');
|
||||
await fs.promises.symlink('A', path.join(frameworkVersionsDirectory, 'Current'));
|
||||
|
||||
const scriptPath = path.join(podsDirectory, 'Target Support Files', 'script.sh');
|
||||
await fs.promises.mkdir(path.dirname(scriptPath), { recursive: true });
|
||||
await fs.promises.writeFile(scriptPath, '#!/bin/sh\n');
|
||||
await fs.promises.chmod(scriptPath, 0o755);
|
||||
|
||||
const { archivePath } = await compressCocoapodsCacheAsync({ workingDirectory });
|
||||
await fs.promises.rm(podsDirectory, { recursive: true, force: true });
|
||||
await restoreCocoapodsCacheArchiveAsync({ archivePath, workingDirectory });
|
||||
|
||||
const restoredSymlinkPath = path.join(frameworkVersionsDirectory, 'Current');
|
||||
expect((await fs.promises.lstat(restoredSymlinkPath)).isSymbolicLink()).toBe(true);
|
||||
await expect(fs.promises.readlink(restoredSymlinkPath)).resolves.toBe('A');
|
||||
|
||||
const restoredScriptStat = await fs.promises.stat(scriptPath);
|
||||
expect(restoredScriptStat.mode & 0o111).toBe(0o111);
|
||||
|
||||
await fs.promises.rm(path.dirname(archivePath), { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${STAGING_VERSION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,7 +91,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${STAGING_VERSION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,7 +101,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${STAGING_VERSION}`],
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0', EXPO_STAGING: '1' },
|
||||
});
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -112,7 +112,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${PRODUCTION_VERSION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
@@ -133,7 +133,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(stagingResult).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0', EXPO_STAGING: '1' },
|
||||
});
|
||||
|
||||
process.env.ENVIRONMENT = 'production';
|
||||
@@ -141,7 +141,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(productionResult).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${EasCliNpmTags.PRODUCTION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
@@ -173,9 +173,17 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: [`eas-cli@${PRODUCTION_VERSION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
});
|
||||
|
||||
it('disables the minimum release age for every npx invocation', async () => {
|
||||
process.env.ENVIRONMENT = 'production';
|
||||
|
||||
const result = await resolveEasCommandPrefixAndEnvAsync();
|
||||
|
||||
expect(result.extraEnv).toEqual({ NPM_CONFIG_MIN_RELEASE_AGE: '0' });
|
||||
});
|
||||
});
|
||||
|
||||
describe(runEasCliCommand, () => {
|
||||
@@ -218,4 +226,25 @@ describe(runEasCliCommand, () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('overrides a user-provided minimum release age', async () => {
|
||||
process.env.ENVIRONMENT = 'production';
|
||||
|
||||
await runEasCliCommand({
|
||||
args: ['deploy', '--json'],
|
||||
options: {
|
||||
cwd: '/tmp/project',
|
||||
env: {
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '365',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const spawnEnv = jest.mocked(spawn).mock.calls[0][2]?.env;
|
||||
expect(spawnEnv).toEqual({
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '0',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { formatSecondsForLog } from '../formatDuration';
|
||||
|
||||
describe(formatSecondsForLog, () => {
|
||||
it.each([
|
||||
[0, '0 seconds'],
|
||||
[1, '1 second'],
|
||||
[45, '45 seconds'],
|
||||
[60, '1 minute'],
|
||||
[90, '1 minute 30 seconds'],
|
||||
[120, '2 minutes'],
|
||||
[3600, '1 hour'],
|
||||
[3661, '1 hour 1 minute 1 second'],
|
||||
[7200, '2 hours'],
|
||||
] as const)('formats %i as %s', (seconds, expected) => {
|
||||
expect(formatSecondsForLog(seconds)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ChildProcess } from 'node:child_process';
|
||||
|
||||
import { isChildProcessAlive } from '../processes';
|
||||
|
||||
function child(partial: {
|
||||
exitCode: number | null;
|
||||
signalCode: NodeJS.Signals | null;
|
||||
killed: boolean;
|
||||
}): ChildProcess {
|
||||
return partial as ChildProcess;
|
||||
}
|
||||
|
||||
describe(isChildProcessAlive, () => {
|
||||
it('is alive while the process is running', () => {
|
||||
expect(isChildProcessAlive(child({ exitCode: null, signalCode: null, killed: false }))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('is dead after a normal exit', () => {
|
||||
expect(isChildProcessAlive(child({ exitCode: 0, signalCode: null, killed: false }))).toBe(
|
||||
false
|
||||
);
|
||||
expect(isChildProcessAlive(child({ exitCode: 1, signalCode: null, killed: false }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('is dead after an external signal termination (the regression case)', () => {
|
||||
expect(
|
||||
isChildProcessAlive(child({ exitCode: null, signalCode: 'SIGTERM', killed: false }))
|
||||
).toBe(false);
|
||||
expect(
|
||||
isChildProcessAlive(child({ exitCode: null, signalCode: 'SIGKILL', killed: false }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is dead once we have killed it', () => {
|
||||
expect(isChildProcessAlive(child({ exitCode: null, signalCode: null, killed: true }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,503 @@
|
||||
import { SshSettings, SystemError } from '@expo/eas-build-job';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { sleepAsync } from '../retry';
|
||||
import {
|
||||
SshSessionHandle,
|
||||
getSshIdleTimeoutSeconds,
|
||||
getSshRelayServerUrl,
|
||||
isSshEnabled,
|
||||
startSshSessionAsync,
|
||||
superviseSshSessionAsync,
|
||||
} from '../turtleSshSession';
|
||||
import { SshConnectionConfig, UptermHost, startUptermHostAsync } from '../upterm';
|
||||
|
||||
jest.mock('../retry', () => ({
|
||||
sleepAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.mock('../upterm', () => ({
|
||||
...jest.requireActual('../upterm'),
|
||||
startUptermHostAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedStartUptermHost = jest.mocked(startUptermHostAsync);
|
||||
|
||||
function sshJob(idleTimeoutSeconds: number): { ssh: SshSettings } {
|
||||
return { ssh: { idleTimeoutSeconds, relayServerUrl: 'wss://relay.expo.dev' } };
|
||||
}
|
||||
|
||||
function superviseHandle(
|
||||
overrides: Partial<SshSessionHandle> &
|
||||
Pick<SshSessionHandle, 'getConnectedClientCountAsync' | 'ensureConnectedAsync'>
|
||||
): SshSessionHandle {
|
||||
return {
|
||||
stopAsync: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe(isSshEnabled, () => {
|
||||
it('is true when job.ssh is present', () => {
|
||||
expect(isSshEnabled(sshJob(0))).toBe(true);
|
||||
});
|
||||
|
||||
it('is false otherwise', () => {
|
||||
expect(isSshEnabled({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(getSshIdleTimeoutSeconds, () => {
|
||||
it('returns idleTimeoutSeconds from job.ssh', () => {
|
||||
expect(getSshIdleTimeoutSeconds(sshJob(900))).toBe(900);
|
||||
expect(getSshIdleTimeoutSeconds(sshJob(0))).toBe(0);
|
||||
expect(getSshIdleTimeoutSeconds(sshJob(3600))).toBe(3600);
|
||||
});
|
||||
|
||||
it('defaults to 0 when job.ssh is missing', () => {
|
||||
expect(getSshIdleTimeoutSeconds({})).toBe(0);
|
||||
});
|
||||
|
||||
it('throws when the value is not an integer in range', () => {
|
||||
for (const idleTimeoutSeconds of [300.5, -1, 3601]) {
|
||||
expect(() => getSshIdleTimeoutSeconds(sshJob(idleTimeoutSeconds))).toThrow(SystemError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(getSshRelayServerUrl, () => {
|
||||
it('returns the relay url from the job ssh settings', () => {
|
||||
expect(getSshRelayServerUrl(sshJob(0))).toBe('wss://relay.expo.dev');
|
||||
});
|
||||
|
||||
it('throws when the job carries no relay url', () => {
|
||||
expect(() => getSshRelayServerUrl({})).toThrow(/no relay server URL/);
|
||||
});
|
||||
});
|
||||
|
||||
describe(startSshSessionAsync, () => {
|
||||
const config1: SshConnectionConfig = {
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'secret-1',
|
||||
};
|
||||
const config2: SshConnectionConfig = { ...config1, secret: 'secret-2' };
|
||||
const target = { type: 'JOB_RUN', id: 'jr-1' } as const;
|
||||
|
||||
let createOrUpdateResult: { data?: unknown; error?: { message: string } };
|
||||
let mutation: jest.Mock;
|
||||
let ctx: BuildContext;
|
||||
|
||||
function makeHost(overrides: Partial<UptermHost> = {}): UptermHost {
|
||||
const host: UptermHost = {
|
||||
connectionConfig: config1,
|
||||
getConnectedClientCountAsync: jest.fn().mockResolvedValue(3),
|
||||
isAlive: jest.fn().mockReturnValue(true),
|
||||
redialAsync: jest.fn(async () => {
|
||||
host.connectionConfig = config2;
|
||||
return config2;
|
||||
}),
|
||||
stopAsync: jest.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
return host;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
createOrUpdateResult = {
|
||||
data: {
|
||||
turtleSshSession: {
|
||||
createOrUpdateTurtleSshSession: {
|
||||
id: 'ts-1',
|
||||
sessionSettings: { idleTimeoutSeconds: 900 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
mutation = jest.fn().mockReturnValue({ toPromise: async () => createOrUpdateResult });
|
||||
ctx = {
|
||||
logger: createMockLogger(),
|
||||
graphqlClient: { mutation },
|
||||
} as unknown as BuildContext;
|
||||
});
|
||||
|
||||
it('creates or updates the session and returns a handle plus idle timeout that delegates to the host', async () => {
|
||||
const host = makeHost();
|
||||
mockedStartUptermHost.mockResolvedValue(host);
|
||||
|
||||
const { handle, idleTimeoutSeconds } = await startSshSessionAsync(ctx, {
|
||||
target,
|
||||
relayServerUrl: 'wss://relay.expo.dev',
|
||||
idleTimeoutSeconds: 900,
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
target: { type: 'JOB_RUN', id: 'jr-1' },
|
||||
connectionConfig: { ...config1, reconnecting: false, type: 'UPTERM_V1' },
|
||||
sessionSettings: { idleTimeoutSeconds: 900 },
|
||||
});
|
||||
expect(idleTimeoutSeconds).toBe(900);
|
||||
await handle.getConnectedClientCountAsync();
|
||||
expect(host.getConnectedClientCountAsync).toHaveBeenCalled();
|
||||
await handle.stopAsync();
|
||||
expect(host.stopAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a build target', async () => {
|
||||
const host = makeHost();
|
||||
mockedStartUptermHost.mockResolvedValue(host);
|
||||
|
||||
await startSshSessionAsync(ctx, {
|
||||
target: { type: 'BUILD', id: 'b-1' },
|
||||
relayServerUrl: 'wss://relay.expo.dev',
|
||||
idleTimeoutSeconds: 900,
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
target: { type: 'BUILD', id: 'b-1' },
|
||||
connectionConfig: { ...config1, reconnecting: false, type: 'UPTERM_V1' },
|
||||
sessionSettings: { idleTimeoutSeconds: 900 },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws and tears down the dialed host when creating or updating the session fails', async () => {
|
||||
const host = makeHost();
|
||||
mockedStartUptermHost.mockResolvedValue(host);
|
||||
createOrUpdateResult = { error: { message: 'boom' } };
|
||||
|
||||
await expect(
|
||||
startSshSessionAsync(ctx, {
|
||||
target,
|
||||
relayServerUrl: 'wss://r',
|
||||
idleTimeoutSeconds: 300,
|
||||
})
|
||||
).rejects.toThrow('boom');
|
||||
expect(host.stopAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still rethrows when teardown after a create/update failure also fails', async () => {
|
||||
const host = makeHost({
|
||||
stopAsync: jest.fn().mockRejectedValue(new Error('already gone')),
|
||||
});
|
||||
mockedStartUptermHost.mockResolvedValue(host);
|
||||
createOrUpdateResult = { error: { message: 'boom' } };
|
||||
|
||||
await expect(
|
||||
startSshSessionAsync(ctx, {
|
||||
target,
|
||||
relayServerUrl: 'wss://r',
|
||||
idleTimeoutSeconds: 300,
|
||||
})
|
||||
).rejects.toThrow('boom');
|
||||
expect(host.stopAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws "no data returned" when create or update succeeds without a payload', async () => {
|
||||
const host = makeHost();
|
||||
mockedStartUptermHost.mockResolvedValue(host);
|
||||
createOrUpdateResult = {};
|
||||
|
||||
await expect(
|
||||
startSshSessionAsync(ctx, {
|
||||
target,
|
||||
relayServerUrl: 'wss://r',
|
||||
idleTimeoutSeconds: 300,
|
||||
})
|
||||
).rejects.toThrow(/no data returned/);
|
||||
expect(host.stopAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ensureConnected is a no-op while the host is alive', async () => {
|
||||
const host = makeHost({ isAlive: jest.fn().mockReturnValue(true) });
|
||||
mockedStartUptermHost.mockResolvedValue(host);
|
||||
|
||||
const { handle } = await startSshSessionAsync(ctx, {
|
||||
target,
|
||||
relayServerUrl: 'wss://r',
|
||||
idleTimeoutSeconds: 300,
|
||||
});
|
||||
await handle.ensureConnectedAsync();
|
||||
|
||||
expect(host.redialAsync).not.toHaveBeenCalled();
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('marks reconnecting, redials, then reports the fresh config', async () => {
|
||||
const host = makeHost({ isAlive: jest.fn().mockReturnValue(false) });
|
||||
mockedStartUptermHost.mockResolvedValue(host);
|
||||
|
||||
const { handle } = await startSshSessionAsync(ctx, {
|
||||
target,
|
||||
relayServerUrl: 'wss://r',
|
||||
idleTimeoutSeconds: 300,
|
||||
});
|
||||
await handle.ensureConnectedAsync();
|
||||
|
||||
expect(host.redialAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
connectionConfig: expect.objectContaining({ reconnecting: true }),
|
||||
})
|
||||
);
|
||||
expect(mutation).toHaveBeenLastCalledWith(expect.anything(), {
|
||||
target: { type: 'JOB_RUN', id: 'jr-1' },
|
||||
connectionConfig: { ...config2, reconnecting: false, type: 'UPTERM_V1' },
|
||||
sessionSettings: { idleTimeoutSeconds: 300 },
|
||||
});
|
||||
});
|
||||
|
||||
it('continues redial when the reconnecting status update fails', async () => {
|
||||
const host = makeHost({ isAlive: jest.fn().mockReturnValue(false) });
|
||||
mockedStartUptermHost.mockResolvedValue(host);
|
||||
mutation
|
||||
.mockReturnValueOnce({ toPromise: async () => createOrUpdateResult }) // initial create
|
||||
.mockReturnValueOnce({
|
||||
toPromise: async () => {
|
||||
throw new Error('www unreachable');
|
||||
},
|
||||
}) // reconnecting=true best-effort
|
||||
.mockReturnValue({ toPromise: async () => createOrUpdateResult }); // post-redial
|
||||
|
||||
const { handle } = await startSshSessionAsync(ctx, {
|
||||
target,
|
||||
relayServerUrl: 'wss://r',
|
||||
idleTimeoutSeconds: 300,
|
||||
});
|
||||
await handle.ensureConnectedAsync();
|
||||
|
||||
expect(host.redialAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries only the report (no second redial) when reporting the fresh config fails', async () => {
|
||||
let alive = false;
|
||||
const host = makeHost({
|
||||
isAlive: jest.fn(() => alive),
|
||||
});
|
||||
host.redialAsync = jest.fn(async () => {
|
||||
alive = true;
|
||||
host.connectionConfig = config2;
|
||||
return config2;
|
||||
});
|
||||
mockedStartUptermHost.mockResolvedValue(host);
|
||||
mutation = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce({ toPromise: async () => createOrUpdateResult })
|
||||
.mockReturnValueOnce({ toPromise: async () => createOrUpdateResult })
|
||||
.mockReturnValueOnce({ toPromise: async () => ({ error: { message: 'flaky' } }) })
|
||||
.mockReturnValue({ toPromise: async () => createOrUpdateResult });
|
||||
ctx = { logger: createMockLogger(), graphqlClient: { mutation } } as unknown as BuildContext;
|
||||
|
||||
const { handle } = await startSshSessionAsync(ctx, {
|
||||
target,
|
||||
relayServerUrl: 'wss://r',
|
||||
idleTimeoutSeconds: 300,
|
||||
});
|
||||
await handle.ensureConnectedAsync();
|
||||
|
||||
expect(host.redialAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mutation).toHaveBeenLastCalledWith(expect.anything(), {
|
||||
target: { type: 'JOB_RUN', id: 'jr-1' },
|
||||
connectionConfig: { ...config2, reconnecting: false, type: 'UPTERM_V1' },
|
||||
sessionSettings: { idleTimeoutSeconds: 300 },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws once the redial budget is exhausted', async () => {
|
||||
const host = makeHost({
|
||||
isAlive: jest.fn().mockReturnValue(false),
|
||||
redialAsync: jest.fn().mockRejectedValue(new Error('relay down')),
|
||||
});
|
||||
mockedStartUptermHost.mockResolvedValue(host);
|
||||
|
||||
const { handle } = await startSshSessionAsync(ctx, {
|
||||
target,
|
||||
relayServerUrl: 'wss://r',
|
||||
idleTimeoutSeconds: 300,
|
||||
});
|
||||
|
||||
await expect(handle.ensureConnectedAsync()).rejects.toThrow(/after 10 attempts/);
|
||||
expect(host.redialAsync).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe(superviseSshSessionAsync, () => {
|
||||
const logger = createMockLogger();
|
||||
const mockedSleep = jest.mocked(sleepAsync);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedSleep.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('keeps the session open while the job is running, even with a 0 idle timeout', async () => {
|
||||
let polls = 0;
|
||||
await superviseSshSessionAsync({
|
||||
handle: superviseHandle({
|
||||
ensureConnectedAsync: async () => {
|
||||
if (polls >= 5) {
|
||||
throw new SystemError('stop the loop');
|
||||
}
|
||||
},
|
||||
getConnectedClientCountAsync: async () => {
|
||||
polls += 1;
|
||||
return 0;
|
||||
},
|
||||
}),
|
||||
idleTimeoutSeconds: 0,
|
||||
hasJobFinished: () => false,
|
||||
logger,
|
||||
});
|
||||
expect(polls).toBe(5);
|
||||
});
|
||||
|
||||
it('closes as soon as the job finishes when the idle timeout is 0', async () => {
|
||||
let polls = 0;
|
||||
await superviseSshSessionAsync({
|
||||
handle: superviseHandle({
|
||||
ensureConnectedAsync: async () => {},
|
||||
getConnectedClientCountAsync: async () => {
|
||||
polls += 1;
|
||||
return 0;
|
||||
},
|
||||
}),
|
||||
idleTimeoutSeconds: 0,
|
||||
hasJobFinished: () => true,
|
||||
logger,
|
||||
});
|
||||
expect(polls).toBe(1);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'The job finished and no SSH client is connected. Closing the session.'
|
||||
);
|
||||
});
|
||||
|
||||
it('waits for a connected client to disconnect before closing with a 0 idle timeout', async () => {
|
||||
const clientCounts = [1, 1, 0];
|
||||
let polls = 0;
|
||||
await superviseSshSessionAsync({
|
||||
handle: superviseHandle({
|
||||
ensureConnectedAsync: async () => {},
|
||||
getConnectedClientCountAsync: async () => {
|
||||
polls += 1;
|
||||
return clientCounts.shift() ?? 0;
|
||||
},
|
||||
}),
|
||||
idleTimeoutSeconds: 0,
|
||||
hasJobFinished: () => true,
|
||||
logger,
|
||||
});
|
||||
expect(polls).toBe(3);
|
||||
});
|
||||
|
||||
it('starts the idle countdown when the job finishes, not when the session opens', async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(0);
|
||||
try {
|
||||
let jobHasFinished = false;
|
||||
let polls = 0;
|
||||
mockedSleep.mockImplementation(async () => {
|
||||
jest.setSystemTime(Date.now() + 60_000);
|
||||
});
|
||||
await superviseSshSessionAsync({
|
||||
handle: superviseHandle({
|
||||
ensureConnectedAsync: async () => {},
|
||||
getConnectedClientCountAsync: async () => {
|
||||
polls += 1;
|
||||
if (polls >= 5) {
|
||||
jobHasFinished = true;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
}),
|
||||
idleTimeoutSeconds: 300,
|
||||
hasJobFinished: () => jobHasFinished,
|
||||
logger,
|
||||
});
|
||||
// Nobody connects, so the 4 idle minutes spent during the job must not count towards the
|
||||
// 5 minute timeout: it closes 5 minutes after the job finished, on the 10th poll.
|
||||
expect(polls).toBe(10);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('logs the connected client count whenever it changes', async () => {
|
||||
const clientCounts = [1, 2, 1, 0];
|
||||
await superviseSshSessionAsync({
|
||||
handle: superviseHandle({
|
||||
ensureConnectedAsync: async () => {},
|
||||
getConnectedClientCountAsync: async () => clientCounts.shift() ?? 0,
|
||||
}),
|
||||
idleTimeoutSeconds: 0,
|
||||
hasJobFinished: () => true,
|
||||
logger,
|
||||
});
|
||||
expect(logger.info).toHaveBeenCalledWith('SSH clients connected: 1');
|
||||
expect(logger.info).toHaveBeenCalledWith('SSH clients connected: 2');
|
||||
expect(logger.info).toHaveBeenCalledWith('SSH clients connected: 1');
|
||||
expect(logger.info).toHaveBeenCalledWith('SSH clients connected: 0');
|
||||
});
|
||||
|
||||
it('closes the session when the relay connection cannot be restored', async () => {
|
||||
let clientCountCalls = 0;
|
||||
await superviseSshSessionAsync({
|
||||
handle: superviseHandle({
|
||||
ensureConnectedAsync: async () => {
|
||||
throw new SystemError('relay down');
|
||||
},
|
||||
getConnectedClientCountAsync: async () => {
|
||||
clientCountCalls += 1;
|
||||
return 1;
|
||||
},
|
||||
}),
|
||||
idleTimeoutSeconds: 300,
|
||||
hasJobFinished: () => false,
|
||||
logger,
|
||||
});
|
||||
expect(clientCountCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('does not close while a client stays connected, even past the idle timeout', async () => {
|
||||
let polls = 0;
|
||||
await superviseSshSessionAsync({
|
||||
handle: superviseHandle({
|
||||
ensureConnectedAsync: async () => {
|
||||
if (polls > 3) {
|
||||
throw new SystemError('stop the loop');
|
||||
}
|
||||
},
|
||||
getConnectedClientCountAsync: async () => {
|
||||
polls += 1;
|
||||
return 1;
|
||||
},
|
||||
}),
|
||||
idleTimeoutSeconds: 0,
|
||||
hasJobFinished: () => true,
|
||||
logger,
|
||||
});
|
||||
expect(polls).toBeGreaterThan(3);
|
||||
});
|
||||
|
||||
it('closes when the client count cannot be read', async () => {
|
||||
let polls = 0;
|
||||
await superviseSshSessionAsync({
|
||||
handle: superviseHandle({
|
||||
ensureConnectedAsync: async () => {},
|
||||
getConnectedClientCountAsync: async () => {
|
||||
polls += 1;
|
||||
throw new SystemError(
|
||||
'Could not read the SSH client count from the upterm admin socket.'
|
||||
);
|
||||
},
|
||||
}),
|
||||
idleTimeoutSeconds: 300,
|
||||
hasJobFinished: () => false,
|
||||
logger,
|
||||
});
|
||||
expect(polls).toBe(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'Could not read the SSH client count. Closing the session.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,466 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import downloadFile from '@expo/downloader';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { sleepAsync } from '../retry';
|
||||
import {
|
||||
connectionConfigFromUptermSession,
|
||||
redactConnectionSecrets,
|
||||
redactSpawnErrorForLog,
|
||||
resolveUptermGcsObjectName,
|
||||
startUptermHostAsync,
|
||||
} from '../upterm';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('@expo/downloader', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('../retry', () => ({ sleepAsync: jest.fn() }));
|
||||
jest.mock('node:fs/promises', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
access: jest.fn(),
|
||||
mkdir: jest.fn(),
|
||||
chmod: jest.fn(),
|
||||
mkdtemp: jest.fn(),
|
||||
writeFile: jest.fn(),
|
||||
rm: jest.fn(),
|
||||
readdir: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe(resolveUptermGcsObjectName, () => {
|
||||
it('maps worker platforms to GCS object names', () => {
|
||||
expect(resolveUptermGcsObjectName('darwin', 'arm64')).toBe('upterm-darwin-arm64');
|
||||
expect(resolveUptermGcsObjectName('linux', 'x64')).toBe('upterm-linux-amd64');
|
||||
});
|
||||
|
||||
it('rejects unsupported platforms', () => {
|
||||
expect(() => resolveUptermGcsObjectName('darwin', 'x64')).toThrow(/only available/);
|
||||
expect(() => resolveUptermGcsObjectName('linux', 'arm64')).toThrow(/only available/);
|
||||
});
|
||||
});
|
||||
|
||||
describe(connectionConfigFromUptermSession, () => {
|
||||
it('parses sessionId and host from session current fields', () => {
|
||||
expect(
|
||||
connectionConfigFromUptermSession({
|
||||
sessionId: 'TOKENabc123',
|
||||
host: 'ssh://relay.expo.dev:22',
|
||||
})
|
||||
).toEqual({
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'TOKENabc123',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a bare hostname', () => {
|
||||
expect(connectionConfigFromUptermSession({ sessionId: 'tok', host: 'relay.expo.dev' })).toEqual(
|
||||
{
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'tok',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a non-default port so the CLI can pass it to ssh -p', () => {
|
||||
expect(
|
||||
connectionConfigFromUptermSession({
|
||||
sessionId: 'tok',
|
||||
host: 'ssh://relay.expo.dev:2222',
|
||||
})
|
||||
).toEqual({
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev:2222',
|
||||
secret: 'tok',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when the host is not a parseable url', () => {
|
||||
expect(connectionConfigFromUptermSession({ sessionId: 'tok', host: 'ssh://' })).toBeNull();
|
||||
expect(connectionConfigFromUptermSession({ sessionId: 'tok', host: 'ssh://[' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(redactConnectionSecrets, () => {
|
||||
it('strips the userinfo secret from a connection string', () => {
|
||||
expect(
|
||||
redactConnectionSecrets('Connect: upterm proxy wss://[email protected] now')
|
||||
).toBe('Connect: upterm proxy wss://<redacted>@relay.expo.dev now');
|
||||
});
|
||||
|
||||
it('strips the bare token that trails the ssh ProxyCommand line', () => {
|
||||
const line =
|
||||
"ssh -o ProxyCommand='upterm proxy wss://[email protected]' [email protected]";
|
||||
const redacted = redactConnectionSecrets(line);
|
||||
expect(redacted).not.toContain('TOKENabc123');
|
||||
expect(redacted).toBe(
|
||||
"ssh -o ProxyCommand='upterm proxy wss://<redacted>@uptermd.upterm.dev' <redacted>@uptermd.upterm.dev"
|
||||
);
|
||||
});
|
||||
|
||||
it('strips the bare token for ws:// relay URLs too', () => {
|
||||
const line =
|
||||
"ssh -o ProxyCommand='upterm proxy ws://[email protected]' [email protected]";
|
||||
const redacted = redactConnectionSecrets(line);
|
||||
expect(redacted).not.toContain('TOKENabc123');
|
||||
expect(redacted).toBe(
|
||||
"ssh -o ProxyCommand='upterm proxy ws://<redacted>@relay.local' <redacted>@relay.local"
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves output without userinfo untouched', () => {
|
||||
expect(redactConnectionSecrets('dialing wss://relay.expo.dev')).toBe(
|
||||
'dialing wss://relay.expo.dev'
|
||||
);
|
||||
});
|
||||
|
||||
it('strips control characters (incl. ESC) while keeping newlines', () => {
|
||||
// ESC alone is enough to neutralize CSI; leftover printable "[31m" is harmless in logs.
|
||||
expect(redactConnectionSecrets('ok\n\u001b[31mfail\u001b[0m\r\nbad\u0007')).toBe(
|
||||
'ok\n[31mfail[0m\nbad'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(redactSpawnErrorForLog, () => {
|
||||
it('redacts secrets in message, stdout, and stderr', () => {
|
||||
expect(
|
||||
redactSpawnErrorForLog({
|
||||
message: 'failed wss://[email protected]',
|
||||
stdout: 'upterm proxy wss://[email protected]',
|
||||
stderr: 'ssh dial error: wss://[email protected]',
|
||||
code: 1,
|
||||
})
|
||||
).toEqual({
|
||||
message: 'failed wss://<redacted>@relay.expo.dev',
|
||||
stdout: 'upterm proxy wss://<redacted>@relay.expo.dev',
|
||||
stderr: 'ssh dial error: wss://<redacted>@relay.expo.dev',
|
||||
code: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes through non-objects', () => {
|
||||
expect(redactSpawnErrorForLog('boom')).toBe('boom');
|
||||
expect(redactSpawnErrorForLog(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves non-string message/stdout/stderr fields alone', () => {
|
||||
expect(
|
||||
redactSpawnErrorForLog({
|
||||
message: 42,
|
||||
stdout: Buffer.from('x'),
|
||||
code: 1,
|
||||
})
|
||||
).toEqual({
|
||||
message: 42,
|
||||
stdout: Buffer.from('x'),
|
||||
code: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(startUptermHostAsync, () => {
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedSleep = jest.mocked(sleepAsync);
|
||||
const mockedFs = jest.mocked(fs);
|
||||
const mockedDownloadFile = jest.mocked(downloadFile);
|
||||
|
||||
let hostProcess: {
|
||||
child: {
|
||||
stdout: { on: (event: string, cb: (chunk: Buffer) => void) => void };
|
||||
stderr: { on: jest.Mock };
|
||||
pid: number;
|
||||
kill: jest.Mock;
|
||||
unref: jest.Mock;
|
||||
exitCode: number | null;
|
||||
signalCode: NodeJS.Signals | null;
|
||||
killed: boolean;
|
||||
};
|
||||
catch: (cb: (err: unknown) => void) => Promise<void>;
|
||||
};
|
||||
let sessionStdout: string;
|
||||
let uptermOnPath = true;
|
||||
|
||||
function makeHostProcess(): typeof hostProcess {
|
||||
return {
|
||||
child: {
|
||||
stdout: {
|
||||
on: jest.fn((_event: string, cb: (chunk: Buffer) => void) => {
|
||||
cb(Buffer.from('host output'));
|
||||
}),
|
||||
},
|
||||
stderr: { on: jest.fn() },
|
||||
pid: 4242,
|
||||
kill: jest.fn(),
|
||||
unref: jest.fn(),
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
killed: false,
|
||||
},
|
||||
catch: cb => {
|
||||
cb(new Error('exited'));
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(env: Record<string, string> = {}): BuildContext {
|
||||
return {
|
||||
logger: createMockLogger(),
|
||||
env,
|
||||
} as unknown as BuildContext;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(process, 'kill').mockReturnValue(true);
|
||||
hostProcess = makeHostProcess();
|
||||
uptermOnPath = true;
|
||||
sessionStdout = JSON.stringify({
|
||||
sessionId: 'TOKENx',
|
||||
host: 'ssh://relay.expo.dev:22',
|
||||
clientCount: 0,
|
||||
});
|
||||
mockedSleep.mockResolvedValue(undefined);
|
||||
mockedFs.mkdir.mockResolvedValue(undefined as never);
|
||||
mockedFs.chmod.mockResolvedValue(undefined as never);
|
||||
mockedFs.mkdtemp.mockImplementation((async (prefix: string) =>
|
||||
String(prefix).includes('eas-upterm') ? '/tmp/eas-upterm-1' : '/tmp/eas-ssh-1') as never);
|
||||
mockedFs.writeFile.mockResolvedValue(undefined as never);
|
||||
mockedFs.rm.mockResolvedValue(undefined as never);
|
||||
mockedFs.readdir.mockResolvedValue(['default.sock'] as never);
|
||||
mockedDownloadFile.mockResolvedValue(undefined as never);
|
||||
mockedSpawn.mockImplementation(((cmd: string, args: string[]) => {
|
||||
if (cmd === 'upterm' && Array.isArray(args) && args[0] === 'version') {
|
||||
if (!uptermOnPath) {
|
||||
return Promise.reject(new Error('not found')) as never;
|
||||
}
|
||||
return Promise.resolve({ stdout: 'upterm version 0.24.0\n', stderr: '' }) as never;
|
||||
}
|
||||
if (Array.isArray(args) && args[0] === 'host') {
|
||||
return hostProcess as never;
|
||||
}
|
||||
if (Array.isArray(args) && args[0] === 'session') {
|
||||
return Promise.resolve({ stdout: sessionStdout, stderr: '' }) as never;
|
||||
}
|
||||
return Promise.resolve({ stdout: '', stderr: '' }) as never;
|
||||
}) as never);
|
||||
});
|
||||
|
||||
it('uses upterm on PATH, dials, and returns a handle with the parsed config', async () => {
|
||||
const env = { PATH: '/job/bin', CUSTOM: '1' };
|
||||
const host = await startUptermHostAsync(makeCtx(env), {
|
||||
relayServerUrl: 'wss://relay.expo.dev',
|
||||
});
|
||||
|
||||
expect(host.connectionConfig).toEqual({
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'TOKENx',
|
||||
});
|
||||
expect(host.isAlive()).toBe(true);
|
||||
expect(mockedDownloadFile).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('upterm', ['version'], {
|
||||
stdio: 'pipe',
|
||||
env,
|
||||
});
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'upterm',
|
||||
expect.arrayContaining(['host']),
|
||||
expect.objectContaining({ env: expect.objectContaining({ CUSTOM: '1', PATH: '/job/bin' }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('downloads from GCS into a fresh temp dir when upterm is not on PATH', async () => {
|
||||
uptermOnPath = false;
|
||||
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://relay.expo.dev' });
|
||||
|
||||
expect(host.connectionConfig.secret).toBe('TOKENx');
|
||||
expect(mockedDownloadFile).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/storage\.googleapis\.com\/turtle-v2\/upterm\/upterm-/),
|
||||
expect.stringContaining('/tmp/eas-upterm-1'),
|
||||
expect.objectContaining({ retry: 3 })
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/tmp/eas-upterm-1'),
|
||||
expect.arrayContaining(['host']),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when upterm is missing from PATH and the GCS download fails', async () => {
|
||||
uptermOnPath = false;
|
||||
mockedDownloadFile.mockRejectedValue(new Error('403 Forbidden') as never);
|
||||
|
||||
await expect(startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' })).rejects.toThrow(
|
||||
/could not be downloaded/
|
||||
);
|
||||
});
|
||||
|
||||
it('still throws when download fails with a non-Error and temp cleanup also fails', async () => {
|
||||
uptermOnPath = false;
|
||||
mockedDownloadFile.mockRejectedValue('network down' as never);
|
||||
mockedFs.rm.mockRejectedValue(new Error('EPERM') as never);
|
||||
|
||||
await expect(startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' })).rejects.toThrow(
|
||||
/network down/
|
||||
);
|
||||
});
|
||||
|
||||
it('tears down and rethrows when the host never registers with the relay', async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(0);
|
||||
try {
|
||||
mockedFs.readdir.mockResolvedValue([] as never);
|
||||
mockedSleep.mockImplementation(async () => {
|
||||
jest.setSystemTime(Date.now() + 61_000);
|
||||
});
|
||||
|
||||
await expect(startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' })).rejects.toThrow(
|
||||
/did not register/
|
||||
);
|
||||
expect(process.kill).toHaveBeenCalledWith(-4242, 'SIGTERM');
|
||||
expect(mockedFs.rm).toHaveBeenCalledWith('/tmp/eas-ssh-1', expect.anything());
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('stopAsync kills the process and removes the state dir', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
await host.stopAsync();
|
||||
|
||||
expect(process.kill).toHaveBeenCalledWith(-4242, 'SIGTERM');
|
||||
expect(mockedFs.rm).toHaveBeenCalledWith('/tmp/eas-ssh-1', expect.anything());
|
||||
});
|
||||
|
||||
it('falls back to child.kill when process.kill on the process group fails', async () => {
|
||||
jest.spyOn(process, 'kill').mockImplementation(() => {
|
||||
throw new Error('ESRCH');
|
||||
});
|
||||
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
await host.stopAsync();
|
||||
|
||||
expect(hostProcess.child.kill).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips process.kill when the host process has no pid', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
hostProcess.child.pid = undefined as unknown as number;
|
||||
await host.stopAsync();
|
||||
|
||||
expect(process.kill).not.toHaveBeenCalled();
|
||||
expect(hostProcess.child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redialAsync re-establishes the connection', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
const config = await host.redialAsync();
|
||||
|
||||
expect(config).toEqual({
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'TOKENx',
|
||||
});
|
||||
expect(host.connectionConfig).toEqual(config);
|
||||
});
|
||||
|
||||
it('redialAsync waits for the old process to exit before clearing its socket dir', async () => {
|
||||
const order: string[] = [];
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
|
||||
// Keep the give-up timer pending so only the process exit can unblock the redial.
|
||||
mockedSleep.mockImplementation(() => new Promise<void>(() => {}));
|
||||
let resolveExit: (() => void) | undefined;
|
||||
hostProcess.catch = () =>
|
||||
new Promise<void>(resolve => {
|
||||
resolveExit = () => {
|
||||
order.push('exited');
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
mockedFs.rm.mockImplementation((() => {
|
||||
order.push('rm');
|
||||
return Promise.resolve(undefined);
|
||||
}) as never);
|
||||
jest.spyOn(process, 'kill').mockImplementation(() => {
|
||||
order.push('kill');
|
||||
// A real SIGTERM is asynchronous, so the exit lands after the kill returns.
|
||||
setImmediate(() => resolveExit?.());
|
||||
return true;
|
||||
});
|
||||
|
||||
await host.redialAsync();
|
||||
|
||||
expect(order).toEqual(['kill', 'exited', 'rm']);
|
||||
});
|
||||
|
||||
it('redialAsync gives up waiting when the old process does not exit', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
hostProcess.catch = () => new Promise<void>(() => {});
|
||||
|
||||
await expect(host.redialAsync()).resolves.toEqual({
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'TOKENx',
|
||||
});
|
||||
expect(mockedSleep).toHaveBeenCalledWith(5_000);
|
||||
});
|
||||
|
||||
it('continues when the previous socket dir cannot be cleared', async () => {
|
||||
mockedFs.rm.mockImplementation(((dir: string) =>
|
||||
dir === '/tmp/eas-ssh-1/upterm'
|
||||
? Promise.reject(new Error('busy'))
|
||||
: Promise.resolve(undefined)) as never);
|
||||
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
|
||||
expect(host.connectionConfig.secret).toBe('TOKENx');
|
||||
});
|
||||
|
||||
describe('getConnectedClientCountAsync', () => {
|
||||
it('throws when no admin socket exists yet (after retries)', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
mockedFs.readdir.mockResolvedValue([] as never);
|
||||
await expect(host.getConnectedClientCountAsync()).rejects.toThrow(/client count/i);
|
||||
});
|
||||
|
||||
it('returns the client count reported by the admin socket', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
sessionStdout = JSON.stringify({
|
||||
sessionId: 'TOKENx',
|
||||
host: 'relay.expo.dev',
|
||||
clientCount: 4,
|
||||
});
|
||||
expect(await host.getConnectedClientCountAsync()).toBe(4);
|
||||
});
|
||||
|
||||
it('throws when the count query fails (after retries)', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
mockedSpawn.mockImplementation((() => Promise.reject(new Error('socket gone'))) as never);
|
||||
await expect(host.getConnectedClientCountAsync()).rejects.toThrow(/client count/i);
|
||||
});
|
||||
|
||||
it('throws when the reported count is not a number (after retries)', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
sessionStdout = JSON.stringify({
|
||||
sessionId: 'TOKENx',
|
||||
host: 'relay.expo.dev',
|
||||
clientCount: 'nope',
|
||||
});
|
||||
await expect(host.getConnectedClientCountAsync()).rejects.toThrow(/client count/i);
|
||||
});
|
||||
|
||||
it('throws when the socket dir cannot be read (after retries)', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
mockedFs.readdir.mockRejectedValue(new Error('no dir') as never);
|
||||
await expect(host.getConnectedClientCountAsync()).rejects.toThrow(/client count/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,106 @@
|
||||
import { hashFiles } from '@expo/steps';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import * as tar from 'tar';
|
||||
|
||||
export const COCOAPODS_CACHE_KEY_PREFIX = 'ios-pods-';
|
||||
|
||||
const PODS_DIRECTORY_NAME = 'Pods';
|
||||
const PODFILE_LOCK_NAME = 'Podfile.lock';
|
||||
|
||||
export function getCocoapodsCachePaths(workingDirectory: string): {
|
||||
iosDirectory: string;
|
||||
podsDirectory: string;
|
||||
podfileLockPath: string;
|
||||
} {
|
||||
const iosDirectory = path.join(workingDirectory, 'ios');
|
||||
return {
|
||||
iosDirectory,
|
||||
podsDirectory: path.join(iosDirectory, PODS_DIRECTORY_NAME),
|
||||
podfileLockPath: path.join(iosDirectory, PODFILE_LOCK_NAME),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveCocoapodsCacheKeyAsync(
|
||||
workingDirectory: string,
|
||||
cocoapodsVersion: string
|
||||
): Promise<{ key: string; keyPrefix: string }> {
|
||||
const normalizedVersion = cocoapodsVersion.trim();
|
||||
if (!normalizedVersion) {
|
||||
throw new Error('Failed to determine CocoaPods version');
|
||||
}
|
||||
|
||||
const keyPrefix = `${COCOAPODS_CACHE_KEY_PREFIX}${normalizedVersion}-`;
|
||||
const { podfileLockPath } = getCocoapodsCachePaths(workingDirectory);
|
||||
|
||||
try {
|
||||
await fs.promises.access(podfileLockPath);
|
||||
} catch {
|
||||
return { key: keyPrefix, keyPrefix };
|
||||
}
|
||||
|
||||
return {
|
||||
key: `${keyPrefix}${hashFiles([podfileLockPath])}`,
|
||||
keyPrefix,
|
||||
};
|
||||
}
|
||||
|
||||
export async function compressCocoapodsCacheAsync({
|
||||
workingDirectory,
|
||||
}: {
|
||||
workingDirectory: string;
|
||||
}): Promise<{ archivePath: string }> {
|
||||
const { iosDirectory, podsDirectory } = getCocoapodsCachePaths(workingDirectory);
|
||||
await fs.promises.access(podsDirectory);
|
||||
|
||||
const archiveDestinationDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'save-cocoapods-cache-')
|
||||
);
|
||||
const archivePath = path.join(archiveDestinationDirectory, 'cache.tar.gz');
|
||||
|
||||
await tar.create(
|
||||
{
|
||||
file: archivePath,
|
||||
cwd: iosDirectory,
|
||||
gzip: true,
|
||||
},
|
||||
[PODS_DIRECTORY_NAME]
|
||||
);
|
||||
|
||||
return { archivePath };
|
||||
}
|
||||
|
||||
export async function restoreCocoapodsCacheArchiveAsync({
|
||||
archivePath,
|
||||
workingDirectory,
|
||||
}: {
|
||||
archivePath: string;
|
||||
workingDirectory: string;
|
||||
}): Promise<void> {
|
||||
const { iosDirectory, podsDirectory } = getCocoapodsCachePaths(workingDirectory);
|
||||
await fs.promises.mkdir(iosDirectory, { recursive: true });
|
||||
|
||||
const temporaryRestoreDirectory = await fs.promises.mkdtemp(
|
||||
path.join(iosDirectory, '.eas-pods-cache-')
|
||||
);
|
||||
try {
|
||||
await tar.extract({
|
||||
file: archivePath,
|
||||
cwd: temporaryRestoreDirectory,
|
||||
filter: entryPath =>
|
||||
entryPath === PODS_DIRECTORY_NAME || entryPath.startsWith(`${PODS_DIRECTORY_NAME}/`),
|
||||
});
|
||||
|
||||
const restoredPodsDirectory = path.join(temporaryRestoreDirectory, PODS_DIRECTORY_NAME);
|
||||
const restoredPodsStat = await fs.promises.stat(restoredPodsDirectory);
|
||||
if (!restoredPodsStat.isDirectory()) {
|
||||
throw new Error('CocoaPods cache archive does not contain a Pods directory');
|
||||
}
|
||||
|
||||
await fs.promises.rm(podsDirectory, { recursive: true, force: true });
|
||||
await fs.promises.rename(restoredPodsDirectory, podsDirectory);
|
||||
} finally {
|
||||
await fs.promises.rm(temporaryRestoreDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import { Sentry } from '../sentry';
|
||||
|
||||
let cachedEasCliVersionsPromise: Promise<EasCliVersions> | undefined;
|
||||
|
||||
const NPX_ENV = { NPM_CONFIG_MIN_RELEASE_AGE: '0' };
|
||||
|
||||
/**
|
||||
* Resolves the `eas-cli` versions to install for staging/production. Prefers the
|
||||
* versions committed to `cli-versions.json` (fetched from expo/eas-cli); on any
|
||||
@@ -74,21 +76,21 @@ export async function resolveEasCommandPrefixAndEnvAsync(): Promise<{
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${versions.STAGING}`],
|
||||
extraEnv: {},
|
||||
extraEnv: NPX_ENV,
|
||||
};
|
||||
} else if (process.env.ENVIRONMENT === 'staging') {
|
||||
const versions = await resolveEasCliVersionsAsync();
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${versions.STAGING}`],
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
extraEnv: { ...NPX_ENV, EXPO_STAGING: '1' },
|
||||
};
|
||||
} else {
|
||||
const versions = await resolveEasCliVersionsAsync();
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${versions.PRODUCTION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: NPX_ENV,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export function formatSecondsForLog(totalSeconds: number): string {
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const parts: string[] = [];
|
||||
if (hours > 0) {
|
||||
parts.push(hours === 1 ? '1 hour' : `${hours} hours`);
|
||||
}
|
||||
if (minutes > 0) {
|
||||
parts.push(minutes === 1 ? '1 minute' : `${minutes} minutes`);
|
||||
}
|
||||
if (seconds > 0 || parts.length === 0) {
|
||||
parts.push(seconds === 1 ? '1 second' : `${seconds} seconds`);
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
@@ -1,5 +1,26 @@
|
||||
import { ChildProcess } from 'node:child_process';
|
||||
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
export function isChildProcessAlive(child: ChildProcess): boolean {
|
||||
return child.exitCode === null && child.signalCode === null && !child.killed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a detached spawn's process group. Negated pid targets the group so bash/sleep
|
||||
* children cannot survive after the parent is gone (e.g. across upterm redial).
|
||||
*/
|
||||
export function killProcessGroup(child: ChildProcess): void {
|
||||
if (child.pid == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGTERM');
|
||||
} catch {
|
||||
child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
async function getChildrenPidsAsync(parentPids: number[]): Promise<number[]> {
|
||||
try {
|
||||
const result = await spawn('pgrep', ['-P', parentPids.join(',')], {
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { Job, SshSettings, SystemError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { graphql } from 'gql.tada';
|
||||
|
||||
import { formatSecondsForLog } from './formatDuration';
|
||||
import { sleepAsync } from './retry';
|
||||
import { SshConnectionConfig, startUptermHostAsync } from './upterm';
|
||||
import { BuildContext } from '../context';
|
||||
import { Sentry } from '../sentry';
|
||||
|
||||
const MAX_SSH_REDIALS = 10;
|
||||
const REDIAL_BACKOFF_MS = 6_000;
|
||||
const CLIENT_COUNT_POLL_INTERVAL_MS = 5_000;
|
||||
const MAX_SSH_IDLE_TIMEOUT_SECONDS = 3600;
|
||||
const DEFAULT_SSH_IDLE_TIMEOUT_SECONDS = 0;
|
||||
|
||||
const CREATE_OR_UPDATE_TURTLE_SSH_SESSION_MUTATION = graphql(`
|
||||
mutation CreateOrUpdateTurtleSshSession(
|
||||
$target: TurtleSshTargetInput!
|
||||
$connectionConfig: TurtleSshConnectionConfigInput!
|
||||
$sessionSettings: TurtleSshSessionSettingsInput!
|
||||
) {
|
||||
turtleSshSession {
|
||||
createOrUpdateTurtleSshSession(
|
||||
target: $target
|
||||
connectionConfig: $connectionConfig
|
||||
sessionSettings: $sessionSettings
|
||||
) {
|
||||
id
|
||||
sessionSettings {
|
||||
idleTimeoutSeconds
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export type TurtleSshTarget = { type: 'BUILD' | 'JOB_RUN'; id: string };
|
||||
|
||||
export function isSshEnabled(job: Pick<Job, 'ssh'>): boolean {
|
||||
return job.ssh != null;
|
||||
}
|
||||
|
||||
export function getSshIdleTimeoutSeconds(job: Pick<Job, 'ssh'>): number {
|
||||
const idleTimeoutSeconds = job.ssh?.idleTimeoutSeconds ?? DEFAULT_SSH_IDLE_TIMEOUT_SECONDS;
|
||||
if (
|
||||
!Number.isInteger(idleTimeoutSeconds) ||
|
||||
idleTimeoutSeconds < 0 ||
|
||||
idleTimeoutSeconds > MAX_SSH_IDLE_TIMEOUT_SECONDS
|
||||
) {
|
||||
throw new SystemError(
|
||||
`SSH idle timeout must be an integer between 0 and ${MAX_SSH_IDLE_TIMEOUT_SECONDS} seconds, got ${idleTimeoutSeconds}.`,
|
||||
{ trackingCode: 'SSH_IDLE_TIMEOUT_INVALID' }
|
||||
);
|
||||
}
|
||||
return idleTimeoutSeconds;
|
||||
}
|
||||
|
||||
export function getSshRelayServerUrl(job: Pick<Job, 'ssh'>): string {
|
||||
const relayServerUrl = job.ssh?.relayServerUrl;
|
||||
if (!relayServerUrl) {
|
||||
throw new SystemError('SSH is enabled but no relay server URL was configured on the job.', {
|
||||
trackingCode: 'SSH_RELAY_SERVER_URL_MISSING',
|
||||
});
|
||||
}
|
||||
return relayServerUrl;
|
||||
}
|
||||
|
||||
export type SshSessionHandle = {
|
||||
getConnectedClientCountAsync: () => Promise<number>;
|
||||
ensureConnectedAsync: () => Promise<void>;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type StartedSshSession = {
|
||||
handle: SshSessionHandle;
|
||||
idleTimeoutSeconds: number;
|
||||
};
|
||||
|
||||
async function createOrUpdateSessionAsync(
|
||||
ctx: BuildContext,
|
||||
{
|
||||
target,
|
||||
connectionConfig,
|
||||
idleTimeoutSeconds,
|
||||
}: {
|
||||
target: TurtleSshTarget;
|
||||
connectionConfig: SshConnectionConfig & { reconnecting?: boolean };
|
||||
idleTimeoutSeconds: number;
|
||||
}
|
||||
): Promise<{ idleTimeoutSeconds: number }> {
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(CREATE_OR_UPDATE_TURTLE_SSH_SESSION_MUTATION, {
|
||||
target,
|
||||
connectionConfig: {
|
||||
...connectionConfig,
|
||||
type: 'UPTERM_V1',
|
||||
},
|
||||
sessionSettings: { idleTimeoutSeconds },
|
||||
})
|
||||
.toPromise();
|
||||
if (result.error || !result.data) {
|
||||
throw new SystemError(
|
||||
`Failed to create or update the SSH session: ${result.error?.message ?? 'no data returned'}`,
|
||||
{ cause: result.error }
|
||||
);
|
||||
}
|
||||
const session = result.data.turtleSshSession.createOrUpdateTurtleSshSession;
|
||||
return { idleTimeoutSeconds: session.sessionSettings.idleTimeoutSeconds };
|
||||
}
|
||||
|
||||
export async function startSshSessionAsync(
|
||||
ctx: BuildContext,
|
||||
{
|
||||
target,
|
||||
relayServerUrl,
|
||||
idleTimeoutSeconds: requestedIdleTimeoutSeconds,
|
||||
}: {
|
||||
target: TurtleSshTarget;
|
||||
} & SshSettings
|
||||
): Promise<StartedSshSession> {
|
||||
const logger = ctx.logger;
|
||||
const host = await startUptermHostAsync(ctx, { relayServerUrl });
|
||||
let idleTimeoutSeconds: number;
|
||||
try {
|
||||
({ idleTimeoutSeconds } = await createOrUpdateSessionAsync(ctx, {
|
||||
target,
|
||||
connectionConfig: { ...host.connectionConfig, reconnecting: false },
|
||||
idleTimeoutSeconds: requestedIdleTimeoutSeconds,
|
||||
}));
|
||||
} catch (err) {
|
||||
await host.stopAsync().catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
|
||||
const ensureConnectedAsync = async (): Promise<void> => {
|
||||
if (host.isAlive()) {
|
||||
return;
|
||||
}
|
||||
for (let attempt = 1; attempt <= MAX_SSH_REDIALS; attempt++) {
|
||||
try {
|
||||
if (!host.isAlive()) {
|
||||
logger.warn('The SSH relay connection dropped. Reconnecting...');
|
||||
await createOrUpdateSessionAsync(ctx, {
|
||||
target,
|
||||
connectionConfig: { ...host.connectionConfig, reconnecting: true },
|
||||
idleTimeoutSeconds: requestedIdleTimeoutSeconds,
|
||||
}).catch(() => {});
|
||||
await host.redialAsync();
|
||||
}
|
||||
await createOrUpdateSessionAsync(ctx, {
|
||||
target,
|
||||
connectionConfig: { ...host.connectionConfig, reconnecting: false },
|
||||
idleTimeoutSeconds: requestedIdleTimeoutSeconds,
|
||||
});
|
||||
logger.info('The SSH relay connection was restored.');
|
||||
return;
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `SSH reconnect attempt ${attempt} of ${MAX_SSH_REDIALS} failed.`);
|
||||
if (attempt < MAX_SSH_REDIALS) {
|
||||
await sleepAsync(REDIAL_BACKOFF_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new SystemError(
|
||||
`The SSH relay connection dropped and could not be restored after ${MAX_SSH_REDIALS} attempts.`
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
handle: {
|
||||
getConnectedClientCountAsync: () => host.getConnectedClientCountAsync(),
|
||||
ensureConnectedAsync,
|
||||
stopAsync: () => host.stopAsync(),
|
||||
},
|
||||
idleTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
export async function superviseSshSessionAsync({
|
||||
handle,
|
||||
idleTimeoutSeconds,
|
||||
hasJobFinished,
|
||||
logger,
|
||||
}: {
|
||||
handle: SshSessionHandle;
|
||||
idleTimeoutSeconds: number;
|
||||
hasJobFinished: () => boolean;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const idleTimeoutMs = idleTimeoutSeconds * 1_000;
|
||||
let idleSince: number | null = null;
|
||||
let previousClientCount = 0;
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
await handle.ensureConnectedAsync();
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Could not restore the SSH relay connection. Closing the session.');
|
||||
Sentry.capture(
|
||||
'Could not restore the SSH relay connection',
|
||||
err instanceof Error ? err : undefined,
|
||||
{
|
||||
tags: { trackingCode: 'SSH_RELAY_RECONNECT_FAILED' },
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let connectedClientCount: number;
|
||||
try {
|
||||
connectedClientCount = await handle.getConnectedClientCountAsync();
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Could not read the SSH client count. Closing the session.');
|
||||
Sentry.capture(
|
||||
'Could not read the SSH client count',
|
||||
err instanceof Error ? err : undefined,
|
||||
{
|
||||
tags: { trackingCode: 'SSH_CLIENT_COUNT_UNREADABLE' },
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (connectedClientCount !== previousClientCount) {
|
||||
logger.info(`SSH clients connected: ${connectedClientCount}`);
|
||||
previousClientCount = connectedClientCount;
|
||||
}
|
||||
|
||||
const jobHasFinished = hasJobFinished();
|
||||
if (connectedClientCount > 0 || !jobHasFinished) {
|
||||
idleSince = null;
|
||||
} else if (idleSince === null) {
|
||||
idleSince = Date.now();
|
||||
}
|
||||
|
||||
if (jobHasFinished && connectedClientCount === 0) {
|
||||
if (idleTimeoutSeconds === 0) {
|
||||
logger.info('The job finished and no SSH client is connected. Closing the session.');
|
||||
return;
|
||||
}
|
||||
if (idleSince !== null && Date.now() - idleSince >= idleTimeoutMs) {
|
||||
logger.info(
|
||||
`No SSH client connected for ${formatSecondsForLog(idleTimeoutSeconds)} after the job finished. Closing the session.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await sleepAsync(CLIENT_COUNT_POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { Env, SystemError } from '@expo/eas-build-job';
|
||||
import downloadFile from '@expo/downloader';
|
||||
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isChildProcessAlive, killProcessGroup } from './processes';
|
||||
import { sleepAsync } from './retry';
|
||||
import { BuildContext } from '../context';
|
||||
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g;
|
||||
|
||||
export function resolveUptermGcsObjectName(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
arch: string = process.arch
|
||||
): string {
|
||||
if (platform === 'darwin' && arch === 'arm64') {
|
||||
return 'upterm-darwin-arm64';
|
||||
}
|
||||
if (platform === 'linux' && arch === 'x64') {
|
||||
return 'upterm-linux-amd64';
|
||||
}
|
||||
throw new SystemError(
|
||||
`SSH upterm is only available on darwin/arm64 and linux/x64 (got ${platform}/${arch}).`
|
||||
);
|
||||
}
|
||||
|
||||
const UPTERM_GCS_BASE_URL = 'https://storage.googleapis.com/turtle-v2/upterm';
|
||||
const UPTERM_DOWNLOAD_TIMEOUT_MS = 60_000;
|
||||
const UPTERM_KEEPALIVE_SLEEP_SECONDS = 6 * 60 * 60;
|
||||
const CONNECTION_POLL_INTERVAL_MS = 500;
|
||||
const CONNECTION_STARTUP_TIMEOUT_MS = 60_000;
|
||||
const PROCESS_EXIT_TIMEOUT_MS = 5_000;
|
||||
const CLIENT_COUNT_READ_ATTEMPTS = 4;
|
||||
const CLIENT_COUNT_READ_RETRY_MS = 500;
|
||||
const DEFAULT_SSH_PORT = '22';
|
||||
|
||||
export type SshConnectionConfig = {
|
||||
type: 'upterm-v1';
|
||||
host: string;
|
||||
secret: string;
|
||||
};
|
||||
|
||||
export type UptermHost = {
|
||||
connectionConfig: SshConnectionConfig;
|
||||
getConnectedClientCountAsync: () => Promise<number>;
|
||||
isAlive: () => boolean;
|
||||
redialAsync: () => Promise<SshConnectionConfig>;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
const UptermSessionJsonZ = z.object({
|
||||
sessionId: z.string().min(1),
|
||||
host: z.string().min(1),
|
||||
clientCount: z.number().optional(),
|
||||
});
|
||||
type UptermSessionJson = z.infer<typeof UptermSessionJsonZ>;
|
||||
|
||||
export function connectionConfigFromUptermSession(
|
||||
parsed: Pick<UptermSessionJson, 'sessionId' | 'host'>
|
||||
): SshConnectionConfig | null {
|
||||
let host = parsed.host;
|
||||
if (host.includes('://')) {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(host);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
host = url.port && url.port !== DEFAULT_SSH_PORT ? `${url.hostname}:${url.port}` : url.hostname;
|
||||
}
|
||||
if (!host) {
|
||||
return null;
|
||||
}
|
||||
return { type: 'upterm-v1', host, secret: parsed.sessionId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip upterm session secrets out of text before it reaches a log or an error message. upterm
|
||||
* prints the session id as the userinfo of the connect line it advertises
|
||||
* (`upterm proxy ws(s)://<sessionId>@host`) and repeats it bare as the ssh destination on the
|
||||
* same line. Control characters are dropped first so they cannot split a token mid-match. The
|
||||
* loop lifts the id out of the proxy URL and removes every occurrence of it; the final replace
|
||||
* blanks userinfo in any other URL as a catch-all.
|
||||
*/
|
||||
export function redactConnectionSecrets(text: string): string {
|
||||
let redacted = text.replace(CONTROL_CHARACTERS, '');
|
||||
for (const [, token] of redacted.matchAll(/upterm proxy wss?:\/\/([^@\s]+)@/g)) {
|
||||
redacted = redacted.split(token).join('<redacted>');
|
||||
}
|
||||
return redacted.replace(/([a-z][a-z0-9+.-]*:\/\/)[^@\s/]+@/gi, '$1<redacted>@');
|
||||
}
|
||||
|
||||
export function redactSpawnErrorForLog(err: unknown): unknown {
|
||||
if (!err || typeof err !== 'object') {
|
||||
return err;
|
||||
}
|
||||
const spawnErr = err as { message?: unknown; stdout?: unknown; stderr?: unknown };
|
||||
return {
|
||||
...spawnErr,
|
||||
...(typeof spawnErr.message === 'string'
|
||||
? { message: redactConnectionSecrets(spawnErr.message) }
|
||||
: {}),
|
||||
...(typeof spawnErr.stdout === 'string'
|
||||
? { stdout: redactConnectionSecrets(spawnErr.stdout) }
|
||||
: {}),
|
||||
...(typeof spawnErr.stderr === 'string'
|
||||
? { stderr: redactConnectionSecrets(spawnErr.stderr) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveUptermPathAsync(env: Env): Promise<string> {
|
||||
try {
|
||||
await spawn('upterm', ['version'], { stdio: 'pipe', env });
|
||||
return 'upterm';
|
||||
} catch {}
|
||||
|
||||
const objectName = resolveUptermGcsObjectName();
|
||||
const downloadDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-upterm-'));
|
||||
const uptermPath = path.join(downloadDir, objectName);
|
||||
const url = `${UPTERM_GCS_BASE_URL}/${objectName}`;
|
||||
try {
|
||||
await downloadFile(url, uptermPath, { retry: 3, timeout: UPTERM_DOWNLOAD_TIMEOUT_MS });
|
||||
await fs.chmod(uptermPath, 0o755);
|
||||
} catch (err) {
|
||||
await fs.rm(downloadDir, { recursive: true, force: true }).catch(() => {});
|
||||
throw new SystemError(
|
||||
`The upterm SSH client was not on PATH and could not be downloaded from ${url}. ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`
|
||||
);
|
||||
}
|
||||
return uptermPath;
|
||||
}
|
||||
|
||||
async function findAdminSocketPathAsync(uptermSocketDir: string): Promise<string | null> {
|
||||
// Use this dial's own admin socket, not upterm's default, which can still point at a previous
|
||||
// dial's session after a redial and break client-count reads.
|
||||
const entries = await fs.readdir(uptermSocketDir).catch(() => [] as string[]);
|
||||
const socketName = entries.find(entry => entry.endsWith('.sock'));
|
||||
return socketName ? path.join(uptermSocketDir, socketName) : null;
|
||||
}
|
||||
|
||||
async function readCurrentSessionJsonAsync(
|
||||
uptermPath: string,
|
||||
adminSocketPath: string
|
||||
): Promise<UptermSessionJson | null> {
|
||||
try {
|
||||
const result = await spawn(
|
||||
uptermPath,
|
||||
['session', 'current', '--admin-socket', adminSocketPath, '--output', 'json'],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
const parsed = UptermSessionJsonZ.safeParse(JSON.parse(result.stdout));
|
||||
return parsed.success ? parsed.data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForConnectionConfigAsync(
|
||||
uptermPath: string,
|
||||
uptermSocketDir: string,
|
||||
getHostOutput: () => string
|
||||
): Promise<SshConnectionConfig> {
|
||||
const deadline = Date.now() + CONNECTION_STARTUP_TIMEOUT_MS;
|
||||
for (;;) {
|
||||
const adminSocketPath = await findAdminSocketPathAsync(uptermSocketDir);
|
||||
if (adminSocketPath) {
|
||||
const session = await readCurrentSessionJsonAsync(uptermPath, adminSocketPath);
|
||||
if (session) {
|
||||
const connectionConfig = connectionConfigFromUptermSession(session);
|
||||
if (connectionConfig) {
|
||||
return connectionConfig;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new SystemError(
|
||||
`The upterm client did not register with the relay within ${
|
||||
CONNECTION_STARTUP_TIMEOUT_MS / 1_000
|
||||
}s. Output:\n${redactConnectionSecrets(getHostOutput())}`
|
||||
);
|
||||
}
|
||||
await sleepAsync(CONNECTION_POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
export async function startUptermHostAsync(
|
||||
ctx: BuildContext,
|
||||
{ relayServerUrl }: { relayServerUrl: string }
|
||||
): Promise<UptermHost> {
|
||||
const uptermPath = await resolveUptermPathAsync(ctx.env);
|
||||
|
||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-ssh-'));
|
||||
const hostKeyPath = path.join(stateDir, 'id_host');
|
||||
const forceCommandPath = path.join(stateDir, 'join.sh');
|
||||
const uptermSocketDir = path.join(stateDir, 'upterm');
|
||||
|
||||
await spawn('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', hostKeyPath, '-q'], {
|
||||
logger: ctx.logger,
|
||||
});
|
||||
await fs.writeFile(forceCommandPath, '#!/usr/bin/env bash\nexec bash -l\n', { mode: 0o755 });
|
||||
|
||||
let currentProcess: SpawnPromise<SpawnResult> | null = null;
|
||||
|
||||
const stopCurrentProcessAsync = async (): Promise<void> => {
|
||||
const previousProcess = currentProcess;
|
||||
currentProcess = null;
|
||||
if (!previousProcess) {
|
||||
return;
|
||||
}
|
||||
killProcessGroup(previousProcess.child);
|
||||
await Promise.race([
|
||||
previousProcess.catch(() => {}),
|
||||
sleepAsync(PROCESS_EXIT_TIMEOUT_MS).then(() => {
|
||||
ctx.logger.debug('The previous upterm host process did not exit in time.');
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
const getConnectedClientCountAsync = async (): Promise<number> => {
|
||||
for (let attempt = 1; attempt <= CLIENT_COUNT_READ_ATTEMPTS; attempt++) {
|
||||
const adminSocketPath = await findAdminSocketPathAsync(uptermSocketDir);
|
||||
const session = adminSocketPath
|
||||
? await readCurrentSessionJsonAsync(uptermPath, adminSocketPath)
|
||||
: null;
|
||||
if (session && typeof session.clientCount === 'number') {
|
||||
return session.clientCount;
|
||||
}
|
||||
if (attempt < CLIENT_COUNT_READ_ATTEMPTS) {
|
||||
await sleepAsync(CLIENT_COUNT_READ_RETRY_MS);
|
||||
}
|
||||
}
|
||||
throw new SystemError('Could not read the SSH client count from the upterm admin socket.', {
|
||||
trackingCode: 'SSH_CLIENT_COUNT_UNREADABLE',
|
||||
});
|
||||
};
|
||||
|
||||
const dialAsync = async (): Promise<SshConnectionConfig> => {
|
||||
await stopCurrentProcessAsync();
|
||||
await fs.rm(uptermSocketDir, { recursive: true, force: true }).catch(err => {
|
||||
ctx.logger.debug({ err }, 'Failed to clear the previous SSH socket directory.');
|
||||
});
|
||||
|
||||
ctx.logger.debug('Connecting to the SSH relay.');
|
||||
// --force-command is what each connecting SSH client runs (a login shell). The `sleep` after
|
||||
// `--` is the host-side process that keeps `upterm host` up while nobody is connected.
|
||||
const uptermProcess = spawn(
|
||||
uptermPath,
|
||||
[
|
||||
'host',
|
||||
'--server',
|
||||
relayServerUrl,
|
||||
'--accept',
|
||||
'--skip-host-key-check',
|
||||
'-i',
|
||||
hostKeyPath,
|
||||
'--force-command',
|
||||
forceCommandPath,
|
||||
'--',
|
||||
'bash',
|
||||
'-lc',
|
||||
`sleep ${UPTERM_KEEPALIVE_SLEEP_SECONDS}`,
|
||||
],
|
||||
{
|
||||
// upterm puts its admin socket under XDG_RUNTIME_DIR; point it at our state dir so we can
|
||||
// find it for `session current` and clean it up on stop/redial.
|
||||
env: { ...ctx.env, XDG_RUNTIME_DIR: stateDir },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
}
|
||||
);
|
||||
uptermProcess.catch(err =>
|
||||
ctx.logger.debug({ err: redactSpawnErrorForLog(err) }, 'The upterm host process exited.')
|
||||
);
|
||||
uptermProcess.child.unref();
|
||||
currentProcess = uptermProcess;
|
||||
|
||||
let output = '';
|
||||
const appendChunk = (chunk: Buffer | string): void => {
|
||||
output += chunk.toString();
|
||||
};
|
||||
uptermProcess.child.stdout?.on('data', appendChunk);
|
||||
uptermProcess.child.stderr?.on('data', appendChunk);
|
||||
|
||||
return await waitForConnectionConfigAsync(uptermPath, uptermSocketDir, () => output);
|
||||
};
|
||||
|
||||
const stopAsync = async (): Promise<void> => {
|
||||
await stopCurrentProcessAsync();
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
let connectionConfig: SshConnectionConfig;
|
||||
try {
|
||||
connectionConfig = await dialAsync();
|
||||
} catch (err) {
|
||||
await stopAsync();
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
get connectionConfig() {
|
||||
return connectionConfig;
|
||||
},
|
||||
getConnectedClientCountAsync,
|
||||
isAlive: () => currentProcess != null && isChildProcessAlive(currentProcess.child),
|
||||
redialAsync: async () => {
|
||||
connectionConfig = await dialAsync();
|
||||
return connectionConfig;
|
||||
},
|
||||
stopAsync,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "22.2.0",
|
||||
"version": "23.1.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "22.1.0"
|
||||
"@expo/steps": "23.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "22.0.0",
|
||||
"version": "23.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "22.0.0",
|
||||
"version": "23.0.0",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "22.0.0",
|
||||
"version": "23.1.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,9 +25,9 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "22.0.0",
|
||||
"@expo/logger": "23.1.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "22.0.0",
|
||||
"@expo/turtle-spawn": "23.1.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
+337
-214
File diff suppressed because it is too large.
Load diff
Generated
+1713
-25
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "22.2.0",
|
||||
"version": "23.2.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,11 +48,11 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "22.0.0",
|
||||
"@expo/eas-json": "22.0.0",
|
||||
"@expo/eas-build-job": "23.1.0",
|
||||
"@expo/eas-json": "23.1.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "22.0.0",
|
||||
"@expo/logger": "23.1.0",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -64,7 +64,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "22.1.0",
|
||||
"@expo/steps": "23.1.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -72,11 +72,12 @@
|
||||
"@sentry/node": "7.77.0",
|
||||
"@urql/core": "4.0.11",
|
||||
"@urql/exchange-retry": "1.2.0",
|
||||
"agent-cli-detector": "0.1.6",
|
||||
"agent-cli-detector": "0.1.7",
|
||||
"ajv": "8.11.0",
|
||||
"ajv-formats": "2.1.1",
|
||||
"better-opn": "3.0.2",
|
||||
"bplist-parser": "^0.3.0",
|
||||
"centrifuge": "5.7.1",
|
||||
"chalk": "4.1.2",
|
||||
"cli-progress": "3.12.0",
|
||||
"dateformat": "4.6.3",
|
||||
@@ -104,12 +105,13 @@
|
||||
"log-symbols": "4.1.0",
|
||||
"mime": "3.0.0",
|
||||
"minimatch": "5.1.2",
|
||||
"minizlib": "3.0.1",
|
||||
"minizlib": "3.1.0",
|
||||
"nanoid": "3.3.8",
|
||||
"node-fetch": "2.6.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",
|
||||
@@ -129,8 +131,9 @@
|
||||
"tslib": "2.6.2",
|
||||
"turndown": "7.1.2",
|
||||
"untildify": "4.0.0",
|
||||
"uuid": "9.0.1",
|
||||
"uuid": "11.1.1",
|
||||
"wrap-ansi": "7.0.0",
|
||||
"ws": "8.21.1",
|
||||
"yaml": "2.6.0",
|
||||
"zod": "^4.1.3"
|
||||
},
|
||||
@@ -158,8 +161,8 @@
|
||||
"@types/tar": "6.1.10",
|
||||
"@types/tar-stream": "3.1.3",
|
||||
"@types/tough-cookie": "4.0.2",
|
||||
"@types/uuid": "9.0.7",
|
||||
"@types/wrap-ansi": "3.0.0",
|
||||
"@types/ws": "8.5.10",
|
||||
"axios": "1.18.1",
|
||||
"eslint-plugin-graphql": "4.0.0",
|
||||
"jest": "29.7.0",
|
||||
@@ -189,6 +192,9 @@
|
||||
"account": {
|
||||
"description": "manage account"
|
||||
},
|
||||
"billing": {
|
||||
"description": "manage account billing and subscriptions"
|
||||
},
|
||||
"branch": {
|
||||
"description": "manage update branches"
|
||||
},
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { getConfigFilePaths } from '@expo/config';
|
||||
|
||||
import { getWorkflowRunUrl } from '../../build/utils/url';
|
||||
import Go from '../../commands/go';
|
||||
import Go, { toRepackTargetSdkVersion } from '../../commands/go';
|
||||
import { WorkflowRunStatus } from '../../graphql/generated';
|
||||
import { WorkflowRunMutation } from '../../graphql/mutations/WorkflowRunMutation';
|
||||
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
|
||||
import Log from '../../log';
|
||||
import { selectAsync } from '../../prompts';
|
||||
import { detectProjectSdkVersionAsync } from '../../project/detectProjectSdkVersionAsync';
|
||||
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
|
||||
import { uploadAccountScopedFileAsync } from '../../project/uploadAccountScopedFileAsync';
|
||||
import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccountScopedProjectSourceAsync';
|
||||
import { ensureActorHasPrimaryAccount } from '../../user/actions';
|
||||
import { detectProjectSdkVersionAsync, toRepackTargetSdkVersion } from '../../commands/go';
|
||||
import { mockTestCommand } from './utils';
|
||||
|
||||
jest.mock('@expo/config', () => ({
|
||||
|
||||
@@ -8,10 +8,7 @@ import {
|
||||
mockProjectId,
|
||||
mockTestCommand,
|
||||
} from './utils';
|
||||
import {
|
||||
fetchRawLogsForBuildJobAsync,
|
||||
fetchRawLogsForCustomJobAsync,
|
||||
} from '../../commandUtils/workflow/fetchLogs';
|
||||
import { fetchRawLogsForJobAsync } from '../../commandUtils/workflow/logs/fetchLogs';
|
||||
import WorkflowLogView from '../../commands/workflow/logs';
|
||||
import { AppPlatform, BuildPriority, BuildStatus } from '../../graphql/generated';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
@@ -38,7 +35,7 @@ jest.mock('fs');
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../prompts');
|
||||
jest.mock('../../utils/json');
|
||||
jest.mock('../../commandUtils/workflow/fetchLogs');
|
||||
jest.mock('../../commandUtils/workflow/logs/fetchLogs');
|
||||
|
||||
describe(WorkflowLogView, () => {
|
||||
beforeEach(() => {
|
||||
@@ -49,6 +46,7 @@ describe(WorkflowLogView, () => {
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(fetchRawLogsForJobAsync).mockReset();
|
||||
});
|
||||
test('view logs, passing in no parameters, no runs found', async () => {
|
||||
const ctx = mockCommandContext(WorkflowLogView, {
|
||||
@@ -105,7 +103,7 @@ describe(WorkflowLogView, () => {
|
||||
return { selectedStep: 'step1' };
|
||||
}
|
||||
});
|
||||
jest.mocked(fetchRawLogsForCustomJobAsync).mockResolvedValue(null);
|
||||
jest.mocked(fetchRawLogsForJobAsync).mockResolvedValue(null);
|
||||
await cmd.run();
|
||||
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
@@ -139,7 +137,7 @@ describe(WorkflowLogView, () => {
|
||||
}
|
||||
});
|
||||
jest
|
||||
.mocked(fetchRawLogsForCustomJobAsync)
|
||||
.mocked(fetchRawLogsForJobAsync)
|
||||
.mockResolvedValue(
|
||||
'{"result":"success","marker":"end-step","buildStepDisplayName":"Install dependencies","buildStepId":"step-id-1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
|
||||
);
|
||||
@@ -209,7 +207,7 @@ describe(WorkflowLogView, () => {
|
||||
}
|
||||
});
|
||||
jest
|
||||
.mocked(fetchRawLogsForBuildJobAsync)
|
||||
.mocked(fetchRawLogsForJobAsync)
|
||||
.mockResolvedValue(
|
||||
'{"result":"success","marker":"end-step","buildStepDisplayName":"step1","buildStepId":"step-id-1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
|
||||
);
|
||||
@@ -255,7 +253,7 @@ describe(WorkflowLogView, () => {
|
||||
}
|
||||
});
|
||||
jest
|
||||
.mocked(fetchRawLogsForCustomJobAsync)
|
||||
.mocked(fetchRawLogsForJobAsync)
|
||||
.mockResolvedValue(
|
||||
[
|
||||
'{"result":"success","marker":"end-step","buildStepDisplayName":"Install","buildStepId":"step-id-1","time":"2022-01-01T00:00:00.000Z","msg":"first"}',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -112,3 +112,13 @@ export function getEASUpdateURL(projectId: string, manifestHostOverride: string
|
||||
export function getExpoApiWorkflowSchemaURL(): string {
|
||||
return getExpoApiBaseUrl() + '/v2/workflows/schema';
|
||||
}
|
||||
|
||||
export function getEASLogsWebsocketUrl(): string {
|
||||
if (process.env.EXPO_STAGING) {
|
||||
return `wss://staging-logs.expo.dev/connection/websocket`;
|
||||
} else if (process.env.EXPO_LOCAL) {
|
||||
return `ws://localhost:4997/connection/websocket`;
|
||||
} else {
|
||||
return `wss://logs.expo.dev/connection/websocket`;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -1,68 +1,109 @@
|
||||
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../../onboarding/git';
|
||||
import glob from 'fast-glob';
|
||||
import fs from 'fs-extra';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { extract } from 'tar';
|
||||
|
||||
import fetch from '../../../fetch';
|
||||
import { installDependenciesAsync } from '../../../onboarding/installDependencies';
|
||||
import { runCommandAsync } from '../../../onboarding/runCommand';
|
||||
import { expoCommandAsync } from '../../../utils/expoCli';
|
||||
import {
|
||||
cloneTemplateAsync,
|
||||
downloadTemplateAsync,
|
||||
fetchTemplatePackumentAsync,
|
||||
initializeGitRepositoryAsync,
|
||||
installProjectDependenciesAsync,
|
||||
} from '../commands';
|
||||
|
||||
jest.mock('../../../utils/expoCli');
|
||||
jest.mock('../../../onboarding/git');
|
||||
jest.mock('../../../fetch');
|
||||
jest.mock('../../../onboarding/runCommand');
|
||||
jest.mock('../../../onboarding/installDependencies');
|
||||
jest.mock('../../../ora');
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('tar');
|
||||
jest.mock('stream/promises');
|
||||
jest.mock('fast-glob');
|
||||
|
||||
describe('commands', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('cloneTemplateAsync', () => {
|
||||
it('should clone the template project with ssh', async () => {
|
||||
describe('fetchTemplatePackumentAsync', () => {
|
||||
it('should fetch the packument from the npm registry', async () => {
|
||||
const packument = { 'dist-tags': { latest: '57.0.19' } };
|
||||
jest.mocked(fetch).mockResolvedValueOnce({ json: async () => packument } as any);
|
||||
|
||||
expect(await fetchTemplatePackumentAsync()).toEqual(packument);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://registry.npmjs.org/expo-template-default',
|
||||
expect.objectContaining({
|
||||
headers: { accept: 'application/vnd.npm.install-v1+json' },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadTemplateAsync', () => {
|
||||
const template = {
|
||||
npmTag: 'sdk-56',
|
||||
version: '56.0.34',
|
||||
tarballUrl: 'https://registry.npmjs.org/expo-template-default/-/56.0.34.tgz',
|
||||
};
|
||||
|
||||
it('should download and extract the template', async () => {
|
||||
const targetProjectDir = '/test/target-project';
|
||||
const finalTargetProjectDir = '/test/final-target-project';
|
||||
const tarballBody = 'tarball-stream';
|
||||
|
||||
jest.mocked(canAccessRepositoryUsingSshAsync).mockResolvedValue(true);
|
||||
jest.mocked(runGitCloneAsync).mockResolvedValue({
|
||||
targetProjectDir: finalTargetProjectDir,
|
||||
});
|
||||
jest.mocked(fs.pathExists).mockResolvedValueOnce(false as never);
|
||||
jest.mocked(fetch).mockResolvedValueOnce({ body: tarballBody } as any);
|
||||
jest.mocked(glob).mockResolvedValueOnce(['gitignore', '_vscode'] as any);
|
||||
|
||||
const result = await cloneTemplateAsync(targetProjectDir);
|
||||
const result = await downloadTemplateAsync(targetProjectDir, template);
|
||||
|
||||
expect(runGitCloneAsync).toHaveBeenCalledWith({
|
||||
githubUsername: 'expo',
|
||||
githubRepositoryName: 'expo-template-default',
|
||||
targetProjectDir,
|
||||
cloneMethod: 'ssh',
|
||||
showOutput: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(finalTargetProjectDir);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://registry.npmjs.org/expo-template-default/-/56.0.34.tgz'
|
||||
);
|
||||
expect(extract).toHaveBeenCalledWith({ cwd: targetProjectDir, strip: 1 });
|
||||
expect(pipeline).toHaveBeenCalledWith(tarballBody, extract({} as any));
|
||||
expect(fs.move).toHaveBeenCalledWith(
|
||||
'/test/target-project/gitignore',
|
||||
'/test/target-project/.gitignore',
|
||||
{ overwrite: true }
|
||||
);
|
||||
expect(fs.move).toHaveBeenCalledWith(
|
||||
'/test/target-project/_vscode',
|
||||
'/test/target-project/.vscode',
|
||||
{ overwrite: true }
|
||||
);
|
||||
expect(result).toBe(targetProjectDir);
|
||||
});
|
||||
|
||||
it('should clone the template project with https', async () => {
|
||||
const targetProjectDir = '/test/target-project';
|
||||
const finalTargetProjectDir = '/test/final-target-project';
|
||||
it('should throw when the target directory exists and is not empty', async () => {
|
||||
jest.mocked(fs.pathExists).mockResolvedValueOnce(true as never);
|
||||
jest.mocked(fs.readdir).mockResolvedValueOnce(['existing-file'] as never);
|
||||
|
||||
jest.mocked(canAccessRepositoryUsingSshAsync).mockResolvedValue(false);
|
||||
jest.mocked(runGitCloneAsync).mockResolvedValue({
|
||||
targetProjectDir: finalTargetProjectDir,
|
||||
});
|
||||
await expect(downloadTemplateAsync('/test/target-project', template)).rejects.toThrow(
|
||||
'already exists and is not empty'
|
||||
);
|
||||
});
|
||||
|
||||
const result = await cloneTemplateAsync(targetProjectDir);
|
||||
it('should not remove a pre-existing directory on failure', async () => {
|
||||
jest.mocked(fs.pathExists).mockResolvedValueOnce(true as never);
|
||||
jest.mocked(fs.readdir).mockResolvedValueOnce(['existing-file'] as never);
|
||||
|
||||
expect(runGitCloneAsync).toHaveBeenCalledWith({
|
||||
githubUsername: 'expo',
|
||||
githubRepositoryName: 'expo-template-default',
|
||||
targetProjectDir,
|
||||
cloneMethod: 'https',
|
||||
showOutput: false,
|
||||
});
|
||||
await expect(downloadTemplateAsync('/test/target-project', template)).rejects.toThrow();
|
||||
expect(fs.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(result).toBe(finalTargetProjectDir);
|
||||
it('should clean up a partially extracted directory on failure', async () => {
|
||||
jest.mocked(fs.pathExists).mockResolvedValueOnce(false as never);
|
||||
jest.mocked(fetch).mockRejectedValueOnce(new Error('network error'));
|
||||
|
||||
await expect(downloadTemplateAsync('/test/target-project', template)).rejects.toThrow(
|
||||
'network error'
|
||||
);
|
||||
expect(fs.remove).toHaveBeenCalledWith('/test/target-project');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
copyProjectTemplatesAsync,
|
||||
generateAppConfigAsync,
|
||||
generateEasConfigAsync,
|
||||
removeTemplateFilesAsync,
|
||||
updatePackageJsonAsync,
|
||||
updateReadmeAsync,
|
||||
} from '../projectFiles';
|
||||
@@ -239,17 +240,24 @@ describe('projectFiles', () => {
|
||||
});
|
||||
|
||||
describe('updatePackageJsonAsync', () => {
|
||||
it('should update the package.json', async () => {
|
||||
it('should replace template metadata and add workflow scripts', async () => {
|
||||
jest.mocked(fs.readJson).mockResolvedValue({
|
||||
name: 'test-app',
|
||||
version: '1.0.0',
|
||||
name: 'expo-template-default',
|
||||
version: '57.0.19',
|
||||
license: '0BSD',
|
||||
description: 'A starter template',
|
||||
repository: 'https://github.com/expo/expo-template-default',
|
||||
tags: ['expo'],
|
||||
main: 'expo-router/entry',
|
||||
});
|
||||
const projectDir = '/test/project-dir';
|
||||
await updatePackageJsonAsync(projectDir);
|
||||
await updatePackageJsonAsync(projectDir, 'My App');
|
||||
|
||||
const expectedPackageJson = {
|
||||
name: 'test-app',
|
||||
name: 'myapp',
|
||||
version: '1.0.0',
|
||||
private: true,
|
||||
main: 'expo-router/entry',
|
||||
scripts: {
|
||||
draft: 'npx eas-cli@latest workflow:run create-draft.yml',
|
||||
'development-builds': 'npx eas-cli@latest workflow:run create-development-builds.yml',
|
||||
@@ -261,6 +269,28 @@ describe('projectFiles', () => {
|
||||
spaces: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep a non-template license', async () => {
|
||||
jest.mocked(fs.readJson).mockResolvedValue({
|
||||
name: 'some-template',
|
||||
license: 'MIT',
|
||||
});
|
||||
await updatePackageJsonAsync('/test/project-dir', 'my-app');
|
||||
|
||||
expect(fs.writeJson).toHaveBeenCalledWith(
|
||||
'/test/project-dir/package.json',
|
||||
expect.objectContaining({ license: 'MIT' }),
|
||||
{ spaces: 2 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeTemplateFilesAsync', () => {
|
||||
it('should remove the template LICENSE file', async () => {
|
||||
await removeTemplateFilesAsync('/test/project-dir');
|
||||
|
||||
expect(fs.remove).toHaveBeenCalledWith('/test/project-dir/LICENSE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyProjectTemplatesAsync', () => {
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import fetch from '../../../fetch';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import { fetchTemplatePackumentAsync } from '../commands';
|
||||
import {
|
||||
fetchSdkVersionsAsync,
|
||||
promptForSdkVersionAsync,
|
||||
resolveTemplateAsync,
|
||||
} from '../sdkVersion';
|
||||
|
||||
jest.mock('../../../fetch');
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../api', () => ({
|
||||
getExpoApiBaseUrl: () => 'https://api.expo.dev',
|
||||
}));
|
||||
jest.mock('../commands');
|
||||
|
||||
const packument = {
|
||||
'dist-tags': { latest: '57.0.19', 'sdk-57': '57.0.19', 'sdk-56': '56.0.34' },
|
||||
versions: {
|
||||
'57.0.19': {
|
||||
dist: { tarball: 'https://registry.npmjs.org/expo-template-default/-/57.0.19.tgz' },
|
||||
},
|
||||
'56.0.34': {
|
||||
dist: { tarball: 'https://registry.npmjs.org/expo-template-default/-/56.0.34.tgz' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function mockVersionsEndpoint(json: object): void {
|
||||
jest.mocked(fetch).mockResolvedValueOnce({ json: async () => json } as any);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(fetchTemplatePackumentAsync).mockResolvedValue(packument);
|
||||
});
|
||||
|
||||
describe('fetchSdkVersionsAsync', () => {
|
||||
it('returns released SDK versions sorted descending', async () => {
|
||||
mockVersionsEndpoint({
|
||||
sdkVersions: {
|
||||
'55.0.0': { releaseNoteUrl: 'https://expo.dev/changelog/sdk-55' },
|
||||
'57.0.0': { releaseNoteUrl: 'https://expo.dev/changelog/sdk-57' },
|
||||
'56.0.0': { releaseNoteUrl: 'https://expo.dev/changelog/sdk-56' },
|
||||
'58.0.0': {}, // canary, no release notes
|
||||
'54.0.0': { releaseNoteUrl: 'https://expo.dev/changelog/sdk-54', isDeprecated: true },
|
||||
},
|
||||
expoGoSdkVersion: '56.0.0',
|
||||
});
|
||||
|
||||
const versions = await fetchSdkVersionsAsync();
|
||||
|
||||
expect(versions).toEqual({
|
||||
latest: 57,
|
||||
expoGoCompatible: 56,
|
||||
available: [57, 56, 55],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when the endpoint is unreachable', async () => {
|
||||
jest.mocked(fetch).mockRejectedValueOnce(new Error('network error'));
|
||||
|
||||
expect(await fetchSdkVersionsAsync()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no released versions are available', async () => {
|
||||
mockVersionsEndpoint({ sdkVersions: {} });
|
||||
|
||||
expect(await fetchSdkVersionsAsync()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptForSdkVersionAsync', () => {
|
||||
it('offers latest and Expo Go compatible choices', async () => {
|
||||
jest.mocked(promptAsync).mockResolvedValueOnce({ answer: 56 });
|
||||
|
||||
const result = await promptForSdkVersionAsync({
|
||||
latest: 57,
|
||||
expoGoCompatible: 56,
|
||||
available: [57, 56, 55],
|
||||
});
|
||||
|
||||
expect(result).toBe(56);
|
||||
expect(promptAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Select an Expo SDK version:',
|
||||
choices: [
|
||||
expect.objectContaining({ title: 'Latest (SDK 57)', value: 57 }),
|
||||
expect.objectContaining({
|
||||
title: 'For learning with Expo Go (SDK 56)',
|
||||
value: 56,
|
||||
}),
|
||||
expect.objectContaining({ title: 'Other SDK version…', value: 'other' }),
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the Expo Go choice when it matches the latest SDK or is unavailable', async () => {
|
||||
jest.mocked(promptAsync).mockResolvedValue({ answer: 57 });
|
||||
|
||||
await promptForSdkVersionAsync({ latest: 57, expoGoCompatible: 57, available: [57, 56] });
|
||||
await promptForSdkVersionAsync({ latest: 57, expoGoCompatible: 54, available: [57, 56] });
|
||||
|
||||
for (const call of jest.mocked(promptAsync).mock.calls) {
|
||||
expect(call[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
choices: [
|
||||
expect.objectContaining({ title: 'Latest (SDK 57)' }),
|
||||
expect.objectContaining({ title: 'Other SDK version…' }),
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('hides the "Other" choice when no other versions are available', async () => {
|
||||
jest.mocked(promptAsync).mockResolvedValueOnce({ answer: 57 });
|
||||
|
||||
await promptForSdkVersionAsync({ latest: 57, expoGoCompatible: null, available: [57] });
|
||||
|
||||
expect(promptAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
choices: [expect.objectContaining({ title: 'Latest (SDK 57)' })],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('offers the remaining versions when "other" is selected', async () => {
|
||||
jest
|
||||
.mocked(promptAsync)
|
||||
.mockResolvedValueOnce({ answer: 'other' })
|
||||
.mockResolvedValueOnce({ sdkVersion: 53 });
|
||||
|
||||
const result = await promptForSdkVersionAsync({
|
||||
latest: 57,
|
||||
expoGoCompatible: 56,
|
||||
available: [57, 56, 55, 54, 53],
|
||||
});
|
||||
|
||||
expect(result).toBe(53);
|
||||
expect(promptAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Select an SDK version:',
|
||||
choices: [
|
||||
{ title: 'SDK 55', value: 55 },
|
||||
{ title: 'SDK 54', value: 54 },
|
||||
{ title: 'SDK 53', value: 53 },
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTemplateAsync', () => {
|
||||
it('uses the provided SDK version without prompting', async () => {
|
||||
expect(await resolveTemplateAsync({ sdkVersion: '56' })).toEqual({
|
||||
npmTag: 'sdk-56',
|
||||
version: '56.0.34',
|
||||
tarballUrl: 'https://registry.npmjs.org/expo-template-default/-/56.0.34.tgz',
|
||||
});
|
||||
expect((await resolveTemplateAsync({ sdkVersion: 'sdk-57' })).npmTag).toBe('sdk-57');
|
||||
expect((await resolveTemplateAsync({ sdkVersion: 'latest' })).npmTag).toBe('latest');
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws for an invalid SDK version', async () => {
|
||||
await expect(resolveTemplateAsync({ sdkVersion: 'abc' })).rejects.toThrow(
|
||||
'Invalid SDK version'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws for an SDK version without a published template', async () => {
|
||||
await expect(resolveTemplateAsync({ sdkVersion: '50' })).rejects.toThrow(
|
||||
'A project template for "sdk-50" is not available. Supported SDK versions: 57, 56.'
|
||||
);
|
||||
});
|
||||
|
||||
it('prompts with only the SDK versions that have a published template', async () => {
|
||||
mockVersionsEndpoint({
|
||||
sdkVersions: {
|
||||
'57.0.0': { releaseNoteUrl: 'https://expo.dev/changelog/sdk-57' },
|
||||
'56.0.0': { releaseNoteUrl: 'https://expo.dev/changelog/sdk-56' },
|
||||
'55.0.0': { releaseNoteUrl: 'https://expo.dev/changelog/sdk-55' },
|
||||
},
|
||||
});
|
||||
jest.mocked(promptAsync).mockResolvedValueOnce({ answer: 56 });
|
||||
|
||||
const template = await resolveTemplateAsync({});
|
||||
|
||||
expect(template.npmTag).toBe('sdk-56');
|
||||
// SDK 55 has no dist-tag in the packument, so it must not be offered.
|
||||
expect(promptAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
choices: [
|
||||
expect.objectContaining({ title: 'Latest (SDK 57)' }),
|
||||
expect.objectContaining({ title: 'Other SDK version…' }),
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the latest tag when the versions endpoint is unavailable', async () => {
|
||||
jest.mocked(fetch).mockRejectedValueOnce(new Error('network error'));
|
||||
|
||||
expect((await resolveTemplateAsync({})).npmTag).toBe('latest');
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the latest tag when no listed version has a template', async () => {
|
||||
mockVersionsEndpoint({
|
||||
sdkVersions: { '99.0.0': { releaseNoteUrl: 'https://expo.dev/changelog/sdk-99' } },
|
||||
});
|
||||
|
||||
expect((await resolveTemplateAsync({})).npmTag).toBe('latest');
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,40 +1,106 @@
|
||||
import chalk from 'chalk';
|
||||
import glob from 'fast-glob';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { extract } from 'tar';
|
||||
|
||||
import { printDirectory } from './utils';
|
||||
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../onboarding/git';
|
||||
import fetch from '../../fetch';
|
||||
import { PackageManager, installDependenciesAsync } from '../../onboarding/installDependencies';
|
||||
import { runCommandAsync } from '../../onboarding/runCommand';
|
||||
import { ora } from '../../ora';
|
||||
import { expoCommandAsync } from '../../utils/expoCli';
|
||||
|
||||
export async function cloneTemplateAsync(targetProjectDir: string): Promise<string> {
|
||||
const githubUsername = 'expo';
|
||||
const githubRepositoryName = 'expo-template-default';
|
||||
export const TEMPLATE_PACKAGE_NAME = 'expo-template-default';
|
||||
const NPM_REGISTRY_URL =
|
||||
process.env.npm_config_registry?.replace(/\/$/, '') ?? 'https://registry.npmjs.org';
|
||||
|
||||
export interface TemplateInfo {
|
||||
npmTag: string;
|
||||
version: string;
|
||||
tarballUrl: string;
|
||||
}
|
||||
|
||||
export interface TemplatePackument {
|
||||
'dist-tags'?: Record<string, string>;
|
||||
versions?: Record<string, { dist: { tarball: string } }>;
|
||||
}
|
||||
|
||||
export async function fetchTemplatePackumentAsync(): Promise<TemplatePackument> {
|
||||
const response = await fetch(`${NPM_REGISTRY_URL}/${TEMPLATE_PACKAGE_NAME}`, {
|
||||
headers: { accept: 'application/vnd.npm.install-v1+json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
return (await response.json()) as TemplatePackument;
|
||||
}
|
||||
|
||||
export async function downloadTemplateAsync(
|
||||
targetProjectDir: string,
|
||||
template: TemplateInfo
|
||||
): Promise<string> {
|
||||
const spinner = ora(
|
||||
`${chalk.bold(`Cloning the project to ${printDirectory(targetProjectDir)}`)}`
|
||||
`${chalk.bold(`Downloading the project template to ${printDirectory(targetProjectDir)}`)}`
|
||||
).start();
|
||||
|
||||
const cloneMethod = (await canAccessRepositoryUsingSshAsync({
|
||||
githubUsername,
|
||||
githubRepositoryName,
|
||||
}))
|
||||
? 'ssh'
|
||||
: 'https';
|
||||
const targetExisted = await fs.pathExists(targetProjectDir);
|
||||
try {
|
||||
// The directory was verified to not exist earlier, but it may have been
|
||||
// created while the user was answering prompts.
|
||||
if (targetExisted && (await fs.readdir(targetProjectDir)).length > 0) {
|
||||
throw new Error(
|
||||
`Directory ${printDirectory(targetProjectDir)} already exists and is not empty.`
|
||||
);
|
||||
}
|
||||
|
||||
const { targetProjectDir: finalTargetProjectDirectory } = await runGitCloneAsync({
|
||||
githubUsername,
|
||||
githubRepositoryName,
|
||||
targetProjectDir,
|
||||
cloneMethod,
|
||||
showOutput: false,
|
||||
await fs.mkdirp(targetProjectDir);
|
||||
const tarballResponse = await fetch(template.tarballUrl);
|
||||
await pipeline(tarballResponse.body, extract({ cwd: targetProjectDir, strip: 1 }));
|
||||
|
||||
await restoreTemplateDotfilesAsync(targetProjectDir);
|
||||
|
||||
spinner.succeed(
|
||||
`Downloaded ${chalk.bold(`${TEMPLATE_PACKAGE_NAME}@${template.version}`)} to ${printDirectory(
|
||||
targetProjectDir
|
||||
)}`
|
||||
);
|
||||
} catch (error) {
|
||||
spinner.fail();
|
||||
if (!targetExisted) {
|
||||
await fs.remove(targetProjectDir);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return targetProjectDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* npm strips `.gitignore` files from published packages, and the template ships
|
||||
* dot-directories with an underscore prefix (e.g. `_vscode`), so restore the
|
||||
* real names after extraction. This mirrors the renames in create-expo-app;
|
||||
* keep the directory list in sync with SUPPORTED_DIRECTORIES in
|
||||
* https://github.com/expo/expo/blob/main/packages/create-expo-app/src/createFileTransform.ts
|
||||
*/
|
||||
async function restoreTemplateDotfilesAsync(projectDir: string): Promise<void> {
|
||||
const entries = await glob('**/{gitignore,_eas,_vscode,_github,_cursor}', {
|
||||
cwd: projectDir,
|
||||
onlyFiles: false,
|
||||
dot: true,
|
||||
});
|
||||
|
||||
spinner.succeed(`Cloned the project to ${printDirectory(finalTargetProjectDirectory)}`);
|
||||
// Rename deeper entries first so parent renames do not invalidate child paths.
|
||||
entries.sort((a, b) => b.split('/').length - a.split('/').length);
|
||||
|
||||
return finalTargetProjectDirectory;
|
||||
for (const entry of entries) {
|
||||
const basename = path.basename(entry);
|
||||
const dotName = basename === 'gitignore' ? '.gitignore' : `.${basename.slice(1)}`;
|
||||
await fs.move(
|
||||
path.join(projectDir, entry),
|
||||
path.join(projectDir, path.dirname(entry), dotName),
|
||||
{ overwrite: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function installProjectDependenciesAsync(
|
||||
|
||||
@@ -110,10 +110,37 @@ export async function generateEasConfigAsync(projectDir: string): Promise<void>
|
||||
await fs.writeJson(easJsonPath, easJson, { spaces: 2 });
|
||||
}
|
||||
|
||||
export async function updatePackageJsonAsync(projectDir: string): Promise<void> {
|
||||
const TEMPLATE_LICENSE = '0BSD';
|
||||
|
||||
// https://github.com/npm/validate-npm-package-name#naming-rules
|
||||
function sanitizeNpmPackageName(name: string): string {
|
||||
return (
|
||||
name
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/^[._]+/, '')
|
||||
.replace(/[^a-z0-9._\-/@]/g, '') || 'app'
|
||||
);
|
||||
}
|
||||
|
||||
export async function updatePackageJsonAsync(
|
||||
projectDir: string,
|
||||
projectName: string
|
||||
): Promise<void> {
|
||||
const packageJsonPath = path.join(projectDir, 'package.json');
|
||||
const packageJson = await fs.readJson(packageJsonPath);
|
||||
|
||||
packageJson.name = sanitizeNpmPackageName(projectName);
|
||||
packageJson.version = '1.0.0';
|
||||
packageJson.private = true;
|
||||
delete packageJson.description;
|
||||
delete packageJson.tags;
|
||||
delete packageJson.repository;
|
||||
if (packageJson.license === TEMPLATE_LICENSE) {
|
||||
delete packageJson.license;
|
||||
}
|
||||
|
||||
if (!packageJson.scripts) {
|
||||
packageJson.scripts = {};
|
||||
}
|
||||
@@ -125,6 +152,10 @@ export async function updatePackageJsonAsync(projectDir: string): Promise<void>
|
||||
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
|
||||
}
|
||||
|
||||
export async function removeTemplateFilesAsync(projectDir: string): Promise<void> {
|
||||
await fs.remove(path.join(projectDir, 'LICENSE'));
|
||||
}
|
||||
|
||||
export async function copyProjectTemplatesAsync(projectDir: string): Promise<void> {
|
||||
const templatesSourceDir = path.join(__dirname, 'templates');
|
||||
|
||||
|
||||
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user