mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 13:08:11 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7817c58f68 | ||
|
|
7cb4981d50 | ||
|
|
e16d25f911 | ||
|
|
49d49184f2 |
No files matched your search
@@ -31,9 +31,7 @@ jobs:
|
||||
script: |
|
||||
core.setFailed('Please add a changelog entry!')
|
||||
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
|
||||
if: >-
|
||||
always() &&
|
||||
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
|
||||
if: always()
|
||||
with:
|
||||
message-id: changelog-entry-check
|
||||
refresh-message-position: true
|
||||
|
||||
@@ -37,6 +37,20 @@ 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
|
||||
@@ -53,10 +67,6 @@ jobs:
|
||||
run: yarn install --immutable
|
||||
- name: Resolve version
|
||||
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_ENV"
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.email "[email protected]"
|
||||
git config user.name "Expo CI"
|
||||
- name: Update changelog
|
||||
working-directory: ./scripts
|
||||
run: yarn release-changelog "$EAS_CLI_VERSION" > /tmp/current-changelog
|
||||
@@ -64,52 +74,17 @@ jobs:
|
||||
run: gh release edit "v$EAS_CLI_VERSION" --draft=false -F /tmp/current-changelog
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Commit and push changelog
|
||||
- name: Commit and push
|
||||
run: |
|
||||
git fetch
|
||||
git checkout main
|
||||
git add CHANGELOG.md
|
||||
git commit --allow-empty -m "update CHANGELOG.md for v$EAS_CLI_VERSION"
|
||||
git push
|
||||
- name: Verify eas-cli is available on npm
|
||||
run: |
|
||||
availability_deadline_epoch=$(( $(date +%s) + 600 ))
|
||||
|
||||
while true; do
|
||||
published="$(npm view "eas-cli@$EAS_CLI_VERSION" version --prefer-online 2>/dev/null || true)"
|
||||
if [ "$published" = "$EAS_CLI_VERSION" ]; then
|
||||
echo "Verified eas-cli@$EAS_CLI_VERSION is available on npm."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
current_epoch=$(date +%s)
|
||||
if [ "$current_epoch" -ge "$availability_deadline_epoch" ]; then
|
||||
break
|
||||
fi
|
||||
|
||||
retry_delay=$((availability_deadline_epoch - current_epoch))
|
||||
if [ "$retry_delay" -gt 10 ]; then
|
||||
retry_delay=10
|
||||
fi
|
||||
echo "eas-cli@$EAS_CLI_VERSION is not available on npm yet. Retrying in ${retry_delay}s."
|
||||
sleep "$retry_delay"
|
||||
done
|
||||
|
||||
echo "eas-cli@$EAS_CLI_VERSION was not available on npm after 10 minutes." >&2
|
||||
exit 1
|
||||
- name: Bump STAGING and PRODUCTION
|
||||
run: |
|
||||
git pull --rebase --autostash origin main
|
||||
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
|
||||
- name: Commit and push version update
|
||||
run: |
|
||||
git add cli-versions.json
|
||||
git commit --allow-empty -m "bump STAGING and PRODUCTION to $EAS_CLI_VERSION in 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 push
|
||||
- name: Prepare changelog for Slack
|
||||
run: |
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
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
|
||||
+5
-145
@@ -8,155 +8,14 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
### 🐛 Bug fixes
|
||||
- [eas-cli] Add `eas account:avatar:set` to upload an account avatar from the command line. ([#4073](https://github.com/expo/eas-cli/pull/4073) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Handle new server-side errors thrown when observe features are blocked. ([#4042](https://github.com/expo/eas-cli/pull/4042) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Add `eas project:icon:set` to upload a project icon from the command line. ([#4068](https://github.com/expo/eas-cli/pull/4068) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
- [eas-cli] Shorten the local egress banner printed by `eas simulator:start` and stop mentioning `eas simulator:egress` in interactive mode, where the client runs in the same terminal. ([#4383](https://github.com/expo/eas-cli/pull/4383) by [@brentvatne](https://github.com/brentvatne))
|
||||
### 🐛 Bug fixes
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [24.1.1](https://github.com/expo/eas-cli/releases/tag/v24.1.1) - 2026-09-11
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Stop telling interactive `eas simulator:start --egress local` users to run `eas simulator:egress` when the egress client already runs in the same terminal. ([#4382](https://github.com/expo/eas-cli/pull/4382) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [24.1.0](https://github.com/expo/eas-cli/releases/tag/v24.1.0) - 2026-09-10
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--egress-allow <host:port>` to `eas simulator:start` so local egress sessions can reach dev servers on this machine or its network. ([#4369](https://github.com/expo/eas-cli/pull/4369) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli][build-tools] Forward `--egress-allow localhost:<port>` and `127.0.0.1:<port>` entries from the simulator host's loopback interface to this machine (like `adb reverse`), so dev server URLs that use `127.0.0.1` work through local egress. ([#4369](https://github.com/expo/eas-cli/pull/4369) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Add the `eas/start_local_egress` step for iOS simulator sessions with local egress: the device host's system proxy points at a reverse tunnel to the EAS CLI machine, so HTTP(S) requests that honor it exit from that machine and third parties see its IP. ([#4364](https://github.com/expo/eas-cli/pull/4364) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Add `--egress local` to `eas simulator:start` and the `eas simulator:egress` command, which run the local egress client for the session. ([#4364](https://github.com/expo/eas-cli/pull/4364) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Require the serve-sim session token for device run session previews, so the tunnel no longer exposes the shell-exec route. ([#4306](https://github.com/expo/eas-cli/pull/4306) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Report a system error when project source refresh fails for a remote build. ([#4359](https://github.com/expo/eas-cli/pull/4359) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [24.0.0](https://github.com/expo/eas-cli/releases/tag/v24.0.0) - 2026-09-09
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Align the `eas observe:*` commands with the renamed Observe GraphQL schema. `--json` output now uses `name`/`value` instead of `metricName`/`metricValue` (metrics) and `name` instead of `eventName` (log events and event-name summaries), and `eas observe:events` now lists user-defined events only — exceptions moved to the new `eas observe:errors`. ([#4338](https://github.com/expo/eas-cli/pull/4338) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Add `lcd_width`, `lcd_height`, and `lcd_density` inputs to configure the Android emulator display. ([#4349](https://github.com/expo/eas-cli/pull/4349) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
|
||||
- [eas-cli] Add `eas channel:protect` and `eas channel:unprotect` to manage EAS Update channel protection, and show protection in channel list and view output. ([#4319](https://github.com/expo/eas-cli/pull/4319) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
- [eas-cli] Add `eas observe:errors` to display error and exception issue groups (grouped by fingerprint), with `--fingerprint <fingerprint>` to drill into a group's individual occurrences and stack traces. ([#4339](https://github.com/expo/eas-cli/pull/4339) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Add `eas observe:event` to display a single Observe event (metric, log, or error) by its ID. ([#4252](https://github.com/expo/eas-cli/pull/4252) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Start Argent remote sessions with `bun x` so they work when the `bunx` symlink is missing. ([491b372a](https://github.com/expo/eas-cli/commit/491b372a) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [build-tools] Reduce `expo-device-hub` preview resolution from 1280 px to 960 px to match `serve-sim` and lower streaming bandwidth. ([#4326](https://github.com/expo/eas-cli/pull/4326) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
|
||||
- [build-tools] Install FFmpeg before launching `expo-device-hub` in Android device sessions. ([#4332](https://github.com/expo/eas-cli/pull/4332) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
|
||||
- [eas-cli] Print the session token in simulator preview links, so they still open once the preview is gated. ([#4312](https://github.com/expo/eas-cli/pull/4312) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Make ASC API key issuer identifier optional in GraphQL and credentials types. ([#4248](https://github.com/expo/eas-cli/pull/4248) by [@sswrk](https://github.com/sswrk))
|
||||
|
||||
## [23.2.0](https://github.com/expo/eas-cli/releases/tag/v23.2.0) - 2026-08-31
|
||||
|
||||
### 🎉 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))
|
||||
|
||||
### 🧹 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))
|
||||
- [eas-cli] Add `eas project:status` (alias `eas status`) command that prints a snapshot of the project's recent builds, development builds, workflow runs, submissions, and updates, with agent-friendly JSON metadata, activity links, structured errors, submission-to-build correlation, and `--non-interactive` support. ([#4006](https://github.com/expo/eas-cli/pull/4006) by [@jonsamp](https://github.com/jonsamp))
|
||||
|
||||
### 🐛 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))
|
||||
- [eas-cli] Add `--environment` flag to the `eas observe:*` commands. ([#4275](https://github.com/expo/eas-cli/pull/4275) by [@kadikraman](https://github.com/kadikraman))
|
||||
- [eas-cli] Add `apple` value to the `--platform` flag of the `eas observe:*` commands. ([#4276](https://github.com/expo/eas-cli/pull/4276) by [@kadikraman](https://github.com/kadikraman))
|
||||
- [eas-cli] Add `--build-number` flag to `eas observe:metrics` and `eas observe:events`. ([#4278](https://github.com/expo/eas-cli/pull/4278) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
### 🐛 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
|
||||
@@ -170,6 +29,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
- [eas-cli] Send the expected `Content-MD5` checksum with GCS signed URL uploads so GCS rejects corrupted uploads. ([#4208](https://github.com/expo/eas-cli/pull/4208) by [@wschurman](https://github.com/wschurman))
|
||||
- [eas-cli] Resolve the `eas go` SDK version to its repack target, so `--sdk-version 57` no longer fails with "No Expo Go repack target is published for SDK 57". ([#4215](https://github.com/expo/eas-cli/pull/4215) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Handle missing fingerprint source in fingerprint:compare command. ([#4210](https://github.com/expo/eas-cli/pull/4210) by [@wschurman](https://github.com/wschurman))
|
||||
- [build-tools] Upload tvOS (and other non-iOS) IPAs to the App Store Connect platform declared in the bundle's `DTPlatformName`, instead of always uploading to the iOS train. ([#4234](https://github.com/expo/eas-cli/pull/4234) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "24.1.1",
|
||||
"PRODUCTION": "24.1.1"
|
||||
"STAGING": "22.2.0",
|
||||
"PRODUCTION": "21.5.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "24.1.2",
|
||||
"version": "22.2.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "24.1.0",
|
||||
"version": "22.2.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,7 +25,6 @@
|
||||
"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",
|
||||
@@ -39,23 +38,22 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "24.0.0",
|
||||
"@expo/eas-build-job": "24.0.0",
|
||||
"@expo/downloader": "22.0.0",
|
||||
"@expo/eas-build-job": "22.0.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "24.0.0",
|
||||
"@expo/logger": "22.0.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": "24.0.0",
|
||||
"@expo/template-file": "24.0.0",
|
||||
"@expo/turtle-spawn": "24.0.0",
|
||||
"@expo/steps": "22.1.0",
|
||||
"@expo/template-file": "22.0.0",
|
||||
"@expo/turtle-spawn": "22.0.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",
|
||||
@@ -79,8 +77,7 @@
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/repack-app": "~0.9.0",
|
||||
"@types/content-disposition": "0.5.9",
|
||||
"@expo/repack-app": "~0.6.1",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.4",
|
||||
@@ -92,6 +89,7 @@
|
||||
"@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",
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
// 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 type { bunyan } from '@expo/logger';
|
||||
import bunyan from 'bunyan';
|
||||
|
||||
export function createLogger(): bunyan {
|
||||
const logger = {
|
||||
|
||||
@@ -11,19 +11,6 @@ jest.mock('../utils/stepMetrics');
|
||||
const mockUploadStepMetricsToWwwAsync = jest.mocked(uploadStepMetricsToWwwAsync);
|
||||
|
||||
describe(CustomBuildContext, () => {
|
||||
it('copies the MCP server URL from the build context', () => {
|
||||
const ctx = new BuildContext(createTestIosJob(), {
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test' },
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
uploadArtifact: jest.fn(),
|
||||
workingdir: '',
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
});
|
||||
|
||||
expect(new CustomBuildContext(ctx).mcpServerUrl).toBe('ws://localhost:8787');
|
||||
});
|
||||
|
||||
it('should not lose workflowInterpolationContext', () => {
|
||||
const contextUploadArtifact = jest.fn();
|
||||
const ctx = new BuildContext(
|
||||
|
||||
@@ -4,11 +4,9 @@ import { BuildStepGlobalContext, StepsConfigParser } from '@expo/steps';
|
||||
import { runGenericJobAsync } from '../generic';
|
||||
import { CustomBuildContext } from '../customBuildContext';
|
||||
import { uploadJobOutputsToWwwAsync } from '../utils/outputs';
|
||||
import { stopLocalEgressResourcesAsync } from '../steps/utils/localEgress';
|
||||
|
||||
jest.mock('../customBuildContext');
|
||||
jest.mock('../utils/outputs');
|
||||
jest.mock('../steps/utils/localEgress', () => ({ stopLocalEgressResourcesAsync: jest.fn() }));
|
||||
jest.mock('../common/projectSources');
|
||||
jest.mock('../steps/easFunctions', () => ({ getEasFunctions: jest.fn().mockReturnValue([]) }));
|
||||
jest.mock('../steps/easFunctionGroups', () => ({
|
||||
@@ -60,25 +58,6 @@ describe(runGenericJobAsync, () => {
|
||||
mockUploadJobOutputsToWwwAsync.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it.each(['success', 'failure'])(
|
||||
'releases local egress after workflow %s, before uploading outputs',
|
||||
async outcome => {
|
||||
const executeAsync =
|
||||
outcome === 'success'
|
||||
? jest.fn().mockResolvedValue(undefined)
|
||||
: jest.fn().mockRejectedValue(new Error(outcome));
|
||||
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
|
||||
parseAsync: jest.fn().mockResolvedValue({ executeAsync }),
|
||||
}));
|
||||
const { runResult } = await runGenericJobAsync(mockCtx);
|
||||
expect(runResult.ok).toBe(outcome === 'success');
|
||||
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledWith(mockCtx.logger);
|
||||
expect(jest.mocked(stopLocalEgressResourcesAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockUploadJobOutputsToWwwAsync.mock.invocationCallOrder[0]
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('awaits drainPendingMetricUploads in COMPLETE_JOB phase', async () => {
|
||||
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
|
||||
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
|
||||
|
||||
@@ -98,7 +98,7 @@ async function buildInnerAsync(
|
||||
await restoreCcacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
target: { platform: ctx.job.platform },
|
||||
platform: ctx.job.platform,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
@@ -238,7 +238,7 @@ async function buildInnerAsync(
|
||||
await saveCcacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
target: { platform: ctx.job.platform },
|
||||
platform: ctx.job.platform,
|
||||
evictUsedBefore,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
|
||||
@@ -18,12 +18,8 @@ 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,
|
||||
restoreCocoapodsCacheAsync,
|
||||
} from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync, saveCocoapodsCacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import { uploadApplicationArchive } from '../utils/artifacts';
|
||||
import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
@@ -107,13 +103,7 @@ async function buildInnerAsync(
|
||||
await restoreCcacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
target: { platform: ctx.job.platform, simulator: ctx.job.simulator === true },
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
await restoreCocoapodsCacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
platform: ctx.job.platform,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
@@ -255,17 +245,11 @@ async function buildInnerAsync(
|
||||
await saveCcacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
target: { platform: ctx.job.platform, simulator: ctx.job.simulator === true },
|
||||
platform: ctx.job.platform,
|
||||
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,48 +70,6 @@ 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,10 +3,7 @@ import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { PackageManager } from '../../utils/packageManager';
|
||||
import {
|
||||
installDependenciesAsync,
|
||||
installDependenciesWithNpmCacheFallbackAsync,
|
||||
} from '../installDependencies';
|
||||
import { installDependenciesWithNpmCacheFallbackAsync } from '../installDependencies';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => jest.fn());
|
||||
jest.mock('../../sentry', () => ({
|
||||
@@ -239,85 +236,6 @@ 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: {
|
||||
|
||||
@@ -2,13 +2,10 @@ import {
|
||||
ArchiveSourceType,
|
||||
BuildMode,
|
||||
BuildTrigger,
|
||||
ErrorCode,
|
||||
Job,
|
||||
Platform,
|
||||
SystemError,
|
||||
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';
|
||||
@@ -17,7 +14,7 @@ import { setTimeout } from 'timers/promises';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { shallowCloneRepositoryAsync } from '../git';
|
||||
import { prepareProjectSourcesAsync, unpackTarGzAsync } from '../projectSources';
|
||||
import { prepareProjectSourcesAsync } from '../projectSources';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('node-fetch');
|
||||
@@ -26,28 +23,7 @@ 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.each([false, true])('uses the correct repository URL (local: %s)', async isLocal => {
|
||||
it('should use the refreshed repository URL', async () => {
|
||||
const robotAccessToken = randomUUID();
|
||||
const buildId = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
|
||||
@@ -78,7 +54,7 @@ describe('projectSources', () => {
|
||||
__API_SERVER_URL: 'https://api.expo.dev',
|
||||
EXPO_TOKEN: robotAccessToken,
|
||||
EAS_BUILD_ID: buildId,
|
||||
EAS_BUILD_RUNNER: isLocal ? 'local-build-plugin' : 'eas-build',
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
},
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
@@ -107,88 +83,108 @@ describe('projectSources', () => {
|
||||
expect.objectContaining({
|
||||
archiveSource: {
|
||||
...ctx.job.projectArchive,
|
||||
repositoryUrl: isLocal
|
||||
? 'https://x-access-token:[email protected]/expo/eas-cli.git'
|
||||
: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
},
|
||||
})
|
||||
);
|
||||
if (isLocal) {
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['http', 'network', 'json', 'schema'])(
|
||||
'throws a system error if refresh fails (%s)',
|
||||
async failure => {
|
||||
const robotAccessToken = randomUUID();
|
||||
const buildId = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
|
||||
it('should fallback to the original repository URL if the refresh fails', async () => {
|
||||
const robotAccessToken = randomUUID();
|
||||
const buildId = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
|
||||
|
||||
const ctx = new BuildContext(
|
||||
{
|
||||
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
|
||||
type: Workflow.MANAGED,
|
||||
mode: BuildMode.BUILD,
|
||||
initiatingUserId: randomUUID(),
|
||||
appId: randomUUID(),
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash: randomBytes(20).toString('hex'),
|
||||
},
|
||||
platform: Platform.IOS,
|
||||
secrets: {
|
||||
robotAccessToken,
|
||||
environmentSecrets: [],
|
||||
},
|
||||
} as Job,
|
||||
{
|
||||
env: {
|
||||
__API_SERVER_URL: 'https://api.expo.dev',
|
||||
EXPO_TOKEN: robotAccessToken,
|
||||
EAS_BUILD_ID: buildId,
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
},
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
const cause = new Error('Source request failed');
|
||||
fetchMock.mockImplementation(async () => {
|
||||
if (failure === 'network') {
|
||||
throw cause;
|
||||
}
|
||||
return {
|
||||
ok: failure !== 'http',
|
||||
status: failure === 'http' ? 500 : 200,
|
||||
json: async () => {
|
||||
if (failure === 'json') {
|
||||
throw cause;
|
||||
}
|
||||
return { data: { repository_url: 'https://github.com/expo/eas-cli.git' } };
|
||||
},
|
||||
} as Response;
|
||||
});
|
||||
|
||||
const result = prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
await expect(result).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(result).rejects.toMatchObject({
|
||||
errorCode: ErrorCode.SERVER_ERROR,
|
||||
cause: expect.any(Error),
|
||||
});
|
||||
if (failure === 'network') {
|
||||
await expect(result).rejects.toMatchObject({ cause });
|
||||
const ctx = new BuildContext(
|
||||
{
|
||||
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
|
||||
type: Workflow.MANAGED,
|
||||
mode: BuildMode.BUILD,
|
||||
initiatingUserId: randomUUID(),
|
||||
appId: randomUUID(),
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash: randomBytes(20).toString('hex'),
|
||||
},
|
||||
platform: Platform.IOS,
|
||||
secrets: {
|
||||
robotAccessToken,
|
||||
environmentSecrets: [],
|
||||
},
|
||||
} as Job,
|
||||
{
|
||||
env: {
|
||||
__API_SERVER_URL: 'https://api.expo.dev',
|
||||
EXPO_TOKEN: robotAccessToken,
|
||||
EAS_BUILD_ID: buildId,
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
},
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
},
|
||||
15_000
|
||||
);
|
||||
);
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
ok: false,
|
||||
text: async () => 'Failed to generate repository URL',
|
||||
}) as Response
|
||||
);
|
||||
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
archiveSource: {
|
||||
...ctx.job.projectArchive,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
},
|
||||
})
|
||||
);
|
||||
fetchMock.mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
ok: false,
|
||||
json: async () => ({
|
||||
// repositoryUrl is the right key
|
||||
repository_url: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
}),
|
||||
}) as Response
|
||||
);
|
||||
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
archiveSource: {
|
||||
...ctx.job.projectArchive,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
fetchMock.mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
ok: false,
|
||||
json: () => Promise.reject(new Error('Failed to generate repository URL')),
|
||||
}) as Response
|
||||
);
|
||||
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
archiveSource: {
|
||||
...ctx.job.projectArchive,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(shallowCloneRepositoryAsync).toHaveBeenCalledTimes(3);
|
||||
}, 15_000);
|
||||
|
||||
it('should retry fetching the repository URL', async () => {
|
||||
const robotAccessToken = randomUUID();
|
||||
@@ -263,7 +259,7 @@ describe('projectSources', () => {
|
||||
);
|
||||
}, 15_000);
|
||||
|
||||
it(`throws a system error if refresh configuration is missing`, async () => {
|
||||
it(`should fallback to the original repository URL if we're missing some config`, async () => {
|
||||
const robotAccessToken = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
|
||||
const logger = createMockLogger();
|
||||
@@ -301,11 +297,12 @@ describe('projectSources', () => {
|
||||
}
|
||||
);
|
||||
|
||||
await expect(prepareProjectSourcesAsync(ctx, ctx.buildDirectory)).rejects.toMatchObject({
|
||||
errorCode: ErrorCode.SERVER_ERROR,
|
||||
cause: new Error('EAS_BUILD_ID is not set'),
|
||||
});
|
||||
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
'Failed to refresh project archive, falling back to the original one'
|
||||
);
|
||||
});
|
||||
|
||||
describe('uploadProjectMetadataAsFireAndForget', () => {
|
||||
@@ -385,10 +382,6 @@ describe('projectSources', () => {
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockImplementation(async () => ({ ok: true }) as Response);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: ctx.job.projectArchive }),
|
||||
} as Response);
|
||||
|
||||
// Call prepareProjectSourcesAsync and don't await metadata upload
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
@@ -502,10 +495,6 @@ describe('projectSources', () => {
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockResolvedValue({ ok: true } as Response);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: ctx.job.projectArchive }),
|
||||
} as Response);
|
||||
|
||||
const startTime = Date.now();
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
@@ -570,10 +559,6 @@ describe('projectSources', () => {
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockResolvedValue({ ok: true } as Response);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: ctx.job.projectArchive }),
|
||||
} as Response);
|
||||
|
||||
// Should not throw even though upload will fail
|
||||
await expect(prepareProjectSourcesAsync(ctx, ctx.buildDirectory)).resolves.not.toThrow();
|
||||
|
||||
@@ -32,11 +32,7 @@ export async function installDependenciesAsync({
|
||||
break;
|
||||
}
|
||||
case PackageManager.PNPM: {
|
||||
args = [
|
||||
'install',
|
||||
...(env['EAS_PNPM_FILTER_WORKSPACE'] ? ['--filter', env['EAS_PNPM_FILTER_WORKSPACE']] : []),
|
||||
useFrozenLockfile ? '--frozen-lockfile' : '--no-frozen-lockfile',
|
||||
];
|
||||
args = ['install', useFrozenLockfile ? '--frozen-lockfile' : '--no-frozen-lockfile'];
|
||||
break;
|
||||
}
|
||||
case PackageManager.YARN: {
|
||||
@@ -62,11 +58,7 @@ export async function installDependenciesAsync({
|
||||
break;
|
||||
}
|
||||
case PackageManager.BUN:
|
||||
args = [
|
||||
'install',
|
||||
...(env['EAS_BUN_FILTER_WORKSPACE'] ? ['--filter', env['EAS_BUN_FILTER_WORKSPACE']] : []),
|
||||
...(useFrozenLockfile ? ['--frozen-lockfile'] : []),
|
||||
];
|
||||
args = ['install', ...(useFrozenLockfile ? ['--frozen-lockfile'] : [])];
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported package manager: ${packageManager}`);
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import {
|
||||
ArchiveSource,
|
||||
ArchiveSourceSchemaZ,
|
||||
ArchiveSourceType,
|
||||
Job,
|
||||
SystemError,
|
||||
} from '@expo/eas-build-job';
|
||||
import { ArchiveSource, ArchiveSourceSchemaZ, ArchiveSourceType, Job } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
@@ -32,12 +26,13 @@ Promise<{ handled: boolean }> {
|
||||
const projectArchiveResult = await asyncResult(fetchProjectArchiveSourceAsync(ctx));
|
||||
|
||||
if (!projectArchiveResult.ok) {
|
||||
throw new SystemError('Failed to fetch project sources. Re-run the job.', {
|
||||
cause: projectArchiveResult.reason,
|
||||
});
|
||||
ctx.logger.error(
|
||||
{ err: projectArchiveResult.reason },
|
||||
'Failed to refresh project archive, falling back to the original one'
|
||||
);
|
||||
}
|
||||
|
||||
projectArchive = projectArchiveResult.value;
|
||||
projectArchive = projectArchiveResult.value ?? ctx.job.projectArchive;
|
||||
}
|
||||
|
||||
switch (projectArchive.type) {
|
||||
@@ -115,7 +110,7 @@ async function prepareProjectSourcesLocallyAsync<TJob extends Job>(
|
||||
});
|
||||
}
|
||||
|
||||
export async function unpackTarGzAsync({
|
||||
async function unpackTarGzAsync({
|
||||
logger,
|
||||
source,
|
||||
destination,
|
||||
@@ -127,8 +122,6 @@ export 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(
|
||||
|
||||
@@ -68,7 +68,6 @@ export interface BuildContextOptions {
|
||||
skipNativeBuild?: boolean;
|
||||
metadata?: Metadata;
|
||||
expoApiV2BaseUrl?: string;
|
||||
mcpServerUrl?: string;
|
||||
}
|
||||
|
||||
export class SkipNativeBuildError extends Error {}
|
||||
@@ -85,7 +84,6 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
) => void;
|
||||
public readonly skipNativeBuild?: boolean;
|
||||
public readonly expoApiV2BaseUrl?: string;
|
||||
public readonly mcpServerUrl?: string;
|
||||
public artifacts: Artifacts = {};
|
||||
|
||||
private readonly _isLocal: boolean;
|
||||
@@ -114,7 +112,6 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
this._metadata = options.metadata;
|
||||
this.skipNativeBuild = options.skipNativeBuild;
|
||||
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
|
||||
this.mcpServerUrl = options.mcpServerUrl;
|
||||
|
||||
const environmentSecrets = this.getEnvironmentSecrets(job);
|
||||
this._env = {
|
||||
|
||||
@@ -63,7 +63,6 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
public readonly logger: bunyan;
|
||||
public readonly graphqlClient: Client;
|
||||
public readonly runtimeApi: BuilderRuntimeApi;
|
||||
public readonly mcpServerUrl?: string;
|
||||
public job: TJob;
|
||||
public metadata?: Metadata;
|
||||
|
||||
@@ -85,7 +84,6 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
this.runtimeApi = {
|
||||
uploadArtifact: (...args) => buildCtx['uploadArtifact'](...args),
|
||||
};
|
||||
this.mcpServerUrl = buildCtx.mcpServerUrl;
|
||||
this.expoApiV2BaseUrl = buildCtx.expoApiV2BaseUrl;
|
||||
this.startTime = new Date();
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import { BuildContext } from './context';
|
||||
import { CustomBuildContext } from './customBuildContext';
|
||||
import { getEasFunctionGroups } from './steps/easFunctionGroups';
|
||||
import { getEasFunctions } from './steps/easFunctions';
|
||||
import { stopLocalEgressResourcesAsync } from './steps/utils/localEgress';
|
||||
import { uploadJobOutputsToWwwAsync } from './utils/outputs';
|
||||
import { retryAsync } from './utils/retry';
|
||||
|
||||
@@ -83,17 +82,7 @@ export async function runGenericJobAsync(
|
||||
}
|
||||
});
|
||||
|
||||
const runResult = await asyncResult(
|
||||
(async () => {
|
||||
try {
|
||||
await workflow.executeAsync();
|
||||
} finally {
|
||||
// The session-owning step may never run if simulator boot/setup fails,
|
||||
// or may time out while resource acquisition is still in flight.
|
||||
await stopLocalEgressResourcesAsync(ctx.logger);
|
||||
}
|
||||
})()
|
||||
);
|
||||
const runResult = await asyncResult(workflow.executeAsync());
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.COMPLETE_JOB, async () => {
|
||||
const results = await Promise.allSettled([
|
||||
|
||||
@@ -5,8 +5,6 @@ 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,
|
||||
@@ -20,9 +18,6 @@ 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';
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
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,10 +19,8 @@ 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';
|
||||
@@ -51,8 +49,7 @@ import { createStartAppiumRemoteSessionBuildFunction } from './functions/startAp
|
||||
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
|
||||
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
|
||||
import { createStartIosSimulatorRecordingsBuildFunction } from './functions/startIosSimulatorRecordings';
|
||||
import { createStartLocalEgressBuildFunction } from './functions/startLocalEgress';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/startWebPreviewRemoteSession';
|
||||
import { createStartServeSimRemoteSessionBuildFunction } from './functions/startServeSimRemoteSession';
|
||||
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
|
||||
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
|
||||
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
|
||||
@@ -77,8 +74,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createPrebuildBuildFunction(),
|
||||
createReadIpaInfoBuildFunction(),
|
||||
createDownloadBuildFunction(ctx),
|
||||
createInstallBuildFunction(),
|
||||
createLaunchApplicationFunction(),
|
||||
createEasExportBuildFunction(),
|
||||
createEasDeployBuildFunction(),
|
||||
createRepackBuildFunction(),
|
||||
@@ -104,11 +99,10 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createStartAndroidEmulatorBuildFunction(),
|
||||
createStartCuttlefishDeviceBuildFunction(),
|
||||
createStartIosSimulatorBuildFunction(),
|
||||
createStartLocalEgressBuildFunction(),
|
||||
createStartIosSimulatorRecordingsBuildFunction(),
|
||||
createFinishIosSimulatorRecordingsBuildFunction(),
|
||||
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
|
||||
createStartWebPreviewRemoteSessionBuildFunction(ctx),
|
||||
createStartServeSimRemoteSessionBuildFunction(ctx),
|
||||
createStartServeSimMetricsBuildFunction(),
|
||||
createCollectServeSimMetricsBuildFunction(ctx),
|
||||
createInstallMaestroBuildFunction(),
|
||||
|
||||
@@ -162,28 +162,6 @@ 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,8 +98,6 @@ function createStepsForIosSimulatorBuild({
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const evictUsedBefore = new Date();
|
||||
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
@@ -108,16 +106,6 @@ 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,
|
||||
@@ -135,16 +123,6 @@ 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, {
|
||||
@@ -160,7 +138,6 @@ function createStepsForIosSimulatorBuild({
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
restoreCache,
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
installPods,
|
||||
configureEASUpdate,
|
||||
@@ -182,10 +159,6 @@ function createStepsForIosSimulatorBuild({
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
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,10 +4,8 @@ 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';
|
||||
@@ -21,9 +19,6 @@ 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({
|
||||
@@ -56,57 +51,6 @@ 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();
|
||||
@@ -114,12 +58,11 @@ describe('downloadBuild', () => {
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
createSuccessfulResponse({
|
||||
body: APP_TAR_GZ_BUFFER,
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
})
|
||||
);
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
body: Readable.from(APP_TAR_GZ_BUFFER),
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
} as unknown as Response);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
@@ -139,39 +82,17 @@ 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(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: applicationArchiveUrl })
|
||||
);
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
body: Readable.from(Buffer.from('hello')),
|
||||
url: applicationArchiveUrl,
|
||||
} as unknown as Response);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
@@ -185,128 +106,6 @@ 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 });
|
||||
|
||||
@@ -404,12 +203,11 @@ describe('downloadBuild', () => {
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
createSuccessfulResponse({
|
||||
body: APP_TAR_GZ_BUFFER,
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
})
|
||||
);
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
body: Readable.from(APP_TAR_GZ_BUFFER),
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
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,10 +1,6 @@
|
||||
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';
|
||||
@@ -15,11 +11,6 @@ jest.mock('@expo/turtle-spawn', () => ({
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@expo/downloader', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../datadog', () => ({
|
||||
Datadog: {
|
||||
distribution: jest.fn(),
|
||||
@@ -27,24 +18,10 @@ 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) => ({
|
||||
@@ -462,184 +439,4 @@ 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,320 +0,0 @@
|
||||
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();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -1,205 +0,0 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import {
|
||||
monitorLocalEgressAsync,
|
||||
readLocalEgressHandoffAsync,
|
||||
registerLocalEgressResources,
|
||||
stopLocalEgressResourcesAsync,
|
||||
} from '../../utils/localEgress';
|
||||
import {
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
|
||||
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
|
||||
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/IosSimulatorUtils');
|
||||
jest.mock('../../../utils/processes');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../utils/agentDeviceArtifacts');
|
||||
jest.mock('../../utils/argentArtifacts');
|
||||
jest.mock('../../utils/agentDeviceEvents', () => ({
|
||||
startAgentDeviceEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
|
||||
}));
|
||||
jest.mock('../../utils/argentEvents', () => ({
|
||||
ARGENT_EVENT_LOG_FILENAME: 'events.jsonl',
|
||||
startArgentEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
|
||||
}));
|
||||
jest.mock('../../utils/appiumEvents', () => ({
|
||||
startAppiumEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
|
||||
}));
|
||||
jest.mock('../../utils/localEgress', () => ({
|
||||
...jest.requireActual('../../utils/localEgress'),
|
||||
readLocalEgressHandoffAsync: jest.fn(),
|
||||
monitorLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
getDeviceRunSessionIdOrThrow: () => 'session-id',
|
||||
getNgrokTunnelDomainOrThrow: () => 'example.test',
|
||||
getNgrokAuthtokenOrThrow: () => 'ngrok-token',
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
spawnDetached: () => ({ pid: 123, getOutput: () => '', stopAsync: jest.fn() }),
|
||||
waitForFileAsync: async () => ({ port: 1234, token: 'controller-token' }),
|
||||
startNgrokTunnelAsync: async () => ({ url: 'https://controller.test', stopAsync: jest.fn() }),
|
||||
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const env = { DEVICE_RUN_SESSION_ID: 'session-id' };
|
||||
const handoff = { url: 'https://egress.test', token: 'secret', fingerprint: 'key=', port: 8899 };
|
||||
const fields = {
|
||||
egressUrl: handoff.url,
|
||||
egressToken: handoff.token,
|
||||
egressFingerprint: handoff.fingerprint,
|
||||
egressPort: handoff.port,
|
||||
};
|
||||
const controllers = [
|
||||
['agent-device', createStartAgentDeviceRemoteSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
|
||||
['Appium', createStartAppiumRemoteSessionBuildFunction, 'appiumUrl'],
|
||||
['Argent', createStartArgentRemoteSessionBuildFunction, 'toolsUrl'],
|
||||
['web preview', createStartWebPreviewRemoteSessionBuildFunction, 'previewUrl'],
|
||||
] as const;
|
||||
|
||||
describe.each(controllers)('%s local egress', (_name, createFunction, controllerField) => {
|
||||
const stopPreview = jest.fn();
|
||||
const stopEgress = jest.fn();
|
||||
let lifetime: AbortSignal;
|
||||
async function run(signal?: AbortSignal): Promise<void> {
|
||||
await createFunction(ctx).fn!(
|
||||
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env,
|
||||
signal,
|
||||
} as never
|
||||
);
|
||||
}
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
lifetime = registerLocalEgressResources(stopEgress);
|
||||
stopEgress.mockResolvedValue(undefined);
|
||||
stopPreview.mockResolvedValue(undefined);
|
||||
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
jest.spyOn(fs.promises, 'mkdtemp').mockResolvedValue('/tmp/appium-egress-test');
|
||||
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
|
||||
jest.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
|
||||
jest.spyOn(fs.promises, 'readdir').mockResolvedValue(['tool-server.json'] as never);
|
||||
jest
|
||||
.spyOn(fs.promises, 'readFile')
|
||||
.mockResolvedValue(JSON.stringify({ port: 1234, pid: 124, token: 'controller-token' }));
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '{}' } as never);
|
||||
jest.mocked(isProcessDescendantOfAsync).mockResolvedValue(true);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'sim' }] as never);
|
||||
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(handoff);
|
||||
jest.mocked(monitorLocalEgressAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewUrl: 'https://preview.test',
|
||||
previewToken: 'preview-secret',
|
||||
stopAsync: stopPreview,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
|
||||
expect(lifetime.aborted).toBe(false);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
afterEach(async () => {
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reports credentials and starts monitoring before waiting, then releases resources', async () => {
|
||||
await run();
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
deviceRunSessionId: 'session-id',
|
||||
logger,
|
||||
remoteConfig: expect.objectContaining({
|
||||
...fields,
|
||||
[controllerField]: expect.any(String),
|
||||
[controllerField === 'previewUrl' ? 'previewToken' : 'webPreviewToken']: 'preview-secret',
|
||||
}),
|
||||
});
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledWith({
|
||||
port: handoff.port,
|
||||
env,
|
||||
logger,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(lifetime.aborted).toBe(true);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('leaves ordinary remote config unchanged and does not monitor', async () => {
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
stopEgress.mockClear();
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(null);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
await run();
|
||||
const { remoteConfig } = jest.mocked(uploadRemoteSessionConfigAsync).mock.calls[0][0];
|
||||
expect(remoteConfig).toHaveProperty(controllerField);
|
||||
expect(Object.keys(remoteConfig).some(key => key.startsWith('egress'))).toBe(false);
|
||||
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(stopEgress).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(['startup', 'report', 'wait', 'teardown'])(
|
||||
'releases egress after %s fails',
|
||||
async phase => {
|
||||
const error = new Error(`${phase} failed`);
|
||||
if (phase === 'startup') {
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockRejectedValue(error);
|
||||
} else if (phase === 'report') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValue(error);
|
||||
} else if (phase === 'wait') {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValue(error);
|
||||
} else {
|
||||
stopPreview.mockRejectedValue(error);
|
||||
}
|
||||
await expect(run()).rejects.toThrow(error);
|
||||
expect(lifetime.aborted).toBe(true);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(
|
||||
phase === 'startup' || phase === 'report' ? 0 : 1
|
||||
);
|
||||
}
|
||||
);
|
||||
it('passes cancellation to the monitor and releases the lifetime', async () => {
|
||||
const abort = new AbortController();
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => abort.abort());
|
||||
await run(abort.signal);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signal: abort.signal })
|
||||
);
|
||||
expect(lifetime.aborted).toBe(true);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('does not report or start a monitor after cancellation', async () => {
|
||||
const abort = new AbortController();
|
||||
abort.abort();
|
||||
await expect(run(abort.signal)).rejects.toThrow();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
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';
|
||||
@@ -194,53 +193,22 @@ describe(resolveIosSigningOptionsAsync, () => {
|
||||
const signingOptions = await resolveIosSigningOptionsAsync({
|
||||
job,
|
||||
logger: mockLogger,
|
||||
tmpDir: '/tmp',
|
||||
});
|
||||
|
||||
expect(signingOptions).not.toBeNull();
|
||||
expect(signingOptions).toMatchObject({
|
||||
keychainPath: '/tmp/ios_keychain',
|
||||
signingIdentity: 'Test App Certificate',
|
||||
provisioningProfile: {
|
||||
'com.example.testapp': '/tmp/ios_provisioning_profile',
|
||||
},
|
||||
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',
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -251,7 +219,6 @@ describe(resolveIosSigningOptionsAsync, () => {
|
||||
const signingOptions = await resolveIosSigningOptionsAsync({
|
||||
job,
|
||||
logger: mockLogger,
|
||||
tmpDir: '/tmp',
|
||||
});
|
||||
expect(signingOptions).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
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, uploadCacheAsync } from '../saveCache';
|
||||
import { compressCacheAsync } 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;
|
||||
}
|
||||
@@ -87,71 +81,3 @@ 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -83,31 +83,6 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
mockedMkdtemp.mockRestore();
|
||||
});
|
||||
|
||||
it('passes profile and LCD inputs to emulator creation', async () => {
|
||||
const systemImagePackage = 'system-images;android-35;default;x86_64';
|
||||
await createStep({
|
||||
device_identifier: 'medium_phone',
|
||||
system_image_package: systemImagePackage,
|
||||
lcd_width: 720,
|
||||
lcd_height: 1600,
|
||||
lcd_density: 262,
|
||||
}).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('sdkmanager', [systemImagePackage], expect.anything());
|
||||
expect(mockedAndroidUtils.createAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deviceIdentifier: 'medium_phone',
|
||||
systemImagePackage,
|
||||
lcdWidth: 720,
|
||||
lcdHeight: 1600,
|
||||
lcdDensity: 262,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.createAsync.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockedAndroidUtils.startAsync.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('retries base emulator startup with increasing readiness timeouts', async () => {
|
||||
mockedAndroidUtils.startAsync
|
||||
.mockResolvedValueOnce(createStartResult('emulator-1111'))
|
||||
|
||||
+6
-39
@@ -9,13 +9,11 @@ import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
|
||||
import { startArgentEventCollectionAsync } from '../../utils/argentEvents';
|
||||
import {
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
@@ -42,14 +40,14 @@ jest.mock('../../utils/argentEvents', () => ({
|
||||
startArgentEventCollectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
ensureFfmpegInstalledAsync: jest.fn(),
|
||||
getDeviceRunSessionIdOrThrow: jest.fn(),
|
||||
getNgrokAuthtokenOrThrow: jest.fn(),
|
||||
getNgrokTunnelDomainOrThrow: jest.fn(),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
spawnDetached: jest.fn(),
|
||||
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
startServeSimWithTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
@@ -60,7 +58,6 @@ 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 () => {
|
||||
@@ -71,7 +68,6 @@ 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,
|
||||
@@ -90,10 +86,6 @@ 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);
|
||||
|
||||
@@ -128,31 +120,18 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
} as never
|
||||
);
|
||||
|
||||
// (1) FFmpeg setup starts in the background before Argent setup.
|
||||
expect(ensureFfmpegInstalledOnceAsync).toHaveBeenCalledWith({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: { EXISTING: 'value' },
|
||||
logger: expect.anything(),
|
||||
});
|
||||
expect(jest.mocked(ensureFfmpegInstalledOnceAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(spawn).mock.invocationCallOrder[0]
|
||||
);
|
||||
|
||||
// (2) The event log flag is enabled, before the tool-server is launched.
|
||||
// (1) The event log flag is enabled, before the tool-server is launched.
|
||||
const spawnCalls = jest.mocked(spawn).mock.calls;
|
||||
const enableEventLogIndex = spawnCalls.findIndex(
|
||||
([command, args]) =>
|
||||
command === 'bun' &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === 'x' &&
|
||||
args.includes('tool-server-event-log')
|
||||
command === 'bunx' && Array.isArray(args) && args.includes('tool-server-event-log')
|
||||
);
|
||||
expect(enableEventLogIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(jest.mocked(spawn).mock.invocationCallOrder[enableEventLogIndex]).toBeLessThan(
|
||||
jest.mocked(spawnDetached).mock.invocationCallOrder[0]
|
||||
);
|
||||
|
||||
// (3) The tool-server and the collector are pinned to the exact same event log path.
|
||||
// (2) The tool-server and the collector are pinned to the exact same event log path.
|
||||
const serverEnv = jest.mocked(spawnDetached).mock.calls[0][0].env;
|
||||
expect(serverEnv.ARGENT_EVENT_LOG).toBe(EXPECTED_EVENT_LOG_PATH);
|
||||
expect(serverEnv.EXISTING).toBe('value');
|
||||
@@ -161,7 +140,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
eventLogPath: EXPECTED_EVENT_LOG_PATH,
|
||||
});
|
||||
|
||||
// (4) Collection starts (before we wait for the session to stop) and (5) is torn down
|
||||
// (3) Collection starts (before we wait for the session to stop) and (4) is torn down
|
||||
// afterwards, exactly once.
|
||||
expect(startArgentEventCollectionAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockStopAsync).toHaveBeenCalledTimes(1);
|
||||
@@ -172,17 +151,5 @@ 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);
|
||||
});
|
||||
});
|
||||
@@ -74,7 +74,7 @@ describe(warnIfArgentPackageVersionCannotBeVerified, () => {
|
||||
).not.toThrow();
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Continuing and letting bun x resolve it.')
|
||||
expect.stringContaining('Continuing and letting bunx resolve it.')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type BuildStepContext } from '@expo/steps';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import {
|
||||
LOCAL_EGRESS_HANDOFF_PATH,
|
||||
configureSystemProxyAsync,
|
||||
downloadChiselAsync,
|
||||
startChiselServerAsync,
|
||||
stopLocalEgressResourcesAsync,
|
||||
writeLocalEgressHandoffAsync,
|
||||
} from '../../utils/localEgress';
|
||||
import { findAvailablePortAsync, startNgrokTunnelAsync } from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartLocalEgressBuildFunction } from '../startLocalEgress';
|
||||
|
||||
jest.mock('../../utils/localEgress', () => ({
|
||||
...jest.requireActual('../../utils/localEgress'),
|
||||
configureSystemProxyAsync: jest.fn(),
|
||||
downloadChiselAsync: jest.fn(),
|
||||
startChiselServerAsync: jest.fn(),
|
||||
writeLocalEgressHandoffAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
findAvailablePortAsync: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
getNgrokAuthtokenOrThrow: () => 'ngrok-token',
|
||||
getNgrokTunnelDomainOrThrow: () => 'example.com',
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const server = { pid: 12345, getOutput: () => '', stopAsync: jest.fn() };
|
||||
const tunnel = { url: 'https://egress.example.com', stopAsync: jest.fn() };
|
||||
|
||||
async function start(signal?: AbortSignal): Promise<void> {
|
||||
await createStartLocalEgressBuildFunction().fn!({ logger } as BuildStepContext, {
|
||||
env: {},
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
describe('local egress acquisition', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(fs.promises, 'mkdtemp').mockResolvedValue('/tmp/egress-acquisition-test');
|
||||
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
|
||||
jest.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
|
||||
server.stopAsync.mockResolvedValue(undefined);
|
||||
tunnel.stopAsync.mockResolvedValue(undefined);
|
||||
jest.mocked(findAvailablePortAsync).mockResolvedValue(52001);
|
||||
jest.mocked(downloadChiselAsync).mockResolvedValue('/tmp/egress-acquisition-test/chisel');
|
||||
jest.mocked(startChiselServerAsync).mockResolvedValue({ process: server, fingerprint: 'key=' });
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValue(tunnel);
|
||||
jest.mocked(configureSystemProxyAsync).mockResolvedValue({ service: 'Ethernet' });
|
||||
jest.mocked(writeLocalEgressHandoffAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('stops the detached server and removes credentials when ngrok acquisition fails', async () => {
|
||||
jest.mocked(startNgrokTunnelAsync).mockRejectedValue(new Error('listener quota'));
|
||||
await expect(start()).rejects.toThrow('listener quota');
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
|
||||
expect(fs.promises.rm).toHaveBeenCalledWith('/tmp/egress-acquisition-test', {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('attempts every release even when one resource cannot stop', async () => {
|
||||
await start();
|
||||
tunnel.stopAsync.mockRejectedValue(new Error('ngrok close failed'));
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(fs.promises.rm).toHaveBeenCalledWith(LOCAL_EGRESS_HANDOFF_PATH, { force: true });
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Could not stop a local egress resource.'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'disposes late listeners without delaying cleanup (close fails: %s)',
|
||||
async closeFails => {
|
||||
let finishTunnel!: (value: typeof tunnel) => void;
|
||||
jest.mocked(startNgrokTunnelAsync).mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finishTunnel = resolve;
|
||||
})
|
||||
);
|
||||
const started = start();
|
||||
const rejected = expect(started).rejects.toThrow();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(startNgrokTunnelAsync).toHaveBeenCalledTimes(1);
|
||||
const stopped = stopLocalEgressResourcesAsync(logger);
|
||||
await Promise.all([stopped, rejected]);
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(tunnel.stopAsync).not.toHaveBeenCalled();
|
||||
if (closeFails) {
|
||||
tunnel.stopAsync.mockRejectedValue(new Error('late close failed'));
|
||||
}
|
||||
finishTunnel(tunnel);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
if (closeFails) {
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Could not release a cancelled local egress acquisition.'
|
||||
);
|
||||
}
|
||||
expect(tunnel.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
|
||||
expect(writeLocalEgressHandoffAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['job', 'step'])('finishes %s cancellation when ngrok never settles', async source => {
|
||||
jest.mocked(startNgrokTunnelAsync).mockReturnValue(new Promise(() => {}));
|
||||
const controller = new AbortController();
|
||||
const started = start(controller.signal);
|
||||
const rejected = expect(started).rejects.toThrow();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(startNgrokTunnelAsync).toHaveBeenCalledTimes(1);
|
||||
if (source === 'step') {
|
||||
controller.abort(new Error('cancelled'));
|
||||
}
|
||||
const stopped = source === 'job' ? stopLocalEgressResourcesAsync(logger) : Promise.resolve();
|
||||
const outcome = await Promise.race([
|
||||
Promise.all([stopped, rejected]).then(() => 'stopped'),
|
||||
new Promise(resolve => setImmediate(() => resolve('still waiting'))),
|
||||
]);
|
||||
expect(outcome).toBe('stopped');
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
|
||||
expect(writeLocalEgressHandoffAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles ngrok rejection after finalization without an unhandled rejection', async () => {
|
||||
let rejectTunnel!: (error: Error) => void;
|
||||
jest.mocked(startNgrokTunnelAsync).mockReturnValue(
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectTunnel = reject;
|
||||
})
|
||||
);
|
||||
const started = start();
|
||||
const rejected = expect(started).rejects.toThrow();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await Promise.all([stopLocalEgressResourcesAsync(logger), rejected]);
|
||||
rejectTunnel(new Error('late connection failure'));
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not launch a server after a download finishes following cancellation', async () => {
|
||||
let finishDownload!: (value: string) => void;
|
||||
jest.mocked(downloadChiselAsync).mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finishDownload = resolve;
|
||||
})
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const started = start(controller.signal);
|
||||
const rejected = expect(started).rejects.toThrow('cancelled');
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
controller.abort(new Error('cancelled'));
|
||||
await rejected;
|
||||
finishDownload('/tmp/chisel');
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(startChiselServerAsync).not.toHaveBeenCalled();
|
||||
expect(
|
||||
jest
|
||||
.mocked(fs.promises.rm)
|
||||
.mock.calls.filter(([target]) => target === '/tmp/egress-acquisition-test')
|
||||
).toHaveLength(2);
|
||||
expect(fs.promises.rm).toHaveBeenCalledWith('/tmp/egress-acquisition-test', {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
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('reports the session token when serve-sim minted one', async () => {
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewUrl: 'https://web-preview.example.test',
|
||||
previewToken: 'tok-1',
|
||||
stopAsync,
|
||||
});
|
||||
|
||||
await runAsync(BuildRuntimePlatform.DARWIN);
|
||||
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: {
|
||||
previewUrl: 'https://web-preview.example.test',
|
||||
previewToken: 'tok-1',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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,19 +8,15 @@ 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';
|
||||
@@ -30,10 +26,6 @@ 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 {
|
||||
@@ -57,12 +49,7 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'build_id',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'application_archive_url',
|
||||
required: false,
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
@@ -83,39 +70,12 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
|
||||
const extensions = z.array(z.string()).parse(inputs.extensions.value);
|
||||
logger.info(`Expected extensions: [${extensions.join(', ')}]`);
|
||||
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 buildId = z.string().uuid().parse(inputs.build_id.value);
|
||||
logger.info(`Downloading build ${buildId}...`);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger,
|
||||
...source,
|
||||
buildId,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
robotAccessToken: stepsCtx.global.staticContext.job.secrets?.robotAccessToken ?? null,
|
||||
extensions,
|
||||
@@ -155,50 +115,39 @@ async function fetchApplicationArchiveUrlAsync({
|
||||
return applicationArchiveUrl;
|
||||
}
|
||||
|
||||
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.'
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadBuildAsync({
|
||||
logger,
|
||||
buildId,
|
||||
graphqlClient,
|
||||
robotAccessToken,
|
||||
extensions,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
buildId: string;
|
||||
graphqlClient: Client;
|
||||
robotAccessToken: string | null;
|
||||
extensions: string[];
|
||||
}): Promise<{ artifactPath: string }> {
|
||||
const downloadDestinationDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'download_build-downloaded-')
|
||||
);
|
||||
|
||||
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers });
|
||||
const downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient });
|
||||
|
||||
const response = await retryOnDNSFailure(fetch)(downloadUrl, {
|
||||
headers: robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const textResult = await asyncResult(response.text());
|
||||
throw new Error(`Unexpected response from server (${response.status}): ${textResult.value}`);
|
||||
}
|
||||
|
||||
const archiveFilename = resolveArchiveFilename({ response, extensions });
|
||||
// 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 archivePath = path.join(downloadDestinationDirectory, archiveFilename);
|
||||
|
||||
await streamPipeline(response.body, fs.createWriteStream(archivePath));
|
||||
@@ -228,18 +177,6 @@ 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(
|
||||
@@ -252,68 +189,8 @@ export async function downloadBuildAsync(
|
||||
`Found ${matchingFiles.length} matching ${pluralize(
|
||||
matchingFiles.length,
|
||||
'entry'
|
||||
)}:\n${matchingFiles.map(f => `- ${path.relative(matchingFilesRoot, f)}`).join('\n')}`
|
||||
)}:\n${matchingFiles.map(f => `- ${path.relative(extractionDirectory, 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, '_');
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
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,4 +1,3 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
@@ -20,11 +19,6 @@ 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({
|
||||
@@ -154,13 +148,11 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
(requestedVersion === undefined &&
|
||||
(!currentMaestroRunnerVersion || semver.gt(currentMaestroRunnerVersion, '1.1.15'))))
|
||||
) {
|
||||
// `getXcodeVersionAsync` returns the raw version (e.g. "16.4"), which
|
||||
// `semver.coerce` normalizes (e.g. "16.4.0") for comparison and display.
|
||||
const xcodeVersion = semver.coerce(await getXcodeVersionAsync({ env }));
|
||||
const xcodeVersion = await getXcodeVersion({ env });
|
||||
|
||||
// maestro-runner 1.1.16 added `arch` to its xcodebuild destination. Xcode versions
|
||||
// below 26 reject that option, so use the last compatible maestro-runner version.
|
||||
if (xcodeVersion && semver.lt(xcodeVersion, '26.0.0')) {
|
||||
if (semver.lt(xcodeVersion, '26.0.0')) {
|
||||
if (requestedMaestroRunnerVersion) {
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_INVALID_INPUT',
|
||||
@@ -198,13 +190,6 @@ 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);
|
||||
|
||||
@@ -216,140 +201,19 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
});
|
||||
}
|
||||
|
||||
async function installMaestroRunnerWdaCache({
|
||||
logger,
|
||||
env,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<void> {
|
||||
if (!env.HOME) {
|
||||
logger.warn(
|
||||
'Skipping the prebuilt WebDriverAgent cache because the $HOME environment variable is empty.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const maestroRunnerHome = path.join(env.HOME, '.maestro-runner');
|
||||
|
||||
async function getXcodeVersion({ env }: { env: BuildStepEnv }): Promise<string> {
|
||||
let stdout: string;
|
||||
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.'
|
||||
);
|
||||
({ stdout } = await spawn('xcodebuild', ['-version'], { stdio: 'pipe', env }));
|
||||
} catch (error) {
|
||||
throw new SystemError('Failed to get Xcode version', { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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()}`);
|
||||
}
|
||||
return xcodeVersion;
|
||||
}
|
||||
|
||||
async function getMaestroVersion({
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -62,12 +62,6 @@ 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,
|
||||
@@ -150,14 +144,12 @@ 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,
|
||||
@@ -305,51 +297,24 @@ export async function resolveAndroidSigningOptionsAsync({
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves iOS signing options from the job secrets, dispatching on the
|
||||
* requested signing backend.
|
||||
* Resolves iOS signing options from the job secrets.
|
||||
*/
|
||||
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);
|
||||
|
||||
@@ -365,53 +330,3 @@ async function createIosFastlaneOptionsAsync({
|
||||
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,16 +15,10 @@ import path from 'path';
|
||||
import { sendCcacheStatsAsync } from './ccacheStats';
|
||||
import { decompressCacheAsync, downloadCacheAsync, downloadPublicCacheAsync } from './restoreCache';
|
||||
import {
|
||||
CcacheBuildTarget,
|
||||
CACHE_KEY_PREFIX_BY_PLATFORM,
|
||||
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';
|
||||
@@ -41,11 +35,6 @@ 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;
|
||||
@@ -59,20 +48,10 @@ 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,
|
||||
target,
|
||||
platform,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
@@ -84,13 +63,6 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
} else {
|
||||
await restoreCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -120,13 +92,13 @@ export function createCacheStatsBuildFunction(): BuildFunction {
|
||||
export async function restoreCcacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
target,
|
||||
platform,
|
||||
env,
|
||||
secrets,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
workingDirectory: string;
|
||||
target: CcacheBuildTarget;
|
||||
platform: Platform;
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
@@ -164,7 +136,7 @@ export async function restoreCcacheAsync({
|
||||
})
|
||||
);
|
||||
|
||||
const cacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, target);
|
||||
const cacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, platform);
|
||||
logger.info(`Restoring cache key: ${cacheKey}`);
|
||||
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
@@ -175,8 +147,8 @@ export async function restoreCcacheAsync({
|
||||
robotAccessToken,
|
||||
paths: [cachePath],
|
||||
key: cacheKey,
|
||||
keyPrefixes: [getCcacheKeyPrefix(target)],
|
||||
platform: target.platform,
|
||||
keyPrefixes: [CACHE_KEY_PREFIX_BY_PLATFORM[platform]],
|
||||
platform,
|
||||
});
|
||||
|
||||
await decompressCacheAsync({
|
||||
@@ -203,7 +175,7 @@ export async function restoreCcacheAsync({
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
paths: [cachePath],
|
||||
platform: target.platform,
|
||||
platform,
|
||||
});
|
||||
await decompressCacheAsync({
|
||||
archivePath,
|
||||
@@ -218,67 +190,6 @@ 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,16 +15,7 @@ import path from 'path';
|
||||
|
||||
import { compressCacheAsync, uploadCacheAsync } from './saveCache';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import {
|
||||
CcacheBuildTarget,
|
||||
generateDefaultBuildCacheKeyAsync,
|
||||
getCcachePath,
|
||||
} from '../../utils/cacheKey';
|
||||
import {
|
||||
compressCocoapodsCacheAsync,
|
||||
getCocoapodsCachePaths,
|
||||
resolveCocoapodsCacheKeyAsync,
|
||||
} from '../../utils/cocoapodsCache';
|
||||
import { generateDefaultBuildCacheKeyAsync, getCcachePath } from '../../utils/cacheKey';
|
||||
import { generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
|
||||
|
||||
export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFunction {
|
||||
@@ -39,11 +30,6 @@ 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;
|
||||
@@ -57,20 +43,10 @@ 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,
|
||||
target,
|
||||
platform,
|
||||
evictUsedBefore,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
@@ -83,88 +59,22 @@ 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,
|
||||
target,
|
||||
platform,
|
||||
evictUsedBefore,
|
||||
env,
|
||||
secrets,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
workingDirectory: string;
|
||||
target: CcacheBuildTarget;
|
||||
platform: Platform;
|
||||
evictUsedBefore: Date;
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
@@ -189,7 +99,7 @@ export async function saveCcacheAsync({
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, target);
|
||||
const cacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, platform);
|
||||
logger.info(`Saving cache key: ${cacheKey}`);
|
||||
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
@@ -233,7 +143,7 @@ export async function saveCcacheAsync({
|
||||
key: cacheKey,
|
||||
paths: [cachePath],
|
||||
size,
|
||||
platform: target.platform,
|
||||
platform,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to save cache');
|
||||
|
||||
@@ -103,7 +103,6 @@ export async function uploadCacheAsync({
|
||||
archivePath,
|
||||
size,
|
||||
platform,
|
||||
force = false,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
jobId: string;
|
||||
@@ -114,7 +113,6 @@ export async function uploadCacheAsync({
|
||||
archivePath: string;
|
||||
size: number;
|
||||
platform: Platform | undefined;
|
||||
force?: boolean;
|
||||
}): Promise<void> {
|
||||
const routerURL = platform
|
||||
? 'v2/turtle-builds/caches/upload-sessions'
|
||||
@@ -129,14 +127,12 @@ export async function uploadCacheAsync({
|
||||
key,
|
||||
version: getCacheVersion(paths),
|
||||
size,
|
||||
force,
|
||||
})
|
||||
: JSON.stringify({
|
||||
jobRunId: jobId,
|
||||
key,
|
||||
version: getCacheVersion(paths),
|
||||
size,
|
||||
force,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
|
||||
@@ -13,10 +13,6 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
|
||||
@@ -27,8 +23,9 @@ import {
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
@@ -69,7 +66,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
// Fail fast before any expensive setup if the injected env
|
||||
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
|
||||
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
|
||||
@@ -110,30 +107,30 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
const agentDeviceRemoteSessionUrl = agentDeviceTunnel.url;
|
||||
logger.info(`Tunnel is ready at ${agentDeviceRemoteSessionUrl}.`);
|
||||
|
||||
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
|
||||
let serveSim: Awaited<ReturnType<typeof startServeSimWithTunnelAsync>> | undefined;
|
||||
let eventCollection:
|
||||
| Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>>
|
||||
| undefined;
|
||||
try {
|
||||
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
|
||||
// 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}`);
|
||||
}
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
agentDeviceRemoteSessionUrl,
|
||||
agentDeviceRemoteSessionToken: daemonToken,
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
...(serveSim ? { webPreviewUrl: serveSim.previewUrl } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
@@ -166,8 +163,8 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
if (webPreview) {
|
||||
await webPreview.stopAsync();
|
||||
if (serveSim) {
|
||||
await serveSim.stopAsync();
|
||||
}
|
||||
await agentDeviceTunnel.stopAsync();
|
||||
if (eventCollection) {
|
||||
@@ -179,7 +176,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
}
|
||||
await daemonProcess.stopAsync();
|
||||
}
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -46,21 +46,6 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
defaultValue: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'lcd_width',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'lcd_height',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'lcd_density',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'count',
|
||||
required: false,
|
||||
@@ -101,9 +86,6 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
const systemImagePackage = `${inputs.system_image_package.value}`;
|
||||
// We can cast because allowedValueTypeName validated this is a string.
|
||||
const deviceIdentifier = inputs.device_identifier.value as AndroidDeviceName | undefined;
|
||||
const lcdWidth = inputs.lcd_width.value as number | undefined;
|
||||
const lcdHeight = inputs.lcd_height.value as number | undefined;
|
||||
const lcdDensity = inputs.lcd_density.value as number | undefined;
|
||||
const shouldAdjustAnimationScale =
|
||||
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== 'false' &&
|
||||
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== '0';
|
||||
@@ -147,9 +129,6 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
deviceName,
|
||||
systemImagePackage,
|
||||
deviceIdentifier: deviceIdentifier ?? null,
|
||||
lcdWidth: lcdWidth ?? null,
|
||||
lcdHeight: lcdHeight ?? null,
|
||||
lcdDensity: lcdDensity ?? null,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -15,10 +15,6 @@ import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
@@ -30,8 +26,9 @@ import {
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
@@ -65,7 +62,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
@@ -121,7 +118,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
logger,
|
||||
});
|
||||
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
|
||||
let serveSim: Awaited<ReturnType<typeof startServeSimWithTunnelAsync>> | undefined;
|
||||
try {
|
||||
appiumTunnel = await startNgrokTunnelAsync({
|
||||
port: APPIUM_PORT,
|
||||
@@ -131,19 +128,20 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
logger,
|
||||
});
|
||||
|
||||
// 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,
|
||||
});
|
||||
switch (runtimePlatform) {
|
||||
case BuildRuntimePlatform.DARWIN:
|
||||
serveSim = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
break;
|
||||
case BuildRuntimePlatform.LINUX:
|
||||
break;
|
||||
}
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
@@ -153,8 +151,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
'appium:automationName': device.automationName,
|
||||
'appium:udid': device.udid,
|
||||
},
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
...(serveSim ? { webPreviewUrl: serveSim.previewUrl } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
@@ -173,8 +170,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
if (webPreview) {
|
||||
await webPreview.stopAsync();
|
||||
if (serveSim) {
|
||||
await serveSim.stopAsync();
|
||||
}
|
||||
if (appiumTunnel) {
|
||||
await appiumTunnel.stopAsync();
|
||||
@@ -183,7 +180,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
await appiumProcess.stopAsync();
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,24 +14,21 @@ import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { isProcessDescendantOfAsync } from '../../utils/processes';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
|
||||
import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents';
|
||||
import {
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
ensureFfmpegInstalledAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
@@ -82,7 +79,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
// Fail fast before any expensive setup if the injected env
|
||||
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
|
||||
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
|
||||
@@ -106,39 +103,32 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
|
||||
// Start the potentially slow installation while Argent is being prepared.
|
||||
// On Linux expo-device-hub calls this again and awaits the same in-flight
|
||||
// setup before launching. On macOS this remains non-blocking, so only a
|
||||
// recording started in the first moments may miss ffmpeg.
|
||||
// Never rejects, so `void` is safe.
|
||||
void ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
// Argent shells out to ffmpeg to record the screen. Installing it pulls a
|
||||
// large Homebrew dependency tree, so do not block session readiness on it:
|
||||
// the session comes up at its usual speed and only a recording started in
|
||||
// the first moments misses ffmpeg. Never rejects, so `void` is safe.
|
||||
void ensureFfmpegInstalledAsync({ runtimePlatform, env, logger });
|
||||
|
||||
logger.info('Enabling the Argent artifacts list endpoint flag.');
|
||||
await spawn(
|
||||
'bun',
|
||||
[
|
||||
'x',
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
|
||||
],
|
||||
'bunx',
|
||||
[`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG],
|
||||
{ env, logger }
|
||||
);
|
||||
|
||||
logger.info('Enabling the Argent tool-server event log flag.');
|
||||
await spawn(
|
||||
'bun',
|
||||
['x', `${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_EVENT_LOG_FLAG],
|
||||
'bunx',
|
||||
[`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_EVENT_LOG_FLAG],
|
||||
{ env, logger }
|
||||
);
|
||||
|
||||
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bun x.`);
|
||||
// Keep Argent itself in foreground mode under the detached bun process. This preserves
|
||||
// the bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bunx.`);
|
||||
// Keep Argent itself in foreground mode under the detached bunx process. This preserves
|
||||
// the bunx -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
const argentServer = spawnDetached({
|
||||
command: 'bun',
|
||||
command: 'bunx',
|
||||
args: [
|
||||
'x',
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
'start',
|
||||
@@ -196,7 +186,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
});
|
||||
|
||||
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
|
||||
let serveSim: Awaited<ReturnType<typeof startServeSimWithTunnelAsync>> | undefined;
|
||||
try {
|
||||
toolsTunnel = await startNgrokTunnelAsync({
|
||||
port: toolServerPort,
|
||||
@@ -209,25 +199,26 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
const publicToolsUrl = toolsTunnel.url;
|
||||
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
|
||||
|
||||
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
|
||||
// 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}`);
|
||||
}
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
toolsUrl: publicToolsUrl,
|
||||
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
...(webPreviewUrl ? { webPreviewUrl } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
@@ -247,8 +238,8 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
if (webPreview) {
|
||||
await webPreview.stopAsync();
|
||||
if (serveSim) {
|
||||
await serveSim.stopAsync();
|
||||
}
|
||||
if (toolsTunnel) {
|
||||
await toolsTunnel.stopAsync();
|
||||
@@ -264,7 +255,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
}
|
||||
await argentServer.stopAsync();
|
||||
}
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -306,7 +297,7 @@ export function warnIfArgentPackageVersionCannotBeVerified({
|
||||
logger.warn(
|
||||
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer, ` +
|
||||
`but package_version "${packageVersion}" is not an exact semver version that EAS can verify. ` +
|
||||
`Continuing and letting bun x resolve it.`
|
||||
`Continuing and letting bunx resolve it.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
CHISEL_VERSION,
|
||||
LOCAL_EGRESS_HANDOFF_PATH,
|
||||
LOCAL_EGRESS_PROXY_PORT,
|
||||
configureSystemProxyAsync,
|
||||
createChiselAuthfileContents,
|
||||
downloadChiselAsync,
|
||||
generateEgressCredentials,
|
||||
registerLocalEgressResources,
|
||||
startChiselServerAsync,
|
||||
stopLocalEgressResourcesAsync,
|
||||
writeLocalEgressHandoffAsync,
|
||||
} from '../utils/localEgress';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
type NgrokTunnelHandle,
|
||||
findAvailablePortAsync,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
startNgrokTunnelAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
/**
|
||||
* The downloader and ngrok SDK do not accept an AbortSignal. Stop awaiting them
|
||||
* on cancellation, while retaining handlers that dispose any eventual result.
|
||||
*/
|
||||
function awaitLocalEgressAcquisitionAsync<T>(
|
||||
pending: Promise<T>,
|
||||
signal: AbortSignal,
|
||||
disposeAbandonedAsync: (result?: T) => Promise<void>,
|
||||
logger: bunyan
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let abandoned = false;
|
||||
const abort = (): void => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
abandoned = true;
|
||||
reject(signal.reason);
|
||||
};
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
if (signal.aborted) {
|
||||
abort();
|
||||
}
|
||||
void pending
|
||||
.then(
|
||||
async result => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
if (abandoned) {
|
||||
await disposeAbandonedAsync(result);
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
},
|
||||
async error => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
if (abandoned) {
|
||||
await disposeAbandonedAsync();
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
)
|
||||
.catch(err =>
|
||||
logger.warn({ err }, 'Could not release a cancelled local egress acquisition.')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Points the device host's system HTTP(S) proxy at the EAS CLI's egress client.
|
||||
* Must run before `eas/start_ios_simulator`: the simulator reads the system proxy
|
||||
* at boot. Nothing else on the host changes, so requests from libraries that
|
||||
* bypass the system proxy are not covered; the shared session monitor reports
|
||||
* them. The shared session cleanup releases the resources started here when
|
||||
* the session ends, with a job finalizer as a fallback.
|
||||
*/
|
||||
export function createStartLocalEgressBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_local_egress',
|
||||
name: 'Start local egress',
|
||||
__metricsId: 'eas/start_local_egress',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
fn: async ({ logger }, { env, signal }) => {
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
let workDir: string | undefined;
|
||||
let server: DetachedProcessHandle | undefined;
|
||||
let tunnel: NgrokTunnelHandle | undefined;
|
||||
let finishSetup!: () => void;
|
||||
const setupFinished = new Promise<void>(resolve => {
|
||||
finishSetup = resolve;
|
||||
});
|
||||
// Register before acquisition. Cancellation ends setup promptly; pending
|
||||
// SDK calls retain their own late-result disposal handlers.
|
||||
const lifetimeSignal = registerLocalEgressResources(async () => {
|
||||
await setupFinished;
|
||||
const results = await Promise.allSettled([
|
||||
Promise.resolve().then(() => tunnel?.stopAsync()),
|
||||
Promise.resolve().then(() => server?.stopAsync()),
|
||||
]);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
logger.warn({ err: result.reason }, 'Could not stop a local egress resource.');
|
||||
}
|
||||
}
|
||||
await Promise.all([
|
||||
fs.promises.rm(LOCAL_EGRESS_HANDOFF_PATH, { force: true }),
|
||||
workDir ? fs.promises.rm(workDir, { recursive: true, force: true }) : undefined,
|
||||
]);
|
||||
});
|
||||
const startupSignal = signal ? AbortSignal.any([signal, lifetimeSignal]) : lifetimeSignal;
|
||||
|
||||
try {
|
||||
startupSignal.throwIfAborted();
|
||||
workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-local-egress-'));
|
||||
startupSignal.throwIfAborted();
|
||||
logger.info(`Downloading the reverse tunnel server (chisel ${CHISEL_VERSION}).`);
|
||||
const downloadDir = workDir;
|
||||
const chiselPath = await awaitLocalEgressAcquisitionAsync(
|
||||
downloadChiselAsync({ destinationDir: downloadDir, logger }),
|
||||
startupSignal,
|
||||
async () => await fs.promises.rm(downloadDir, { recursive: true, force: true }),
|
||||
logger
|
||||
);
|
||||
startupSignal.throwIfAborted();
|
||||
|
||||
const credentials = generateEgressCredentials();
|
||||
const authfilePath = path.join(workDir, 'authfile.json');
|
||||
await fs.promises.writeFile(
|
||||
authfilePath,
|
||||
createChiselAuthfileContents({ ...credentials, port: LOCAL_EGRESS_PROXY_PORT }),
|
||||
{ encoding: 'utf8', mode: 0o600 }
|
||||
);
|
||||
startupSignal.throwIfAborted();
|
||||
|
||||
const controlPort = await findAvailablePortAsync();
|
||||
startupSignal.throwIfAborted();
|
||||
logger.info(`Starting the reverse tunnel server on 127.0.0.1:${controlPort}.`);
|
||||
const started = await startChiselServerAsync({
|
||||
chiselPath,
|
||||
controlPort,
|
||||
authfilePath,
|
||||
env,
|
||||
signal: startupSignal,
|
||||
});
|
||||
server = started.process;
|
||||
startupSignal.throwIfAborted();
|
||||
tunnel = await awaitLocalEgressAcquisitionAsync(
|
||||
startNgrokTunnelAsync({
|
||||
port: controlPort,
|
||||
subdomainPrefix: 'egress',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
logger,
|
||||
}),
|
||||
startupSignal,
|
||||
async listener => await listener?.stopAsync(),
|
||||
logger
|
||||
);
|
||||
startupSignal.throwIfAborted();
|
||||
|
||||
const { service } = await configureSystemProxyAsync({
|
||||
env,
|
||||
logger,
|
||||
port: LOCAL_EGRESS_PROXY_PORT,
|
||||
signal: startupSignal,
|
||||
});
|
||||
startupSignal.throwIfAborted();
|
||||
await writeLocalEgressHandoffAsync({
|
||||
url: tunnel.url,
|
||||
token: credentials.password,
|
||||
fingerprint: started.fingerprint,
|
||||
port: LOCAL_EGRESS_PROXY_PORT,
|
||||
});
|
||||
startupSignal.throwIfAborted();
|
||||
logger.info(
|
||||
`Local egress is configured on network service "${service}". HTTP(S) and WebSocket ` +
|
||||
'requests that honor the system proxy (WebKit, URLSession and other CFNetwork clients) ' +
|
||||
'fail until the EAS CLI egress client connects, then exit from that machine. Requests ' +
|
||||
'from libraries that bypass the system proxy are not covered and exit from this worker. ' +
|
||||
'Connection sampling may report these bypasses, but can miss short connections and unconnected UDP traffic.'
|
||||
);
|
||||
} catch (error) {
|
||||
finishSetup();
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
throw error;
|
||||
} finally {
|
||||
finishSetup();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
+14
-32
@@ -6,73 +6,55 @@ import {
|
||||
} from '@expo/steps';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
|
||||
export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
export function createStartServeSimRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_serve_sim_remote_session',
|
||||
name: 'Start web preview remote session',
|
||||
name: 'Start serve-sim 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: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
fn: async ({ logger }, { inputs, env, signal }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
const packageVersion = inputs.package_version?.value as string | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
|
||||
logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`);
|
||||
logger.info('Starting serve-sim remote session.');
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
|
||||
const webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
runtimePlatform,
|
||||
const serveSim = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
packageVersion,
|
||||
});
|
||||
logger.info(`Preview URL: ${webPreview.previewUrl}`);
|
||||
logger.info(`Preview URL: ${serveSim.previewUrl}`);
|
||||
|
||||
try {
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
previewUrl: webPreview.previewUrl,
|
||||
...(webPreview.previewToken ? { previewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
remoteConfig: { previewUrl: serveSim.previewUrl },
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -84,8 +66,8 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
signal,
|
||||
});
|
||||
} finally {
|
||||
await webPreview.stopAsync();
|
||||
await serveSim.stopAsync();
|
||||
}
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -90,24 +90,19 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
const ascApiKeyJson = await fs.readJson(ascApiKeyPath);
|
||||
const ascApiKey = z
|
||||
.object({
|
||||
issuer_id: z.string().nullish(),
|
||||
issuer_id: z.string(),
|
||||
key_id: z.string(),
|
||||
key: z.string(),
|
||||
})
|
||||
.parse(ascApiKeyJson);
|
||||
|
||||
const privateKey = await jose.importPKCS8(ascApiKey.key, 'ES256');
|
||||
const jwt = new jose.SignJWT({})
|
||||
const token = await new jose.SignJWT({})
|
||||
.setProtectedHeader({ alg: 'ES256', kid: ascApiKey.key_id })
|
||||
.setIssuer(ascApiKey.issuer_id)
|
||||
.setAudience('appstoreconnect-v1')
|
||||
.setExpirationTime('20m');
|
||||
if (ascApiKey.issuer_id) {
|
||||
jwt.setIssuer(ascApiKey.issuer_id);
|
||||
} else {
|
||||
// Nullish issuer_id means an individual API key
|
||||
jwt.setSubject('user');
|
||||
}
|
||||
const token = await jwt.sign(privateKey);
|
||||
.setExpirationTime('20m')
|
||||
.sign(privateKey);
|
||||
|
||||
const client = new AscApiClient({ token, logger: stepsCtx.logger });
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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,10 +58,7 @@ describe(startAppiumEventCollectionAsync, () => {
|
||||
producer: 'appium',
|
||||
type: 'operation.completed',
|
||||
durationMs: 25,
|
||||
summary: 'Read the screen orientation',
|
||||
data: {
|
||||
command: 'getOrientation',
|
||||
},
|
||||
summary: 'getOrientation',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -67,17 +67,10 @@ 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,
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
@@ -91,17 +84,10 @@ 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' },
|
||||
@@ -157,7 +143,6 @@ describe(uploadArgentArtifactAsync, () => {
|
||||
|
||||
async function uploadAsync(artifact: {
|
||||
id: string;
|
||||
kind?: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
isDirectory?: boolean;
|
||||
@@ -200,42 +185,7 @@ describe(uploadArgentArtifactAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
it('leaves directory artifacts unclassified because they are uploaded as a tarball', async () => {
|
||||
const ctx = await uploadAsync({
|
||||
id: 'artifact-id',
|
||||
filename: 'screenshots',
|
||||
|
||||
@@ -1,425 +0,0 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { spawnDetached } from '../remoteDeviceRunSession';
|
||||
|
||||
import {
|
||||
CHISEL_VERSION,
|
||||
LOCAL_EGRESS_PROXY_PORT,
|
||||
buildEgressRemoteConfigFields,
|
||||
buildNetworksetupProxyArgs,
|
||||
collectSimulatorProcessIds,
|
||||
createChiselAuthfileContents,
|
||||
getChiselAssetName,
|
||||
getChiselDownloadUrl,
|
||||
parseChiselFingerprint,
|
||||
parseDefaultRouteInterface,
|
||||
parseDirectSimulatorConnections,
|
||||
parseExitIpResponse,
|
||||
parseNetworkServiceNameForDevice,
|
||||
readLocalEgressHandoffAsync,
|
||||
registerLocalEgressResources,
|
||||
startChiselServerAsync,
|
||||
stopLocalEgressResourcesAsync,
|
||||
writeLocalEgressHandoffAsync,
|
||||
} from '../localEgress';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../remoteDeviceRunSession', () => ({ spawnDetached: jest.fn() }));
|
||||
|
||||
describe(getChiselAssetName, () => {
|
||||
it.each([
|
||||
['darwin', 'arm64', `chisel_${CHISEL_VERSION}_darwin_arm64.gz`],
|
||||
['darwin', 'x64', `chisel_${CHISEL_VERSION}_darwin_amd64.gz`],
|
||||
['linux', 'x64', `chisel_${CHISEL_VERSION}_linux_amd64.gz`],
|
||||
['linux', 'arm64', `chisel_${CHISEL_VERSION}_linux_arm64.gz`],
|
||||
] as const)('maps %s/%s to %s', (platform, arch, expected) => {
|
||||
expect(getChiselAssetName({ platform, arch })).toBe(expected);
|
||||
});
|
||||
|
||||
it('rejects platforms without a pinned binary', () => {
|
||||
expect(() => getChiselAssetName({ platform: 'win32', arch: 'x64' })).toThrow(
|
||||
'Local egress is not supported on win32/x64'
|
||||
);
|
||||
expect(() => getChiselAssetName({ platform: 'linux', arch: 'ia32' })).toThrow(
|
||||
'Local egress is not supported on linux/ia32'
|
||||
);
|
||||
});
|
||||
|
||||
it('downloads from the pinned GitHub release', () => {
|
||||
expect(getChiselDownloadUrl(`chisel_${CHISEL_VERSION}_darwin_arm64.gz`)).toBe(
|
||||
`https://github.com/jpillora/chisel/releases/download/v${CHISEL_VERSION}/chisel_${CHISEL_VERSION}_darwin_arm64.gz`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(createChiselAuthfileContents, () => {
|
||||
it('allows reverse remotes on the loopback proxy port and unprivileged loopback ports only', () => {
|
||||
const contents = createChiselAuthfileContents({ user: 'eas', password: 'pw', port: 8899 });
|
||||
const parsed = JSON.parse(contents) as Record<string, string[]>;
|
||||
expect(parsed['eas:pw']).toHaveLength(2);
|
||||
expect(parsed['eas:pw'][0]).toBe('^R:127\\.0\\.0\\.1:8899$');
|
||||
|
||||
const patterns = parsed['eas:pw'].map(source => new RegExp(source));
|
||||
const allowed = (remote: string): boolean => patterns.some(pattern => pattern.test(remote));
|
||||
expect(allowed('R:127.0.0.1:8899')).toBe(true);
|
||||
expect(allowed('R:127.0.0.1:1024')).toBe(true);
|
||||
expect(allowed('R:127.0.0.1:3000')).toBe(true);
|
||||
expect(allowed('R:127.0.0.1:8081')).toBe(true);
|
||||
expect(allowed('R:127.0.0.1:65535')).toBe(true);
|
||||
// Privileged and out-of-range ports, other interfaces, and forward remotes stay denied.
|
||||
expect(allowed('R:127.0.0.1:1023')).toBe(false);
|
||||
expect(allowed('R:127.0.0.1:80')).toBe(false);
|
||||
expect(allowed('R:127.0.0.1:0')).toBe(false);
|
||||
expect(allowed('R:127.0.0.1:65536')).toBe(false);
|
||||
expect(allowed('R:127.0.0.1:88990')).toBe(false);
|
||||
expect(allowed('R:0.0.0.0:8899')).toBe(false);
|
||||
expect(allowed('R:0.0.0.0:3000')).toBe(false);
|
||||
expect(allowed('R:[::1]:3000')).toBe(false);
|
||||
expect(allowed('R:127x0x0x1:8899')).toBe(false);
|
||||
expect(allowed('R:socks')).toBe(false);
|
||||
expect(allowed('example.com:443')).toBe(false);
|
||||
expect(allowed('127.0.0.1:3000')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseChiselFingerprint, () => {
|
||||
it('extracts the fingerprint chisel prints at startup', () => {
|
||||
const output =
|
||||
'2026/09/07 22:00:00 server: Fingerprint eZm5d2ZQ+Zb2aS0lJm7A5GkWm9G9ZqnPz2W0i2Y6Q1c=\n' +
|
||||
'2026/09/07 22:00:00 server: Reverse tunnelling enabled\n' +
|
||||
'2026/09/07 22:00:00 server: Listening on http://127.0.0.1:52001\n';
|
||||
expect(parseChiselFingerprint(output)).toBe('eZm5d2ZQ+Zb2aS0lJm7A5GkWm9G9ZqnPz2W0i2Y6Q1c=');
|
||||
});
|
||||
|
||||
it('returns null before the fingerprint line appears', () => {
|
||||
expect(parseChiselFingerprint('')).toBeNull();
|
||||
expect(parseChiselFingerprint('2026/09/07 22:00:00 server: starting')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseDefaultRouteInterface, () => {
|
||||
it('reads the interface from `route -n get default`', () => {
|
||||
const output =
|
||||
' route to: default\n' +
|
||||
'destination: default\n' +
|
||||
' mask: default\n' +
|
||||
' gateway: 172.20.10.1\n' +
|
||||
' interface: en0\n' +
|
||||
' flags: <UP,GATEWAY,DONE,STATIC,PRCLONING,GLOBAL>\n';
|
||||
expect(parseDefaultRouteInterface(output)).toBe('en0');
|
||||
});
|
||||
|
||||
it('returns null when there is no default route', () => {
|
||||
expect(
|
||||
parseDefaultRouteInterface('route: writing to routing socket: not in table\n')
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseNetworkServiceNameForDevice, () => {
|
||||
const listOutput =
|
||||
'An asterisk (*) denotes that a network service is disabled.\n' +
|
||||
'(1) Thunderbolt Ethernet Slot 0\n' +
|
||||
'(Hardware Port: Thunderbolt Ethernet Slot 0, Device: en8)\n' +
|
||||
'\n' +
|
||||
'(2) Thunderbolt Bridge\n' +
|
||||
'(Hardware Port: Thunderbolt Bridge, Device: bridge0)\n' +
|
||||
'\n' +
|
||||
'(3) Wi-Fi\n' +
|
||||
'(Hardware Port: Wi-Fi, Device: en0)\n' +
|
||||
'\n' +
|
||||
'(4) iPhone USB\n' +
|
||||
'(Hardware Port: iPhone USB, Device: en9)\n';
|
||||
|
||||
it('returns the service whose device matches', () => {
|
||||
expect(parseNetworkServiceNameForDevice(listOutput, 'en0')).toBe('Wi-Fi');
|
||||
expect(parseNetworkServiceNameForDevice(listOutput, 'en8')).toBe('Thunderbolt Ethernet Slot 0');
|
||||
});
|
||||
|
||||
it('returns null for an unknown device', () => {
|
||||
expect(parseNetworkServiceNameForDevice(listOutput, 'utun3')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildNetworksetupProxyArgs, () => {
|
||||
it('sets both the HTTP and HTTPS proxy to the loopback egress port', () => {
|
||||
expect(
|
||||
buildNetworksetupProxyArgs({ service: 'Ethernet', host: '127.0.0.1', port: 8899 })
|
||||
).toEqual([
|
||||
['-setwebproxy', 'Ethernet', '127.0.0.1', '8899'],
|
||||
['-setsecurewebproxy', 'Ethernet', '127.0.0.1', '8899'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(collectSimulatorProcessIds, () => {
|
||||
const runtimeRoot =
|
||||
'/Library/Developer/CoreSimulator/Volumes/iOS_23F77/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.5.simruntime/Contents/Resources/RuntimeRoot';
|
||||
const psOutput =
|
||||
' 1 0 /sbin/launchd\n' +
|
||||
' 501 1 /usr/local/bin/node\n' +
|
||||
` 600 1 ${runtimeRoot}/sbin/launchd_sim\n` +
|
||||
' 610 600 /Users/expo/Library/Developer/CoreSimulator/Devices/ABC/data/Containers/Bundle/Application/DEF/App.app/App\n' +
|
||||
` 611 600 ${runtimeRoot}/System/Library/ExtensionKit/Extensions/NetworkingExtension.appex/com.apple.WebKit.Networking\n` +
|
||||
' 620 610 /usr/bin/helper\n' +
|
||||
' 700 1 /usr/sbin/lsof\n';
|
||||
|
||||
it('returns every descendant of launchd_sim and nothing else', () => {
|
||||
expect(collectSimulatorProcessIds(psOutput).sort((a, b) => a - b)).toEqual([610, 611, 620]);
|
||||
});
|
||||
|
||||
it('returns nothing when no simulator is running', () => {
|
||||
expect(
|
||||
collectSimulatorProcessIds(' 1 0 /sbin/launchd\n 501 1 /usr/local/bin/node\n')
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseDirectSimulatorConnections, () => {
|
||||
const lsofOutput = [
|
||||
'p610',
|
||||
'cApp',
|
||||
'f10',
|
||||
'PTCP',
|
||||
'n127.0.0.1:52344->127.0.0.1:8899',
|
||||
'TST=ESTABLISHED',
|
||||
'f11',
|
||||
'PTCP',
|
||||
'n192.168.64.2:52345->93.184.216.34:443',
|
||||
'TST=ESTABLISHED',
|
||||
'f12',
|
||||
'PTCP',
|
||||
'n192.168.64.2:52346->104.16.0.1:443',
|
||||
'TST=SYN_SENT',
|
||||
'f13',
|
||||
'PTCP',
|
||||
'n*:8080',
|
||||
'TST=LISTEN',
|
||||
'f14',
|
||||
'PUDP',
|
||||
'n*:5353',
|
||||
'f15',
|
||||
'PUDP',
|
||||
'n192.168.64.2:60000->1.1.1.1:443',
|
||||
'f16',
|
||||
'PTCP',
|
||||
'n[::1]:52347->[::1]:8899',
|
||||
'TST=ESTABLISHED',
|
||||
'f17',
|
||||
'PTCP',
|
||||
'n[fd00::2]:52348->[2606:4700:4700::1111]:443',
|
||||
'TST=ESTABLISHED',
|
||||
'p700',
|
||||
'cnode',
|
||||
'f20',
|
||||
'PTCP',
|
||||
'n192.168.64.2:52349->93.184.216.34:443',
|
||||
'TST=ESTABLISHED',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
it('reports simulator connections to non-loopback peers only', () => {
|
||||
expect(parseDirectSimulatorConnections(lsofOutput, new Set([610]))).toEqual([
|
||||
{ pid: 610, command: 'App', protocol: 'TCP', remote: '93.184.216.34:443' },
|
||||
{ pid: 610, command: 'App', protocol: 'TCP', remote: '104.16.0.1:443' },
|
||||
{ pid: 610, command: 'App', protocol: 'UDP', remote: '1.1.1.1:443' },
|
||||
{ pid: 610, command: 'App', protocol: 'TCP', remote: '[2606:4700:4700::1111]:443' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores processes outside the simulator', () => {
|
||||
expect(parseDirectSimulatorConnections(lsofOutput, new Set([700]))).toEqual([
|
||||
{ pid: 700, command: 'node', protocol: 'TCP', remote: '93.184.216.34:443' },
|
||||
]);
|
||||
expect(parseDirectSimulatorConnections(lsofOutput, new Set([999]))).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns nothing for an empty listing', () => {
|
||||
expect(parseDirectSimulatorConnections('', new Set([610]))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildEgressRemoteConfigFields, () => {
|
||||
it('returns nothing without a handoff', () => {
|
||||
expect(buildEgressRemoteConfigFields(null)).toEqual({});
|
||||
});
|
||||
|
||||
it('maps the handoff to the remoteConfig field names', () => {
|
||||
expect(
|
||||
buildEgressRemoteConfigFields({
|
||||
url: 'https://egress-abc.eas-simulator.ngrok.dev',
|
||||
token: 'pw',
|
||||
fingerprint: 'fp=',
|
||||
port: LOCAL_EGRESS_PROXY_PORT,
|
||||
})
|
||||
).toEqual({
|
||||
egressUrl: 'https://egress-abc.eas-simulator.ngrok.dev',
|
||||
egressToken: 'pw',
|
||||
egressFingerprint: 'fp=',
|
||||
egressPort: LOCAL_EGRESS_PROXY_PORT,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseExitIpResponse, () => {
|
||||
it('reads the ip field', () => {
|
||||
expect(parseExitIpResponse('{"ip":"64.114.211.44"}')).toBe('64.114.211.44');
|
||||
});
|
||||
|
||||
it('rejects responses without an ip', () => {
|
||||
expect(() => parseExitIpResponse('{"error":"rate limited"}')).toThrow(
|
||||
'Unexpected exit IP response'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('local egress handoff', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'local-egress-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('round-trips through the handoff file', async () => {
|
||||
const handoffPath = path.join(tempDir, 'handoff.json');
|
||||
const handoff = {
|
||||
url: 'https://egress-abc.eas-simulator.ngrok.dev',
|
||||
token: 'pw',
|
||||
fingerprint: 'fp=',
|
||||
port: 8899,
|
||||
};
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
await expect(readLocalEgressHandoffAsync(handoffPath)).resolves.toEqual(handoff);
|
||||
});
|
||||
|
||||
it('returns null when no egress step ran', async () => {
|
||||
await expect(
|
||||
readLocalEgressHandoffAsync(path.join(tempDir, 'missing.json'))
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a malformed handoff', async () => {
|
||||
const handoffPath = path.join(tempDir, 'handoff.json');
|
||||
await fs.promises.writeFile(handoffPath, JSON.stringify({ url: 'x' }), 'utf8');
|
||||
await expect(readLocalEgressHandoffAsync(handoffPath)).rejects.toThrow('malformed');
|
||||
});
|
||||
});
|
||||
|
||||
describe(startChiselServerAsync, () => {
|
||||
const options = {
|
||||
chiselPath: '/tmp/chisel',
|
||||
controlPort: 52001,
|
||||
authfilePath: '/tmp/authfile.json',
|
||||
env: { PATH: '/bin', AUTH: 'unexpected:password' },
|
||||
};
|
||||
let output: string;
|
||||
let stopAsync: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
output = 'server: Fingerprint pinned-key=\n';
|
||||
stopAsync = jest.fn().mockResolvedValue(undefined);
|
||||
jest.spyOn(process, 'kill').mockReturnValue(true);
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 12345,
|
||||
getOutput: () => output,
|
||||
stopAsync,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('waits for the child to bind its exact port and clears inherited AUTH', async () => {
|
||||
let resolved = false;
|
||||
const started = startChiselServerAsync(options).then(result => {
|
||||
resolved = true;
|
||||
return result;
|
||||
});
|
||||
output += 'server: Listening on http://127.0.0.1:520010\n';
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
expect(resolved).toBe(false);
|
||||
output += 'server: Listening on http://127.0.0.1:52001\n';
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
await expect(started).resolves.toMatchObject({ fingerprint: 'pinned-key=' });
|
||||
expect(spawnDetached).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ env: { PATH: '/bin', AUTH: '' } })
|
||||
);
|
||||
expect(options.env.AUTH).toBe('unexpected:password');
|
||||
expect(stopAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects and stops a child that prints its fingerprint then fails to bind', async () => {
|
||||
const started = startChiselServerAsync(options);
|
||||
const rejected = expect(started).rejects.toThrow('address already in use');
|
||||
output += 'listen tcp 127.0.0.1:52001: bind: address already in use\n';
|
||||
jest.mocked(process.kill).mockImplementation(() => {
|
||||
throw new Error('ESRCH');
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
await rejected;
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops the child when startup is cancelled', async () => {
|
||||
const controller = new AbortController();
|
||||
const started = startChiselServerAsync({ ...options, signal: controller.signal });
|
||||
const rejected = expect(started).rejects.toThrow('cancelled');
|
||||
controller.abort(new Error('cancelled'));
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
await rejected;
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('local egress resource lifetime', () => {
|
||||
const logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
|
||||
it('aborts the lifetime and awaits the same cleanup for concurrent callers', async () => {
|
||||
let finish!: () => void;
|
||||
const cleanup = jest.fn(
|
||||
() =>
|
||||
new Promise<void>(resolve => {
|
||||
finish = resolve;
|
||||
})
|
||||
);
|
||||
const signal = registerLocalEgressResources(cleanup);
|
||||
const first = stopLocalEgressResourcesAsync(logger);
|
||||
const second = stopLocalEgressResourcesAsync(logger);
|
||||
expect(signal.aborted).toBe(true);
|
||||
await Promise.resolve();
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
let complete = false;
|
||||
void second.then(() => {
|
||||
complete = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(complete).toBe(false);
|
||||
finish();
|
||||
await Promise.all([first, second]);
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports cleanup failure without masking the job result', async () => {
|
||||
const error = new Error('cleanup failed');
|
||||
registerLocalEgressResources(async () => {
|
||||
throw error;
|
||||
});
|
||||
await expect(stopLocalEgressResourcesAsync(logger)).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: error },
|
||||
'Could not stop a local egress resource.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -11,20 +11,16 @@ import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
|
||||
import { sleepAsync } from '../../../utils/retry';
|
||||
import {
|
||||
createExpoDeviceHubArgs,
|
||||
createServeSimArgs,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
ensureFfmpegInstalledAsync,
|
||||
fetchServeSimTurnArgsAsync,
|
||||
metricsCorsOriginToServeSimArgs,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startExpoDeviceHubWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
turnIceServersToWebPreviewArgs,
|
||||
turnIceServersToServeSimArgs,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForWebPreviewReadyAsync,
|
||||
waitForServeSimReadyAsync,
|
||||
} from '../remoteDeviceRunSession';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
@@ -34,11 +30,6 @@ jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../../utils/retry', () => ({ sleepAsync: jest.fn() }));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
// Spyable so a test can stand in for the serve-sim state directory, which a local serve-sim owns.
|
||||
jest.mock('../serveSimMetricsRecorder', () => {
|
||||
const actual = jest.requireActual('../serveSimMetricsRecorder');
|
||||
return { ...actual, readServeSimServersAsync: jest.fn(actual.readServeSimServersAsync) };
|
||||
});
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return {
|
||||
@@ -136,17 +127,16 @@ describe(createServeSimArgs, () => {
|
||||
'4321',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--require-token',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'vp8',
|
||||
'--max-dimension',
|
||||
'960',
|
||||
'1280',
|
||||
'--mjpeg-quality',
|
||||
'0.55',
|
||||
'--video-bitrate',
|
||||
'6000000',
|
||||
'3000000',
|
||||
'--video-fps',
|
||||
'60',
|
||||
'--turn-url',
|
||||
@@ -167,63 +157,6 @@ 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',
|
||||
'960',
|
||||
'--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, () => {
|
||||
@@ -253,7 +186,7 @@ describe(metricsCorsOriginToServeSimArgs, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(waitForWebPreviewReadyAsync, () => {
|
||||
describe(waitForServeSimReadyAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
jest.mocked(sleepAsync).mockReset();
|
||||
@@ -268,9 +201,8 @@ describe(waitForWebPreviewReadyAsync, () => {
|
||||
json: async () => ({ status: 'ready', device: 'DEVICE-A' }),
|
||||
} as unknown as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
|
||||
await waitForWebPreviewReadyAsync({
|
||||
previewServer: { pid: undefined, getOutput: () => '' },
|
||||
serverName: 'expo-device-hub',
|
||||
await waitForServeSimReadyAsync({
|
||||
serveSim: { pid: undefined, getOutput: () => '' },
|
||||
port: 4321,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
@@ -315,239 +247,14 @@ describe(startNgrokTunnelAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid: 'device-id', url: 'http://127.0.0.1:1', token: 'tok-1' }]);
|
||||
|
||||
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('installs ffmpeg before starting expo-device-hub for Linux', 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);
|
||||
jest
|
||||
.mocked(spawn)
|
||||
.mockReturnValueOnce(
|
||||
Promise.reject(new Error('ffmpeg is missing')) as ReturnType<typeof spawn>
|
||||
)
|
||||
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>)
|
||||
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion,
|
||||
});
|
||||
|
||||
const spawnCalls = jest.mocked(spawn).mock.calls;
|
||||
expect(spawnCalls[0]).toEqual(['ffmpeg', ['-version'], { env }]);
|
||||
expect(spawnCalls[1]).toEqual([
|
||||
'sudo',
|
||||
['apt-get', 'update'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ DEBIAN_FRONTEND: 'noninteractive' }),
|
||||
}),
|
||||
]);
|
||||
expect(spawnCalls[2]).toEqual([
|
||||
'sudo',
|
||||
['apt-get', 'install', '-y', 'ffmpeg'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ DEBIAN_FRONTEND: 'noninteractive' }),
|
||||
}),
|
||||
]);
|
||||
const expoDeviceHubCallIndex = spawnCalls.findIndex(([command]) => command === 'npx');
|
||||
expect(expoDeviceHubCallIndex).toBeGreaterThan(2);
|
||||
expect(jest.mocked(spawn).mock.invocationCallOrder[2]).toBeLessThan(
|
||||
jest.mocked(spawn).mock.invocationCallOrder[expoDeviceHubCallIndex]
|
||||
);
|
||||
const [command, args] = spawnCalls[expoDeviceHubCallIndex];
|
||||
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('carries the serve-sim session token for Darwin', async () => {
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://preview.example.test',
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(preview.previewToken).toBe('tok-1');
|
||||
expect(preview.previewUrl).toBe('https://preview.example.test');
|
||||
});
|
||||
|
||||
// serve-sim is always launched with --require-token, so a missing token means it is running
|
||||
// ungated on a public tunnel. Failing beats handing out a preview that is dead or unprotected.
|
||||
it('fails for Darwin when serve-sim reports no token', async () => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid: 'device-id', url: 'http://127.0.0.1:1' }]);
|
||||
|
||||
await expect(
|
||||
startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
})
|
||||
).rejects.toThrow(/wrote no session token/);
|
||||
});
|
||||
|
||||
// expo-device-hub mints no token, so the Android preview must not require one.
|
||||
it('starts for Linux without a token, and does not gate expo-device-hub', async () => {
|
||||
jest.mocked(readServeSimServersAsync).mockResolvedValue([]);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://android-preview.example.test',
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(preview.previewToken).toBeUndefined();
|
||||
const [, args] = jest.mocked(spawn).mock.calls[0];
|
||||
expect(args).not.toContain('--require-token');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('does not install ffmpeg before starting expo-device-hub outside Linux', async () => {
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://android-preview.example.test',
|
||||
close,
|
||||
} as never);
|
||||
|
||||
const preview = await startExpoDeviceHubWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(jest.mocked(spawn).mock.calls[0][0]).toBe('npx');
|
||||
expect(jest.mocked(spawn)).not.toHaveBeenCalledWith(
|
||||
'ffmpeg',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
|
||||
await preview.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe(turnIceServersToWebPreviewArgs, () => {
|
||||
describe(turnIceServersToServeSimArgs, () => {
|
||||
it('returns no args for an empty ICE server list', () => {
|
||||
expect(turnIceServersToWebPreviewArgs([])).toEqual([]);
|
||||
expect(turnIceServersToServeSimArgs([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('builds --stun-url and --turn-url flags from Cloudflare ICE servers', () => {
|
||||
expect(
|
||||
turnIceServersToWebPreviewArgs([
|
||||
turnIceServersToServeSimArgs([
|
||||
{ urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.cloudflare.com:53'] },
|
||||
{
|
||||
urls: [
|
||||
@@ -572,7 +279,7 @@ describe(turnIceServersToWebPreviewArgs, () => {
|
||||
|
||||
it('emits only --turn-url flags when no credential-less (STUN) entry is present', () => {
|
||||
expect(
|
||||
turnIceServersToWebPreviewArgs([
|
||||
turnIceServersToServeSimArgs([
|
||||
{
|
||||
urls: ['turns:turn.cloudflare.com:443?transport=tcp'],
|
||||
username: 'u',
|
||||
@@ -590,19 +297,19 @@ describe(turnIceServersToWebPreviewArgs, () => {
|
||||
});
|
||||
|
||||
it('emits only --stun-url when there is no credentialed TURN entry', () => {
|
||||
expect(turnIceServersToWebPreviewArgs([{ urls: ['stun:stun.cloudflare.com:3478'] }])).toEqual([
|
||||
expect(turnIceServersToServeSimArgs([{ urls: ['stun:stun.cloudflare.com:3478'] }])).toEqual([
|
||||
'--stun-url',
|
||||
'stun:stun.cloudflare.com:3478',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(fetchWebPreviewTurnArgsAsync, () => {
|
||||
describe(fetchServeSimTurnArgsAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
it('requests TURN ICE servers from the device run session endpoint and returns web preview args', async () => {
|
||||
it('requests TURN ICE servers from the device run session endpoint and returns serve-sim args', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValue({
|
||||
json: async () => ({
|
||||
data: {
|
||||
@@ -618,7 +325,7 @@ describe(fetchWebPreviewTurnArgsAsync, () => {
|
||||
}),
|
||||
} as unknown as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
|
||||
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), {
|
||||
const args = await fetchServeSimTurnArgsAsync(createCtxMock(), {
|
||||
env: createEnvMock(),
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
@@ -642,11 +349,11 @@ describe(fetchWebPreviewTurnArgsAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('returns [] and warns when the request fails so the web preview falls back to P2P/STUN', async () => {
|
||||
it('returns [] and warns when the request fails so serve-sim falls back to P2P/STUN', async () => {
|
||||
jest.mocked(turtleFetch).mockRejectedValue(new Error('boom'));
|
||||
const logger = createLoggerMock();
|
||||
|
||||
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), {
|
||||
const args = await fetchServeSimTurnArgsAsync(createCtxMock(), {
|
||||
env: createEnvMock(),
|
||||
logger,
|
||||
});
|
||||
@@ -907,7 +614,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
describe(ensureFfmpegInstalledAsync, () => {
|
||||
const spawnMock = jest.mocked(spawn);
|
||||
|
||||
function spawnResolved(): ReturnType<typeof spawn> {
|
||||
@@ -926,7 +633,7 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
it('does not install when ffmpeg is on PATH', async () => {
|
||||
spawnMock.mockReturnValueOnce(spawnResolved());
|
||||
|
||||
await ensureFfmpegInstalledOnceAsync({
|
||||
await ensureFfmpegInstalledAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createEnvMock(),
|
||||
logger: createLoggerMock(),
|
||||
@@ -939,7 +646,7 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
it('installs ffmpeg with Homebrew on darwin when it is missing', async () => {
|
||||
spawnMock.mockReturnValueOnce(spawnRejected()).mockReturnValueOnce(spawnResolved());
|
||||
|
||||
await ensureFfmpegInstalledOnceAsync({
|
||||
await ensureFfmpegInstalledAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createEnvMock(),
|
||||
logger: createLoggerMock(),
|
||||
@@ -960,7 +667,7 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
.mockReturnValueOnce(spawnResolved()) // apt-get update
|
||||
.mockReturnValueOnce(spawnResolved()); // apt-get install
|
||||
|
||||
await ensureFfmpegInstalledOnceAsync({
|
||||
await ensureFfmpegInstalledAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: createEnvMock(),
|
||||
logger: createLoggerMock(),
|
||||
@@ -982,33 +689,6 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('shares an in-flight ffmpeg setup between callers', async () => {
|
||||
let finishInstall: (() => void) | undefined;
|
||||
const pendingInstall = new Promise<void>(resolve => {
|
||||
finishInstall = resolve;
|
||||
});
|
||||
spawnMock
|
||||
.mockReturnValueOnce(spawnRejected()) // ffmpeg -version
|
||||
.mockReturnValueOnce(spawnResolved()) // apt-get update
|
||||
.mockReturnValueOnce(pendingInstall as unknown as ReturnType<typeof spawn>); // apt-get install
|
||||
const options = {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: createEnvMock(),
|
||||
logger: createLoggerMock(),
|
||||
};
|
||||
|
||||
const firstSetup = ensureFfmpegInstalledOnceAsync(options);
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
expect(spawnMock).toHaveBeenCalledTimes(3);
|
||||
|
||||
const secondSetup = ensureFfmpegInstalledOnceAsync(options);
|
||||
expect(spawnMock).toHaveBeenCalledTimes(3);
|
||||
|
||||
finishInstall?.();
|
||||
await Promise.all([firstSetup, secondSetup]);
|
||||
expect(spawnMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('still installs on linux when the apt index refresh fails', async () => {
|
||||
spawnMock
|
||||
.mockReturnValueOnce(spawnRejected()) // ffmpeg -version
|
||||
@@ -1016,7 +696,7 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
.mockReturnValueOnce(spawnResolved()); // apt-get install
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await ensureFfmpegInstalledOnceAsync({
|
||||
await ensureFfmpegInstalledAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: createEnvMock(),
|
||||
logger,
|
||||
@@ -1035,7 +715,7 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await expect(
|
||||
ensureFfmpegInstalledOnceAsync({
|
||||
ensureFfmpegInstalledAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createEnvMock(),
|
||||
logger,
|
||||
@@ -1056,7 +736,7 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await expect(
|
||||
ensureFfmpegInstalledOnceAsync({
|
||||
ensureFfmpegInstalledAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createEnvMock(),
|
||||
logger,
|
||||
|
||||
@@ -101,14 +101,6 @@ describe(readServeSimServersAsync, () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes the session token when the state file carries one', async () => {
|
||||
await writeServerStateAsync('E', { device: 'E', url: 'http://127.0.0.1:5', token: 'tok-abc' });
|
||||
|
||||
expect(await readServeSimServersAsync(stateDir)).toEqual([
|
||||
{ udid: 'E', url: 'http://127.0.0.1:5', token: 'tok-abc' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns [] when the state dir does not exist', async () => {
|
||||
expect(await readServeSimServersAsync(path.join(workDir, 'nope'))).toEqual([]);
|
||||
});
|
||||
@@ -128,20 +120,6 @@ describe(streamServeSimMetricsToFileAsync, () => {
|
||||
expect(String(jest.mocked(fetch).mock.calls[0][0])).toBe('https://sim.example/metrics');
|
||||
});
|
||||
|
||||
it('sends the session token as a bearer header when given one', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(sseResponse());
|
||||
await streamServeSimMetricsToFileAsync({
|
||||
serveSimUrl: 'https://sim.example',
|
||||
serveSimToken: 'tok-abc',
|
||||
filePath: path.join(workDir, 'auth.ndjson'),
|
||||
signal: new AbortController().signal,
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
expect(jest.mocked(fetch).mock.calls[0][1]?.headers).toMatchObject({
|
||||
Authorization: 'Bearer tok-abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('writes nothing on a non-ok response', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(new Response('nope', { status: 502 }));
|
||||
const filePath = path.join(workDir, 'empty.ndjson');
|
||||
|
||||
@@ -1,421 +0,0 @@
|
||||
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',
|
||||
mobileStartAutomationSession: 'Started an automation session',
|
||||
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',
|
||||
mobileStopAutomationSession: 'Stopped an automation session',
|
||||
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',
|
||||
mobileViewportElementRect: 'Read an element position and size in the viewport',
|
||||
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;
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
// 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'
|
||||
| 'mobileStartAutomationSession'
|
||||
| 'mobileStartLogsBroadcast'
|
||||
| 'mobileStartNetworkMonitor'
|
||||
| 'mobileStartPerfRecord'
|
||||
| 'mobileStartScreenRecording'
|
||||
| 'mobileStartSystemMonitor'
|
||||
| 'mobileStartXctestScreenRecording'
|
||||
| 'mobileStopAutomationSession'
|
||||
| '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'
|
||||
| 'mobileViewportElementRect'
|
||||
| '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,7 +7,6 @@ import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { humanizeAppiumCommand } from './appiumCommandSummary';
|
||||
import {
|
||||
type DeviceRunSessionEventCollection,
|
||||
type DeviceRunSessionEventSource,
|
||||
@@ -147,7 +146,7 @@ function createAppiumEventSource(eventFile: string): DeviceRunSessionEventSource
|
||||
type: 'operation.completed',
|
||||
operationId,
|
||||
durationMs: Math.max(0, command.endTime - command.startTime),
|
||||
summary: humanizeAppiumCommand(command.cmd),
|
||||
summary: 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;
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
const ARGENT_ARTIFACT_KIND_BY_MIME_TYPE = new Map<string, string>([
|
||||
['image/png', 'screenshot'],
|
||||
['image/jpeg', 'screenshot'],
|
||||
@@ -32,9 +32,6 @@ 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(),
|
||||
@@ -46,12 +43,8 @@ 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. A semantic kind above remains valid because it
|
||||
// describes what the artifact represents, not its transport format.
|
||||
// contents rather than the file we actually store.
|
||||
if (artifact.isDirectory) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ 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';
|
||||
@@ -295,6 +294,24 @@ 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 ?? '';
|
||||
|
||||
@@ -1,724 +0,0 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
|
||||
import { type DetachedProcessHandle, spawnDetached } from './remoteDeviceRunSession';
|
||||
|
||||
/**
|
||||
* Local egress provides an HTTP(S) proxy through the machine running the EAS CLI.
|
||||
* Requests that use this proxy exit through that machine's network.
|
||||
*
|
||||
* Worker side: a chisel server in reverse mode listens on loopback and is exposed
|
||||
* through the session's ngrok domain. When the CLI's egress client connects, chisel
|
||||
* opens LOCAL_EGRESS_PROXY_PORT on this host and forwards every connection to the
|
||||
* HTTP proxy the CLI runs. The macOS system proxy points at that port. Until the
|
||||
* client connects, nothing listens on the port and proxied requests fail.
|
||||
*
|
||||
* Contract: HTTP(S) and WebSocket requests that honor the system proxy (WebKit,
|
||||
* URLSession and other CFNetwork clients) exit from the egress client's network
|
||||
* and fail while the client is disconnected. Requests from libraries that bypass
|
||||
* the system proxy are not covered and exit from this host. Nothing here enforces
|
||||
* routing; the session monitor reports such direct connections instead.
|
||||
*/
|
||||
|
||||
export const LOCAL_EGRESS_PROXY_HOST = '127.0.0.1';
|
||||
export const LOCAL_EGRESS_PROXY_PORT = 8899;
|
||||
export const LOCAL_EGRESS_USERNAME = 'eas';
|
||||
export const LOCAL_EGRESS_HANDOFF_PATH = path.join(os.tmpdir(), 'eas-simulator-local-egress.json');
|
||||
|
||||
export const CHISEL_VERSION = '1.12.0';
|
||||
// sha256 of the release .gz assets, cross-checked against chisel_1.12.0_checksums.txt.
|
||||
const CHISEL_SHA256_BY_ASSET: Record<string, string> = {
|
||||
'chisel_1.12.0_darwin_arm64.gz':
|
||||
'707a4b932eea214765146504a0df246cefc415b4297af65a80dd67cf69ba85a9',
|
||||
'chisel_1.12.0_darwin_amd64.gz':
|
||||
'4aeae36c867f11c8e8c3f2b913a0e063ea3c6d29e1c14a52ed2e6eef8cfc4395',
|
||||
'chisel_1.12.0_linux_amd64.gz':
|
||||
'f3f180f1d93aa72cce4e6386f98cc06569a0146fbd65eb4423cf83e6434bcfe6',
|
||||
'chisel_1.12.0_linux_arm64.gz':
|
||||
'2ec6152cd2c74fe0146d4d79e4e7aa174521368c56e433d55e023a92ea404ec3',
|
||||
};
|
||||
|
||||
const CHISEL_STARTUP_TIMEOUT_MS = 15_000;
|
||||
const EGRESS_MONITOR_INTERVAL_MS = 2_000;
|
||||
const EGRESS_ESCAPE_SCAN_INTERVAL_MS = 5_000;
|
||||
const EGRESS_ESCAPE_LOG_LIMIT = 50;
|
||||
|
||||
export type LocalEgressHandoff = {
|
||||
/** Public URL of the reverse tunnel server, reachable through ngrok. */
|
||||
url: string;
|
||||
/** Secret the client presents to the tunnel server, paired with LOCAL_EGRESS_USERNAME. */
|
||||
token: string;
|
||||
/** Fingerprint of the tunnel server key, for the client to pin. */
|
||||
fingerprint: string;
|
||||
/** Loopback port on this host that the client must serve. */
|
||||
port: number;
|
||||
};
|
||||
|
||||
export function getChiselAssetName({
|
||||
platform,
|
||||
arch,
|
||||
}: {
|
||||
platform: NodeJS.Platform;
|
||||
arch: string;
|
||||
}): string {
|
||||
const os = platform === 'darwin' ? 'darwin' : platform === 'linux' ? 'linux' : null;
|
||||
const cpu = arch === 'arm64' ? 'arm64' : arch === 'x64' ? 'amd64' : null;
|
||||
if (!os || !cpu) {
|
||||
throw new SystemError(
|
||||
`Local egress is not supported on ${platform}/${arch}. The reverse tunnel binary is available for macOS and Linux on arm64 and x64.`
|
||||
);
|
||||
}
|
||||
return `chisel_${CHISEL_VERSION}_${os}_${cpu}.gz`;
|
||||
}
|
||||
|
||||
export function getChiselDownloadUrl(assetName: string): string {
|
||||
return `https://github.com/jpillora/chisel/releases/download/v${CHISEL_VERSION}/${assetName}`;
|
||||
}
|
||||
|
||||
async function sha256FileAsync(filePath: string): Promise<string> {
|
||||
const hash = createHash('sha256');
|
||||
await pipeline(fs.createReadStream(filePath), hash);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
export async function downloadChiselAsync({
|
||||
destinationDir,
|
||||
logger,
|
||||
platform = process.platform,
|
||||
arch = process.arch,
|
||||
}: {
|
||||
destinationDir: string;
|
||||
logger: bunyan;
|
||||
platform?: NodeJS.Platform;
|
||||
arch?: string;
|
||||
}): Promise<string> {
|
||||
const assetName = getChiselAssetName({ platform, arch });
|
||||
const expectedSha256 = CHISEL_SHA256_BY_ASSET[assetName];
|
||||
if (!expectedSha256) {
|
||||
throw new SystemError(`No pinned checksum for ${assetName}.`);
|
||||
}
|
||||
const url = getChiselDownloadUrl(assetName);
|
||||
const archivePath = path.join(destinationDir, assetName);
|
||||
logger.info(`Downloading ${url}.`);
|
||||
await downloadFile(url, archivePath, { retry: 3, timeout: 60_000 });
|
||||
|
||||
const actualSha256 = await sha256FileAsync(archivePath);
|
||||
if (actualSha256 !== expectedSha256) {
|
||||
throw new SystemError(
|
||||
`Checksum mismatch for ${assetName}: expected ${expectedSha256}, got ${actualSha256}. ` +
|
||||
'The download may be corrupted or tampered with; the session was not started.'
|
||||
);
|
||||
}
|
||||
|
||||
const binaryPath = path.join(destinationDir, 'chisel');
|
||||
await pipeline(
|
||||
fs.createReadStream(archivePath),
|
||||
zlib.createGunzip(),
|
||||
fs.createWriteStream(binaryPath)
|
||||
);
|
||||
await fs.promises.chmod(binaryPath, 0o755);
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
export function generateEgressCredentials(): { user: string; password: string } {
|
||||
return { user: LOCAL_EGRESS_USERNAME, password: randomBytes(24).toString('base64url') };
|
||||
}
|
||||
|
||||
// Matches 1024-65535: every unprivileged TCP port.
|
||||
const UNPRIVILEGED_PORT_PATTERN =
|
||||
'(102[4-9]|10[3-9][0-9]|1[1-9][0-9]{2}|[2-9][0-9]{3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])';
|
||||
|
||||
/**
|
||||
* chisel authfile: one user, allowed to open reverse remotes on loopback only.
|
||||
* The proxy port carries proxied requests. Any unprivileged loopback port may be
|
||||
* forwarded too: the EAS CLI opens one per `--egress-allow localhost:<port>`
|
||||
* entry so `127.0.0.1:<port>` inside the simulator reaches that port on the
|
||||
* developer's machine, like `adb reverse`. iOS never sends loopback-literal
|
||||
* requests to the system proxy, so the proxy alone cannot serve them.
|
||||
* Reverse remotes are matched as `R:<interface>:<port>`.
|
||||
*/
|
||||
export function createChiselAuthfileContents({
|
||||
user,
|
||||
password,
|
||||
port,
|
||||
}: {
|
||||
user: string;
|
||||
password: string;
|
||||
port: number;
|
||||
}): string {
|
||||
const escapedHost = LOCAL_EGRESS_PROXY_HOST.replace(/\./g, '\\.');
|
||||
return JSON.stringify({
|
||||
[`${user}:${password}`]: [
|
||||
`^R:${escapedHost}:${port}$`,
|
||||
`^R:${escapedHost}:${UNPRIVILEGED_PORT_PATTERN}$`,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function parseChiselFingerprint(output: string): string | null {
|
||||
const match = /Fingerprint\s+(\S+)/.exec(output);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startChiselServerAsync({
|
||||
chiselPath,
|
||||
controlPort,
|
||||
authfilePath,
|
||||
env,
|
||||
signal,
|
||||
}: {
|
||||
chiselPath: string;
|
||||
controlPort: number;
|
||||
authfilePath: string;
|
||||
env: BuildStepEnv;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ process: DetachedProcessHandle; fingerprint: string }> {
|
||||
const server = spawnDetached({
|
||||
command: chiselPath,
|
||||
args: [
|
||||
'server',
|
||||
'--host',
|
||||
LOCAL_EGRESS_PROXY_HOST,
|
||||
'--port',
|
||||
String(controlPort),
|
||||
'--reverse',
|
||||
'--authfile',
|
||||
authfilePath,
|
||||
],
|
||||
// AUTH adds an unrestricted user even when --authfile is supplied.
|
||||
env: { ...env, AUTH: '' },
|
||||
});
|
||||
|
||||
try {
|
||||
const deadline = Date.now() + CHISEL_STARTUP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
signal?.throwIfAborted();
|
||||
if (server.pid === undefined || !isProcessRunning(server.pid)) {
|
||||
break;
|
||||
}
|
||||
const output = server.getOutput();
|
||||
const fingerprint = parseChiselFingerprint(output);
|
||||
// Chisel prints the fingerprint before binding. This line comes from the
|
||||
// child only after its listener succeeds, unlike a probe of a reused port.
|
||||
const listening = output
|
||||
.split('\n')
|
||||
.some(line =>
|
||||
line
|
||||
.trimEnd()
|
||||
.endsWith(`server: Listening on http://${LOCAL_EGRESS_PROXY_HOST}:${controlPort}`)
|
||||
);
|
||||
if (fingerprint && listening) {
|
||||
return { process: server, fingerprint };
|
||||
}
|
||||
await sleepAsync(250);
|
||||
}
|
||||
throw new SystemError(
|
||||
`The reverse tunnel server did not start within ${CHISEL_STARTUP_TIMEOUT_MS / 1000}s. Output:\n${
|
||||
server.getOutput() || '<empty>'
|
||||
}`
|
||||
);
|
||||
} catch (error) {
|
||||
await server.stopAsync();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDefaultRouteInterface(routeOutput: string): string | null {
|
||||
const match = /^\s*interface:\s*(\S+)/m.exec(routeOutput);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `networksetup -listnetworkserviceorder` prints each service as two lines:
|
||||
* "(1) Wi-Fi" followed by "(Hardware Port: Wi-Fi, Device: en0)". Return the
|
||||
* service name whose device matches.
|
||||
*/
|
||||
export function parseNetworkServiceNameForDevice(
|
||||
listOutput: string,
|
||||
device: string
|
||||
): string | null {
|
||||
const lines = listOutput.split('\n').map(line => line.trim());
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const hardwarePortMatch = /^\(Hardware Port: (.+), Device: (\S+)\)$/.exec(lines[i]);
|
||||
if (!hardwarePortMatch || hardwarePortMatch[2] !== device) {
|
||||
continue;
|
||||
}
|
||||
const serviceLineMatch = /^\(\d+\) (.+)$/.exec(lines[i - 1] ?? '');
|
||||
return serviceLineMatch?.[1] ?? hardwarePortMatch[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolveActiveNetworkServiceNameAsync({
|
||||
env,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
}): Promise<string> {
|
||||
const routeResult = await spawn('route', ['-n', 'get', 'default'], { env, stdio: 'pipe' });
|
||||
const device = parseDefaultRouteInterface(routeResult.stdout);
|
||||
if (!device) {
|
||||
throw new SystemError(
|
||||
'Could not determine the default network interface of the device host, so the system proxy ' +
|
||||
'cannot be configured. Output of `route -n get default`:\n' +
|
||||
(routeResult.stdout || '<empty>')
|
||||
);
|
||||
}
|
||||
const listResult = await spawn('networksetup', ['-listnetworkserviceorder'], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const service = parseNetworkServiceNameForDevice(listResult.stdout, device);
|
||||
if (!service) {
|
||||
throw new SystemError(
|
||||
`Could not find the network service for interface ${device}, so the system proxy cannot be ` +
|
||||
'configured. Output of `networksetup -listnetworkserviceorder`:\n' +
|
||||
(listResult.stdout || '<empty>')
|
||||
);
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
export function buildNetworksetupProxyArgs({
|
||||
service,
|
||||
host,
|
||||
port,
|
||||
}: {
|
||||
service: string;
|
||||
host: string;
|
||||
port: number;
|
||||
}): string[][] {
|
||||
return [
|
||||
['-setwebproxy', service, host, String(port)],
|
||||
['-setsecurewebproxy', service, host, String(port)],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the macOS system HTTP and HTTPS proxy at the loopback egress port. The
|
||||
* simulator reads these settings when it boots, so this must run before
|
||||
* `start_ios_simulator`. Existing bypass and automatic proxy settings are left
|
||||
* unchanged. Requests that bypass this proxy can leave from this host's address.
|
||||
*/
|
||||
export async function configureSystemProxyAsync({
|
||||
env,
|
||||
logger,
|
||||
port,
|
||||
signal,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
port: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ service: string }> {
|
||||
if (process.env.ENVIRONMENT === 'development') {
|
||||
logger.info('Job running outside of EAS, not changing the system proxy.');
|
||||
return { service: '<development>' };
|
||||
}
|
||||
const service = await resolveActiveNetworkServiceNameAsync({ env });
|
||||
for (const args of buildNetworksetupProxyArgs({ service, host: LOCAL_EGRESS_PROXY_HOST, port })) {
|
||||
signal?.throwIfAborted();
|
||||
await spawn('networksetup', args, { env, logger, signal });
|
||||
}
|
||||
logger.info(`System proxy for "${service}" set to ${LOCAL_EGRESS_PROXY_HOST}:${port}.`);
|
||||
return { service };
|
||||
}
|
||||
|
||||
export async function writeLocalEgressHandoffAsync(
|
||||
handoff: LocalEgressHandoff,
|
||||
handoffPath: string = LOCAL_EGRESS_HANDOFF_PATH
|
||||
): Promise<void> {
|
||||
await fs.promises.writeFile(handoffPath, JSON.stringify(handoff), {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
export async function readLocalEgressHandoffAsync(
|
||||
handoffPath: string = LOCAL_EGRESS_HANDOFF_PATH
|
||||
): Promise<LocalEgressHandoff | null> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.promises.readFile(handoffPath, 'utf8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<LocalEgressHandoff>;
|
||||
if (
|
||||
typeof parsed.url !== 'string' ||
|
||||
typeof parsed.token !== 'string' ||
|
||||
typeof parsed.fingerprint !== 'string' ||
|
||||
typeof parsed.port !== 'number'
|
||||
) {
|
||||
throw new SystemError(`Local egress handoff at ${handoffPath} is malformed.`);
|
||||
}
|
||||
return {
|
||||
url: parsed.url,
|
||||
token: parsed.token,
|
||||
fingerprint: parsed.fingerprint,
|
||||
port: parsed.port,
|
||||
};
|
||||
}
|
||||
|
||||
/** remoteConfig fields the CLI needs to start the egress client. */
|
||||
export function buildEgressRemoteConfigFields(
|
||||
handoff: LocalEgressHandoff | null
|
||||
): Record<string, string | number> {
|
||||
if (!handoff) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
egressUrl: handoff.url,
|
||||
egressToken: handoff.token,
|
||||
egressFingerprint: handoff.fingerprint,
|
||||
egressPort: handoff.port,
|
||||
};
|
||||
}
|
||||
|
||||
// The egress step returns before the session ends, so the resources it created
|
||||
// are held here and released by the session step or the job's finally block.
|
||||
let activeLocalEgressResources:
|
||||
| { stopAsync: () => Promise<void>; controller: AbortController; stopping?: Promise<void> }
|
||||
| undefined;
|
||||
|
||||
export function registerLocalEgressResources(stopAsync: () => Promise<void>): AbortSignal {
|
||||
if (activeLocalEgressResources) {
|
||||
throw new SystemError('Local egress resources are already registered for this job.');
|
||||
}
|
||||
const controller = new AbortController();
|
||||
activeLocalEgressResources = { stopAsync, controller };
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
export async function stopLocalEgressResourcesAsync(logger: bunyan): Promise<void> {
|
||||
const resources = activeLocalEgressResources;
|
||||
if (!resources) {
|
||||
return;
|
||||
}
|
||||
resources.controller.abort();
|
||||
resources.stopping ??= Promise.resolve()
|
||||
.then(resources.stopAsync)
|
||||
.catch(err => logger.warn({ err }, 'Could not stop a local egress resource.'))
|
||||
.finally(() => {
|
||||
activeLocalEgressResources = undefined;
|
||||
});
|
||||
await resources.stopping;
|
||||
}
|
||||
|
||||
export async function isPortListeningAsync({
|
||||
host,
|
||||
port,
|
||||
timeoutMs = 1_000,
|
||||
}: {
|
||||
host: string;
|
||||
port: number;
|
||||
timeoutMs?: number;
|
||||
}): Promise<boolean> {
|
||||
return await new Promise<boolean>(resolve => {
|
||||
const socket = net.connect({ host, port });
|
||||
const finish = (listening: boolean): void => {
|
||||
socket.destroy();
|
||||
resolve(listening);
|
||||
};
|
||||
socket.setTimeout(timeoutMs, () => finish(false));
|
||||
socket.once('connect', () => finish(true));
|
||||
socket.once('error', () => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
export function parseExitIpResponse(body: string): string {
|
||||
const parsed = JSON.parse(body) as { ip?: unknown };
|
||||
if (typeof parsed.ip !== 'string' || parsed.ip.length === 0) {
|
||||
throw new SystemError(`Unexpected exit IP response: ${body}`);
|
||||
}
|
||||
return parsed.ip;
|
||||
}
|
||||
|
||||
export async function fetchExitIpThroughProxyAsync({
|
||||
port,
|
||||
env,
|
||||
}: {
|
||||
port: number;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<string> {
|
||||
const result = await spawn(
|
||||
'curl',
|
||||
[
|
||||
'-sS',
|
||||
'--max-time',
|
||||
'10',
|
||||
'-x',
|
||||
`http://${LOCAL_EGRESS_PROXY_HOST}:${port}`,
|
||||
'https://api.ipify.org?format=json',
|
||||
],
|
||||
{ env, stdio: 'pipe' }
|
||||
);
|
||||
return parseExitIpResponse(result.stdout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes inside the simulator run on the host as descendants of launchd_sim,
|
||||
* so `ps -axo pid=,ppid=,comm=` ancestry identifies them. Returns every
|
||||
* descendant pid; launchd_sim itself opens no network connections.
|
||||
*/
|
||||
export function collectSimulatorProcessIds(psOutput: string): number[] {
|
||||
const childrenByParent = new Map<number, number[]>();
|
||||
const roots: number[] = [];
|
||||
for (const line of psOutput.split('\n')) {
|
||||
const match = /^\s*(\d+)\s+(\d+)\s+(\S.*)$/.exec(line);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const pid = Number(match[1]);
|
||||
const parentPid = Number(match[2]);
|
||||
const command = match[3].trim();
|
||||
const siblings = childrenByParent.get(parentPid) ?? [];
|
||||
siblings.push(pid);
|
||||
childrenByParent.set(parentPid, siblings);
|
||||
if (path.basename(command) === 'launchd_sim') {
|
||||
roots.push(pid);
|
||||
}
|
||||
}
|
||||
|
||||
const descendants: number[] = [];
|
||||
const seen = new Set<number>(roots);
|
||||
const queue = [...roots];
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
for (const child of childrenByParent.get(queue[i]) ?? []) {
|
||||
if (seen.has(child)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(child);
|
||||
descendants.push(child);
|
||||
queue.push(child);
|
||||
}
|
||||
}
|
||||
return descendants;
|
||||
}
|
||||
|
||||
export type DirectSimulatorConnection = {
|
||||
pid: number;
|
||||
command: string;
|
||||
protocol: string;
|
||||
/** Remote peer as lsof prints it, e.g. `93.184.216.34:443` or `[2606::1]:443`. */
|
||||
remote: string;
|
||||
};
|
||||
|
||||
function remoteHostOf(remote: string): string {
|
||||
if (remote.startsWith('[')) {
|
||||
const end = remote.indexOf(']');
|
||||
return end === -1 ? remote : remote.slice(1, end);
|
||||
}
|
||||
const colon = remote.lastIndexOf(':');
|
||||
return colon === -1 ? remote : remote.slice(0, colon);
|
||||
}
|
||||
|
||||
function isLoopbackHost(host: string): boolean {
|
||||
return (
|
||||
host === 'localhost' || host.startsWith('127.') || host === '::1' || host === '::ffff:127.0.0.1'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `lsof -nP -i -F pcPnT` output. Returns the TCP and UDP connections that
|
||||
* simulator processes hold to peers outside loopback. Those did not go through
|
||||
* the proxy on 127.0.0.1, so they exit from this host instead of the egress
|
||||
* client. Listening sockets, unconnected UDP sockets and connections to
|
||||
* loopback (the proxy itself) are ignored.
|
||||
*/
|
||||
export function parseDirectSimulatorConnections(
|
||||
lsofOutput: string,
|
||||
simulatorPids: ReadonlySet<number>
|
||||
): DirectSimulatorConnection[] {
|
||||
const connections: DirectSimulatorConnection[] = [];
|
||||
let pid: number | null = null;
|
||||
let command = '';
|
||||
let protocol: string | null = null;
|
||||
let name: string | null = null;
|
||||
let tcpState: string | null = null;
|
||||
|
||||
const flush = (): void => {
|
||||
if (pid !== null && simulatorPids.has(pid) && protocol && name) {
|
||||
const arrow = name.indexOf('->');
|
||||
const remote = arrow === -1 ? null : name.slice(arrow + 2);
|
||||
const active =
|
||||
protocol !== 'TCP' ||
|
||||
tcpState === null ||
|
||||
tcpState === 'ESTABLISHED' ||
|
||||
tcpState === 'SYN_SENT';
|
||||
if (remote && active && !isLoopbackHost(remoteHostOf(remote))) {
|
||||
connections.push({ pid, command, protocol, remote });
|
||||
}
|
||||
}
|
||||
protocol = null;
|
||||
name = null;
|
||||
tcpState = null;
|
||||
};
|
||||
|
||||
for (const line of lsofOutput.split('\n')) {
|
||||
const field = line[0];
|
||||
const value = line.slice(1);
|
||||
switch (field) {
|
||||
case 'p':
|
||||
flush();
|
||||
pid = Number(value);
|
||||
command = '';
|
||||
break;
|
||||
case 'c':
|
||||
command = value;
|
||||
break;
|
||||
case 'f':
|
||||
flush();
|
||||
break;
|
||||
case 'P':
|
||||
protocol = value;
|
||||
break;
|
||||
case 'n':
|
||||
name = value;
|
||||
break;
|
||||
case 'T':
|
||||
if (value.startsWith('ST=')) {
|
||||
tcpState = value.slice('ST='.length);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return connections;
|
||||
}
|
||||
|
||||
async function findDirectSimulatorConnectionsAsync({
|
||||
env,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
}): Promise<DirectSimulatorConnection[]> {
|
||||
const ps = await spawn('ps', ['-axo', 'pid=,ppid=,comm='], { env, stdio: 'pipe' });
|
||||
const pids = collectSimulatorProcessIds(ps.stdout);
|
||||
if (pids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
let lsofOutput: string;
|
||||
try {
|
||||
const lsof = await spawn('lsof', ['-nP', '-i', '-F', 'pcPnT', '-a', '-p', pids.join(',')], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
lsofOutput = lsof.stdout;
|
||||
} catch (err) {
|
||||
// lsof exits with 1 when none of the processes holds a matching socket, and
|
||||
// when one of them exited between `ps` and `lsof`. Its stdout is still valid.
|
||||
const result = err as { status?: number | null; stdout?: string };
|
||||
if (result.status !== 1) {
|
||||
throw err;
|
||||
}
|
||||
lsofOutput = result.stdout ?? '';
|
||||
}
|
||||
return parseDirectSimulatorConnections(lsofOutput, new Set(pids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log proxy listener availability, the exit IP observed by a worker request
|
||||
* through it, and simulator connections that bypassed the proxy. Neither check
|
||||
* verifies that proxied simulator requests reach the egress client. Never
|
||||
* rejects: it runs in the background for the whole session.
|
||||
*/
|
||||
export async function monitorLocalEgressAsync({
|
||||
port,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
port: number;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal: AbortSignal;
|
||||
}): Promise<void> {
|
||||
let connected = false;
|
||||
let lastEscapeScanAt = 0;
|
||||
let escapeScanBroken = false;
|
||||
const reportedEscapes = new Set<string>();
|
||||
const lifetimeSignal = activeLocalEgressResources?.controller.signal;
|
||||
try {
|
||||
while (!signal.aborted && !lifetimeSignal?.aborted) {
|
||||
const listening = await isPortListeningAsync({ host: LOCAL_EGRESS_PROXY_HOST, port });
|
||||
if (listening && !connected) {
|
||||
connected = true;
|
||||
logger.info('Local egress proxy listener is available.');
|
||||
try {
|
||||
const exitIp = await fetchExitIpThroughProxyAsync({ port, env });
|
||||
logger.info(
|
||||
`Worker proxy exit-IP check observed ${exitIp}. This does not verify simulator routing.`
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'The local egress proxy listener is available, but the worker exit-IP check through it failed.'
|
||||
);
|
||||
}
|
||||
} else if (!listening && connected) {
|
||||
connected = false;
|
||||
logger.warn(
|
||||
'Local egress proxy listener is unavailable. Proxied HTTP(S) requests fail until it returns.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!escapeScanBroken && Date.now() - lastEscapeScanAt >= EGRESS_ESCAPE_SCAN_INTERVAL_MS) {
|
||||
lastEscapeScanAt = Date.now();
|
||||
try {
|
||||
for (const connection of await findDirectSimulatorConnectionsAsync({ env })) {
|
||||
const key = `${connection.pid}|${connection.protocol}|${connection.remote}`;
|
||||
if (reportedEscapes.has(key)) {
|
||||
continue;
|
||||
}
|
||||
reportedEscapes.add(key);
|
||||
if (reportedEscapes.size > EGRESS_ESCAPE_LOG_LIMIT) {
|
||||
if (reportedEscapes.size === EGRESS_ESCAPE_LOG_LIMIT + 1) {
|
||||
logger.warn(
|
||||
`Local egress: more than ${EGRESS_ESCAPE_LOG_LIMIT} direct connections were reported; further ones are not logged.`
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
logger.warn(
|
||||
`Local egress: ${connection.command} (pid ${connection.pid}) opened a direct ${connection.protocol} connection to ${connection.remote}, bypassing the system proxy. That traffic exits from this worker, not from the egress client.`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
escapeScanBroken = true;
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Local egress: could not inspect simulator connections. Direct connections that bypass the proxy will not be reported.'
|
||||
);
|
||||
}
|
||||
}
|
||||
await sleepAsync(EGRESS_MONITOR_INTERVAL_MS);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Local egress monitoring stopped unexpectedly.');
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { type BuildFunction, type BuildStepEnv } from '@expo/steps';
|
||||
|
||||
import {
|
||||
buildEgressRemoteConfigFields,
|
||||
monitorLocalEgressAsync,
|
||||
readLocalEgressHandoffAsync,
|
||||
stopLocalEgressResourcesAsync,
|
||||
} from './localEgress';
|
||||
import { uploadRemoteSessionConfigAsync } from './remoteDeviceRunSession';
|
||||
|
||||
/** Release the pre-boot egress resources even if controller startup or teardown fails. */
|
||||
export function withLocalEgressSession(
|
||||
fn: NonNullable<BuildFunction['fn']>
|
||||
): NonNullable<BuildFunction['fn']> {
|
||||
return async (context, args) => {
|
||||
try {
|
||||
args.signal?.throwIfAborted();
|
||||
await fn(context, args);
|
||||
} finally {
|
||||
await stopLocalEgressResourcesAsync(context.logger);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** All simulator controllers publish the same egress handoff and run the same monitor. */
|
||||
export async function uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
...options
|
||||
}: Parameters<typeof uploadRemoteSessionConfigAsync>[0] & {
|
||||
env: BuildStepEnv;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
// Written by start_local_egress before simulator boot; absent for ordinary sessions.
|
||||
const localEgress = await readLocalEgressHandoffAsync();
|
||||
signal?.throwIfAborted();
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
...options,
|
||||
remoteConfig: { ...options.remoteConfig, ...buildEgressRemoteConfigFields(localEgress) },
|
||||
});
|
||||
if (localEgress && !signal?.aborted) {
|
||||
options.logger.info(
|
||||
'Local egress: waiting for the EAS CLI egress client to connect. Proxied HTTP(S) ' +
|
||||
'requests are unavailable until it does.'
|
||||
);
|
||||
// The monitor also observes the registered resources' lifetime signal, which
|
||||
// withLocalEgressSession aborts on success, failure, or cancellation.
|
||||
void monitorLocalEgressAsync({
|
||||
port: localEgress.port,
|
||||
env,
|
||||
logger: options.logger,
|
||||
signal: signal ?? new AbortController().signal,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -17,19 +17,14 @@ import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
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_PACKAGE_SPEC = '@expo/serve-sim@latest';
|
||||
const SERVE_SIM_HOST = '127.0.0.1';
|
||||
const SERVE_SIM_MAX_DIMENSION = '1280';
|
||||
const SERVE_SIM_MJPEG_QUALITY = '0.55';
|
||||
const SERVE_SIM_VIDEO_BITRATE = '6000000';
|
||||
const SERVE_SIM_VIDEO_BITRATE = '3000000';
|
||||
const SERVE_SIM_VIDEO_FPS = '60';
|
||||
const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub';
|
||||
const EXPO_DEVICE_HUB_MAX_DIMENSION = '960';
|
||||
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!) {
|
||||
@@ -298,9 +293,10 @@ async function sleepUntilAbortedAsync(
|
||||
}
|
||||
}
|
||||
|
||||
// Device-session tools resolve `ffmpeg` from PATH. Spawning it with the step's
|
||||
// environment rejects with ENOENT when the binary is absent, and running it also
|
||||
// proves that the installed binary works.
|
||||
// Argent encodes screen recordings by piping simulator frames into `ffmpeg`,
|
||||
// which it resolves from PATH. The tool-server inherits this step's env, so
|
||||
// spawning ffmpeg resolves against the same PATH argent will search: it rejects
|
||||
// with ENOENT when the binary is absent, and running it also proves it works.
|
||||
async function isFfmpegAvailableAsync(env: BuildStepEnv): Promise<boolean> {
|
||||
return (await asyncResult(spawn('ffmpeg', ['-version'], { env }))).ok;
|
||||
}
|
||||
@@ -333,15 +329,14 @@ async function installFfmpegWithAptAsync({
|
||||
await spawn('sudo', ['apt-get', 'install', '-y', 'ffmpeg'], { env: aptEnv, logger });
|
||||
}
|
||||
|
||||
let ffmpegSetupPromise: Promise<void> | undefined;
|
||||
|
||||
/**
|
||||
* Install ffmpeg when the runtime does not already provide it. Device-session
|
||||
* tools use it for video encoding on macOS (iOS simulators) and Linux (Android
|
||||
* emulators) alike, but the worker images do not ship it yet.
|
||||
* Install ffmpeg when the runtime does not already provide it, so argent's
|
||||
* `screen-recording-start` tool can encode a video. The worker images do not
|
||||
* ship ffmpeg yet, so without this the tool fails with "`ffmpeg` was not found
|
||||
* on PATH" — on macOS (iOS simulators) and Linux (Android emulators) alike.
|
||||
*
|
||||
* Best-effort by design: a failure here is logged and the session continues
|
||||
* without FFmpeg-dependent features.
|
||||
* Best-effort by design: screen recording is one optional argent tool, so a
|
||||
* failure here is logged and the session continues without it.
|
||||
*
|
||||
* The whole body is wrapped because the caller runs this in the background with
|
||||
* `void`. There is no unhandledRejection handler in the worker, so a rejection
|
||||
@@ -349,7 +344,7 @@ let ffmpegSetupPromise: Promise<void> | undefined;
|
||||
* `spawn` is not an async function and can throw synchronously, which
|
||||
* `asyncResult` cannot catch — it only wraps an already-created promise.
|
||||
*/
|
||||
async function ensureFfmpegInstalledAsync({
|
||||
export async function ensureFfmpegInstalledAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
@@ -368,7 +363,7 @@ async function ensureFfmpegInstalledAsync({
|
||||
logger.info(
|
||||
`ffmpeg is not installed, installing it with ${
|
||||
isDarwin ? 'Homebrew' : 'apt'
|
||||
} for the device session.`
|
||||
} for argent screen recording.`
|
||||
);
|
||||
if (isDarwin) {
|
||||
await installFfmpegWithHomebrewAsync({ env, logger });
|
||||
@@ -378,41 +373,16 @@ async function ensureFfmpegInstalledAsync({
|
||||
logger.info('Installed ffmpeg.');
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not install ffmpeg for the device session', error, {
|
||||
Sentry.capture('Could not install ffmpeg for argent screen recording', error, {
|
||||
level: 'warning',
|
||||
});
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Could not install ffmpeg. FFmpeg-dependent features may not work in this session.'
|
||||
'Could not install ffmpeg. Argent screen recording will not work in this session.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
if (ffmpegSetupPromise) {
|
||||
await ffmpegSetupPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
const setupPromise = ensureFfmpegInstalledAsync({ runtimePlatform, env, logger });
|
||||
ffmpegSetupPromise = setupPromise;
|
||||
try {
|
||||
await setupPromise;
|
||||
} finally {
|
||||
if (ffmpegSetupPromise === setupPromise) {
|
||||
ffmpegSetupPromise = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const TurnIceServersResponseSchema = z.object({
|
||||
data: z.object({
|
||||
iceServers: TurnIceServersSchema,
|
||||
@@ -420,12 +390,11 @@ const TurnIceServersResponseSchema = z.object({
|
||||
});
|
||||
|
||||
/**
|
||||
* Translate Cloudflare ICE servers into web preview CLI flags: `--stun-url` (the
|
||||
* Translate Cloudflare ICE servers into serve-sim CLI flags: `--stun-url` (the
|
||||
* credential-less entries) and `--turn-url`/`--turn-username`/`--turn-credential`
|
||||
* (the entry carrying the short-lived credentials). serve-sim and expo-device-hub
|
||||
* intentionally expose the same ICE flag contract.
|
||||
* (the entry carrying the short-lived credentials).
|
||||
*/
|
||||
export function turnIceServersToWebPreviewArgs(iceServers: TurnIceServers): string[] {
|
||||
export function turnIceServersToServeSimArgs(iceServers: TurnIceServers): string[] {
|
||||
const stunUrls = iceServers
|
||||
.filter(server => !server.username && !server.credential)
|
||||
.flatMap(server => server.urls);
|
||||
@@ -451,14 +420,14 @@ export function turnIceServersToWebPreviewArgs(iceServers: TurnIceServers): stri
|
||||
/**
|
||||
* 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 web preview CLI flags.
|
||||
* translate them into serve-sim CLI flags.
|
||||
*
|
||||
* 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).
|
||||
* 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).
|
||||
*/
|
||||
export async function fetchWebPreviewTurnArgsAsync(
|
||||
export async function fetchServeSimTurnArgsAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
|
||||
): Promise<string[]> {
|
||||
@@ -487,9 +456,9 @@ export async function fetchWebPreviewTurnArgsAsync(
|
||||
);
|
||||
|
||||
const { data } = TurnIceServersResponseSchema.parse(await response.json());
|
||||
const args = turnIceServersToWebPreviewArgs(data.iceServers);
|
||||
const args = turnIceServersToServeSimArgs(data.iceServers);
|
||||
if (args.length > 0) {
|
||||
logger.info('Configured the web preview with Cloudflare TURN ICE servers.');
|
||||
logger.info('Configured serve-sim with Cloudflare TURN ICE servers.');
|
||||
}
|
||||
return args;
|
||||
} catch (err) {
|
||||
@@ -497,7 +466,7 @@ export async function fetchWebPreviewTurnArgsAsync(
|
||||
Sentry.capture('Could not fetch Cloudflare TURN ICE servers', error, { level: 'warning' });
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Could not fetch Cloudflare TURN ICE servers; the web preview will fall back to P2P/STUN.'
|
||||
'Could not fetch Cloudflare TURN ICE servers; serve-sim will fall back to P2P/STUN.'
|
||||
);
|
||||
return [];
|
||||
}
|
||||
@@ -627,33 +596,22 @@ 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',
|
||||
createServeSimPackageSpec(packageVersion),
|
||||
SERVE_SIM_PACKAGE_SPEC,
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
WEB_PREVIEW_HOST,
|
||||
'--require-token',
|
||||
SERVE_SIM_HOST,
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
@@ -671,84 +629,44 @@ 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,
|
||||
];
|
||||
}
|
||||
|
||||
export async function findAvailablePortAsync(): Promise<number> {
|
||||
async function findAvailablePortAsync(): Promise<number> {
|
||||
const server = createServer();
|
||||
server.unref();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, WEB_PREVIEW_HOST, () => resolve());
|
||||
server.listen(0, SERVE_SIM_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 the web preview.');
|
||||
throw new SystemError('Could not allocate a local port for serve-sim.');
|
||||
}
|
||||
return address.port;
|
||||
}
|
||||
|
||||
const WebPreviewReadyResponseSchema = z.object({
|
||||
const ServeSimReadyResponseSchema = z.object({
|
||||
status: z.literal('ready'),
|
||||
device: z.string(),
|
||||
});
|
||||
|
||||
export async function waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
export async function waitForServeSimReadyAsync({
|
||||
serveSim,
|
||||
port,
|
||||
timeoutMs,
|
||||
}: {
|
||||
previewServer: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
|
||||
serverName: string;
|
||||
serveSim: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
|
||||
port: number;
|
||||
timeoutMs: number;
|
||||
}): Promise<string> {
|
||||
const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`;
|
||||
}): Promise<void> {
|
||||
const readyUrl = `http://${SERVE_SIM_HOST}:${port}/readyz`;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
if (previewServer.pid !== undefined && !isProcessRunning(previewServer.pid)) {
|
||||
if (serveSim.pid !== undefined && !isProcessRunning(serveSim.pid)) {
|
||||
throw new SystemError(
|
||||
`${serverName} exited before becoming ready. Last output:\n${
|
||||
previewServer.getOutput() || '<empty>'
|
||||
}`
|
||||
`serve-sim exited before becoming ready. Last output:\n${serveSim.getOutput() || '<empty>'}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
@@ -756,69 +674,52 @@ export async function waitForWebPreviewReadyAsync({
|
||||
retries: 0,
|
||||
timeout: 2_000,
|
||||
});
|
||||
const ready = WebPreviewReadyResponseSchema.parse(await response.json());
|
||||
return ready.device;
|
||||
ServeSimReadyResponseSchema.parse(await response.json());
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
throw new SystemError(
|
||||
`Timed out waiting for ${serverName} readiness at ${readyUrl}${
|
||||
`Timed out waiting for serve-sim readiness at ${readyUrl}${
|
||||
lastError instanceof Error ? `: ${lastError.message}` : ''
|
||||
}. Last output:\n${previewServer.getOutput() || '<empty>'}`
|
||||
}. Last output:\n${serveSim.getOutput() || '<empty>'}`
|
||||
);
|
||||
}
|
||||
|
||||
export type DeviceWebPreviewHandle = {
|
||||
export type ServeSimPreviewHandle = {
|
||||
previewUrl: string;
|
||||
/** Session token gating the preview. Only serve-sim mints one. */
|
||||
previewToken?: string;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type ServeSimPreviewHandle = DeviceWebPreviewHandle;
|
||||
|
||||
async function startWebPreviewWithTunnelAsync(
|
||||
export async function startServeSimWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
serverName,
|
||||
packageSpec,
|
||||
createArgs,
|
||||
readPreviewTokenAsync,
|
||||
}: {
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
serverName: string;
|
||||
packageSpec: string;
|
||||
createArgs: (port: number, turnArgs: string[]) => string[];
|
||||
readPreviewTokenAsync?: (device: string) => Promise<string>;
|
||||
}
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
): Promise<ServeSimPreviewHandle> {
|
||||
const port = await findAvailablePortAsync();
|
||||
logger.info(`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port}.`);
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
|
||||
const previewServer = spawnDetached({
|
||||
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({
|
||||
command: 'npx',
|
||||
args: createArgs(port, turnArgs),
|
||||
args: createServeSimArgs({ port, turnArgs, metricsCorsArgs }),
|
||||
env,
|
||||
});
|
||||
|
||||
try {
|
||||
logger.info(`Waiting for ${serverName} to become ready.`);
|
||||
const device = await waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
});
|
||||
const previewToken = await readPreviewTokenAsync?.(device);
|
||||
logger.info('Waiting for serve-sim to become ready.');
|
||||
await waitForServeSimReadyAsync({ serveSim, port, timeoutMs });
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix: 'web-preview',
|
||||
@@ -828,127 +729,21 @@ async function startWebPreviewWithTunnelAsync(
|
||||
});
|
||||
return {
|
||||
previewUrl: tunnel.url,
|
||||
previewToken,
|
||||
stopAsync: async () => {
|
||||
const results = await Promise.allSettled([tunnel.stopAsync(), previewServer.stopAsync()]);
|
||||
const results = await Promise.allSettled([tunnel.stopAsync(), serveSim.stopAsync()]);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
logger.warn({ err: result.reason }, `Could not stop a ${serverName} preview resource.`);
|
||||
logger.warn({ err: result.reason }, 'Could not stop a serve-sim preview resource.');
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await previewServer.stopAsync();
|
||||
await serveSim.stopAsync();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readServeSimPreviewTokenAsync(
|
||||
udid: string,
|
||||
stateDir: string = SERVE_SIM_STATE_DIR
|
||||
): Promise<string | undefined> {
|
||||
const servers = await readServeSimServersAsync(stateDir);
|
||||
return servers.find(server => server.udid === udid)?.token;
|
||||
}
|
||||
|
||||
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 }),
|
||||
readPreviewTokenAsync: async device => {
|
||||
const previewToken = await readServeSimPreviewTokenAsync(device);
|
||||
if (!previewToken) {
|
||||
// A serve-sim that does not know --require-token fails earlier, in the readiness check, so
|
||||
// reaching here means it started and left no token in its state file.
|
||||
throw new SystemError(
|
||||
`serve-sim became ready but wrote no session token for device ${device}. The preview is ` +
|
||||
'on a public tunnel and would be reachable without one, so the session cannot continue. ' +
|
||||
'This usually means the state file was not written as expected; retry the session, and ' +
|
||||
'report it if it repeats.'
|
||||
);
|
||||
}
|
||||
return previewToken;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function startExpoDeviceHubWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
runtimePlatform,
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
packageVersion,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
}
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
if (runtimePlatform === BuildRuntimePlatform.LINUX) {
|
||||
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
}
|
||||
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, runtimePlatform });
|
||||
}
|
||||
}
|
||||
|
||||
export type NgrokTunnelHandle = {
|
||||
url: string;
|
||||
stopAsync: () => Promise<void>;
|
||||
|
||||
@@ -13,9 +13,9 @@ const POLL_INTERVAL_MS = 2_000;
|
||||
const MAX_CONSECUTIVE_STREAM_FAILURES_PER_DEVICE = 10;
|
||||
const END_STREAM_TIMEOUT_MS = 2_000;
|
||||
// serve-sim's own per-device registry while serving: `${tmpdir}/serve-sim/server-<udid>.json`.
|
||||
export const SERVE_SIM_STATE_DIR = path.join(os.tmpdir(), 'serve-sim');
|
||||
const SERVE_SIM_STATE_DIR = path.join(os.tmpdir(), 'serve-sim');
|
||||
|
||||
type ServeSimServer = { udid: string; url: string; token?: string };
|
||||
type ServeSimServer = { udid: string; url: string };
|
||||
|
||||
type ServeSimMetricsSession = {
|
||||
logger: bunyan;
|
||||
@@ -130,7 +130,6 @@ async function pollServeSimMetricsAsync(session: ServeSimMetricsSession): Promis
|
||||
logger.info(`Collecting serve-sim metrics for ${server.udid}.`);
|
||||
const donePromise = streamServeSimMetricsToFileAsync({
|
||||
serveSimUrl: server.url,
|
||||
serveSimToken: server.token,
|
||||
filePath,
|
||||
signal: streamSignal,
|
||||
logger,
|
||||
@@ -173,14 +172,9 @@ export async function readServeSimServersAsync(stateDir: string): Promise<ServeS
|
||||
const state = JSON.parse(await readFile(path.join(stateDir, entry), 'utf-8')) as {
|
||||
device?: unknown;
|
||||
url?: unknown;
|
||||
token?: unknown;
|
||||
};
|
||||
if (typeof state.device === 'string' && typeof state.url === 'string') {
|
||||
servers.push({
|
||||
udid: state.device,
|
||||
url: state.url,
|
||||
...(typeof state.token === 'string' ? { token: state.token } : {}),
|
||||
});
|
||||
servers.push({ udid: state.device, url: state.url });
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
@@ -191,13 +185,11 @@ export async function readServeSimServersAsync(stateDir: string): Promise<ServeS
|
||||
|
||||
export async function streamServeSimMetricsToFileAsync({
|
||||
serveSimUrl,
|
||||
serveSimToken,
|
||||
filePath,
|
||||
signal,
|
||||
logger,
|
||||
}: {
|
||||
serveSimUrl: string;
|
||||
serveSimToken?: string;
|
||||
filePath: string;
|
||||
signal: AbortSignal;
|
||||
logger: bunyan;
|
||||
@@ -209,10 +201,7 @@ export async function streamServeSimMetricsToFileAsync({
|
||||
let receivedData = false;
|
||||
let metadata: Record<string, unknown> | undefined;
|
||||
try {
|
||||
const response = await fetch(new URL('/metrics', serveSimUrl).toString(), {
|
||||
signal,
|
||||
...(serveSimToken ? { headers: { Authorization: `Bearer ${serveSimToken}` } } : {}),
|
||||
});
|
||||
const response = await fetch(new URL('/metrics', serveSimUrl).toString(), { signal });
|
||||
if (!response.ok || !response.body) {
|
||||
logger.warn(`serve-sim /metrics responded ${response.status} for ${serveSimUrl}.`);
|
||||
return { receivedData, metadata };
|
||||
|
||||
@@ -99,18 +99,12 @@ export namespace AndroidEmulatorUtils {
|
||||
deviceName,
|
||||
systemImagePackage,
|
||||
deviceIdentifier,
|
||||
lcdWidth,
|
||||
lcdHeight,
|
||||
lcdDensity,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
deviceName: AndroidVirtualDeviceName;
|
||||
systemImagePackage: string;
|
||||
deviceIdentifier: AndroidDeviceName | null;
|
||||
lcdWidth: number | null;
|
||||
lcdHeight: number | null;
|
||||
lcdDensity: number | null;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
@@ -182,13 +176,6 @@ export namespace AndroidEmulatorUtils {
|
||||
}
|
||||
}
|
||||
|
||||
if (lcdWidth !== null && lcdHeight !== null && lcdDensity !== null) {
|
||||
logger.info(
|
||||
`Setting screen resolution to ${lcdWidth}x${lcdHeight} and density to ${lcdDensity} ppi.`
|
||||
);
|
||||
configIniFileContent = `${configIniFileContent}\nhw.lcd.height=${lcdHeight}\nhw.lcd.width=${lcdWidth}\nhw.lcd.density=${lcdDensity}\n`;
|
||||
}
|
||||
|
||||
const shouldAdjustHeapSize =
|
||||
env.ANDROID_EMULATOR_ADJUST_HEAP_SIZE !== 'false' &&
|
||||
env.ANDROID_EMULATOR_ADJUST_HEAP_SIZE !== '0';
|
||||
|
||||
@@ -44,32 +44,6 @@ 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,
|
||||
|
||||
@@ -71,9 +71,6 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
@@ -98,9 +95,6 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
@@ -164,9 +158,6 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
@@ -218,9 +209,6 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: envForAttempt,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
@@ -302,9 +290,6 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger,
|
||||
});
|
||||
@@ -376,9 +361,6 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
|
||||
@@ -8,11 +8,7 @@ import path from 'node:path';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
AndroidDeviceName,
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../AndroidEmulatorUtils';
|
||||
import { AndroidEmulatorUtils, AndroidVirtualDeviceName } from '../AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
@@ -52,120 +48,6 @@ describe('AndroidEmulatorUtils', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.createAsync, () => {
|
||||
it('applies an explicit LCD configuration after screen scaling', async () => {
|
||||
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
|
||||
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
|
||||
await fs.promises.mkdir(avdDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
`${avdDirectory}/config.ini`,
|
||||
[
|
||||
'hw.ramSize=1536',
|
||||
'hw.lcd.height=2424',
|
||||
'hw.lcd.width=1080',
|
||||
'hw.lcd.density=420',
|
||||
'skin.path=pixel_9',
|
||||
'showDeviceFrame=yes',
|
||||
].join('\n')
|
||||
);
|
||||
const avdManagerPromise = Promise.resolve({ stdout: '', stderr: '' }) as any;
|
||||
const write = jest.fn();
|
||||
const end = jest.fn();
|
||||
avdManagerPromise.child = { stdin: { write, end } };
|
||||
mockedSpawn.mockReturnValue(avdManagerPromise);
|
||||
const logger = createMockLogger();
|
||||
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
deviceName,
|
||||
systemImagePackage: 'system-images;android-35;default;x86_64',
|
||||
deviceIdentifier: 'medium_phone' as AndroidDeviceName,
|
||||
lcdWidth: 720,
|
||||
lcdHeight: 1600,
|
||||
lcdDensity: 262,
|
||||
env: {
|
||||
HOME: '/home/expo',
|
||||
ANDROID_EMULATOR_ADJUST_SCREEN: '1',
|
||||
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'avdmanager',
|
||||
[
|
||||
'create',
|
||||
'avd',
|
||||
'--name',
|
||||
deviceName,
|
||||
'--package',
|
||||
'system-images;android-35;default;x86_64',
|
||||
'--force',
|
||||
'--device',
|
||||
'medium_phone',
|
||||
],
|
||||
{ env: expect.any(Object), stdio: 'pipe' }
|
||||
);
|
||||
expect(write).toHaveBeenCalledWith('no');
|
||||
expect(end).toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Setting scaled screen resolution: hw.lcd.height to 1270 and hw.lcd.width to 566.'
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Setting screen resolution to 720x1600 and density to 262 ppi.'
|
||||
);
|
||||
await expect(fs.promises.readFile(`${avdDirectory}/config.ini`, 'utf8')).resolves.toBe(
|
||||
[
|
||||
'hw.ramSize=1536',
|
||||
'hw.lcd.height=2424',
|
||||
'hw.lcd.width=1080',
|
||||
'hw.lcd.density=420',
|
||||
'skin.path=pixel_9',
|
||||
'showDeviceFrame=yes',
|
||||
'hw.ramSize=2048',
|
||||
'',
|
||||
'hw.lcd.density=220',
|
||||
'',
|
||||
'hw.lcd.height=1270',
|
||||
'hw.lcd.width=566',
|
||||
'',
|
||||
'hw.lcd.height=1600',
|
||||
'hw.lcd.width=720',
|
||||
'hw.lcd.density=262',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
it('does not apply an LCD configuration when it is omitted', async () => {
|
||||
const deviceName = 'generic-emulator' as AndroidVirtualDeviceName;
|
||||
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
|
||||
const initialConfig = ['skin.path=pixel_9', 'showDeviceFrame=yes'].join('\n');
|
||||
await fs.promises.mkdir(avdDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(`${avdDirectory}/config.ini`, initialConfig);
|
||||
const avdManagerPromise = Promise.resolve({ stdout: '', stderr: '' }) as any;
|
||||
avdManagerPromise.child = { stdin: { write: jest.fn(), end: jest.fn() } };
|
||||
mockedSpawn.mockReturnValue(avdManagerPromise);
|
||||
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: {
|
||||
HOME: '/home/expo',
|
||||
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
|
||||
},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
const config = await fs.promises.readFile(`${avdDirectory}/config.ini`, 'utf8');
|
||||
expect(config).toContain(initialConfig);
|
||||
expect(config).not.toContain('hw.lcd.');
|
||||
});
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.startAsync, () => {
|
||||
function mockSuccessfulStart(deviceName: AndroidVirtualDeviceName) {
|
||||
// Under `stdio: 'pipe'` these are net.Sockets, which is what startAsync
|
||||
|
||||
@@ -23,41 +23,6 @@ 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({
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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-');
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
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: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
extraEnv: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,7 +91,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${STAGING_VERSION}`],
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
extraEnv: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,7 +101,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${STAGING_VERSION}`],
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0', EXPO_STAGING: '1' },
|
||||
extraEnv: { 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: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
extraEnv: {},
|
||||
});
|
||||
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: { NPM_CONFIG_MIN_RELEASE_AGE: '0', EXPO_STAGING: '1' },
|
||||
extraEnv: { 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: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
extraEnv: {},
|
||||
});
|
||||
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
@@ -173,17 +173,9 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: [`eas-cli@${PRODUCTION_VERSION}`],
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
extraEnv: {},
|
||||
});
|
||||
});
|
||||
|
||||
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, () => {
|
||||
@@ -226,25 +218,4 @@ 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import { BuildPhase, BuildPhaseResult, LogMarker } from '@expo/eas-build-job';
|
||||
|
||||
import { withLogPhaseAsync } from '../logPhase';
|
||||
|
||||
describe(withLogPhaseAsync.name, () => {
|
||||
const phaseLogger = { info: jest.fn() };
|
||||
const logger = { child: jest.fn(() => phaseLogger) };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns the callback result and ends the phase successfully', async () => {
|
||||
const result = await withLogPhaseAsync(logger as any, 'Phase name', async callbackLogger => {
|
||||
expect(callbackLogger).toBe(phaseLogger);
|
||||
return 'result';
|
||||
});
|
||||
|
||||
expect(result).toBe('result');
|
||||
expect(logger.child).toHaveBeenCalledWith({
|
||||
phase: BuildPhase.CUSTOM,
|
||||
buildStepId: expect.stringMatching(/^step-\d{3,}$/),
|
||||
buildStepDisplayName: 'Phase name',
|
||||
});
|
||||
expect(phaseLogger.info).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ marker: LogMarker.START_PHASE },
|
||||
'Start phase: Phase name'
|
||||
);
|
||||
expect(phaseLogger.info).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ marker: LogMarker.END_PHASE, result: BuildPhaseResult.SUCCESS },
|
||||
'End phase: Phase name'
|
||||
);
|
||||
});
|
||||
|
||||
it('ends the phase with failure and rethrows the error', async () => {
|
||||
const error = new Error('failed');
|
||||
|
||||
await expect(
|
||||
withLogPhaseAsync(logger as any, 'Phase name', async () => {
|
||||
throw error;
|
||||
})
|
||||
).rejects.toBe(error);
|
||||
|
||||
expect(phaseLogger.info).toHaveBeenLastCalledWith(
|
||||
{ marker: LogMarker.END_PHASE, result: BuildPhaseResult.FAIL },
|
||||
'End phase: Phase name'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,503 +0,0 @@
|
||||
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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,466 +0,0 @@
|
||||
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,24 +7,17 @@ import path from 'path';
|
||||
|
||||
import { findPackagerRootDir } from './packageManager';
|
||||
|
||||
const IOS_DEVICE_CACHE_KEY_PREFIX = 'ios-device-ccache-';
|
||||
const IOS_SIMULATOR_CACHE_KEY_PREFIX = 'ios-simulator-ccache-';
|
||||
const IOS_CACHE_KEY_PREFIX = 'ios-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 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 CACHE_KEY_PREFIX_BY_PLATFORM: Record<Platform, string> = {
|
||||
[Platform.ANDROID]: ANDROID_CACHE_KEY_PREFIX,
|
||||
[Platform.IOS]: IOS_CACHE_KEY_PREFIX,
|
||||
};
|
||||
|
||||
export const PUBLIC_CACHE_KEY_PREFIX_BY_PLATFORM: Record<Platform, string> = {
|
||||
[Platform.ANDROID]: PUBLIC_ANDROID_CACHE_KEY_PREFIX,
|
||||
@@ -43,7 +36,7 @@ export function getCcachePath(env: Record<string, string | undefined>): string {
|
||||
|
||||
export async function generateDefaultBuildCacheKeyAsync(
|
||||
workingDirectory: string,
|
||||
target: CcacheBuildTarget
|
||||
platform: Platform
|
||||
): 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
|
||||
@@ -52,7 +45,7 @@ export async function generateDefaultBuildCacheKeyAsync(
|
||||
const lockPath = path.join(packagerRunDir, manager.lockFile);
|
||||
|
||||
try {
|
||||
return `${getCcacheKeyPrefix(target)}${hashFiles([lockPath])}`;
|
||||
return `${CACHE_KEY_PREFIX_BY_PLATFORM[platform]}${hashFiles([lockPath])}`;
|
||||
} catch (err: any) {
|
||||
throw new Error(`Failed to read lockfile for cache key generation: ${err.message}`);
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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,8 +12,6 @@ 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
|
||||
@@ -76,21 +74,21 @@ export async function resolveEasCommandPrefixAndEnvAsync(): Promise<{
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${versions.STAGING}`],
|
||||
extraEnv: NPX_ENV,
|
||||
extraEnv: {},
|
||||
};
|
||||
} else if (process.env.ENVIRONMENT === 'staging') {
|
||||
const versions = await resolveEasCliVersionsAsync();
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${versions.STAGING}`],
|
||||
extraEnv: { ...NPX_ENV, EXPO_STAGING: '1' },
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
};
|
||||
} else {
|
||||
const versions = await resolveEasCliVersionsAsync();
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${versions.PRODUCTION}`],
|
||||
extraEnv: NPX_ENV,
|
||||
extraEnv: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
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,30 +0,0 @@
|
||||
import { BuildPhase, BuildPhaseResult, LogMarker } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildStep } from '@expo/steps';
|
||||
|
||||
export async function withLogPhaseAsync<T>(
|
||||
logger: bunyan,
|
||||
name: string,
|
||||
fn: (logger: bunyan) => Promise<T>
|
||||
): Promise<T> {
|
||||
const phaseLogger = logger.child({
|
||||
phase: BuildPhase.CUSTOM,
|
||||
buildStepId: BuildStep.getNewId(),
|
||||
buildStepDisplayName: name,
|
||||
});
|
||||
phaseLogger.info({ marker: LogMarker.START_PHASE }, `Start phase: ${name}`);
|
||||
try {
|
||||
const result = await fn(phaseLogger);
|
||||
phaseLogger.info(
|
||||
{ marker: LogMarker.END_PHASE, result: BuildPhaseResult.SUCCESS },
|
||||
`End phase: ${name}`
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
phaseLogger.info(
|
||||
{ marker: LogMarker.END_PHASE, result: BuildPhaseResult.FAIL },
|
||||
`End phase: ${name}`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,5 @@
|
||||
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(',')], {
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
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": "24.0.0",
|
||||
"version": "22.2.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "24.0.0"
|
||||
"@expo/steps": "22.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "24.0.0",
|
||||
"version": "22.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": "24.0.0",
|
||||
"version": "22.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": "24.0.0",
|
||||
"version": "22.0.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": "24.0.0",
|
||||
"@expo/logger": "22.0.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "24.0.0",
|
||||
"@expo/turtle-spawn": "22.0.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
+317
-721
File diff suppressed because it is too large.
Load diff
@@ -13,7 +13,6 @@ const config: CodegenConfig = {
|
||||
'src/graphql/generated.ts': {
|
||||
plugins: ['typescript', 'typescript-operations'],
|
||||
config: {
|
||||
addUnderscoreToArgsType: true,
|
||||
dedupeOperationSuffix: true,
|
||||
},
|
||||
hooks: {
|
||||
|
||||
Generated
+1662
-9911
File diff suppressed because it is too large.
Load diff
Loaded 100 of 296 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user