mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 03:38:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34e8b45bad | ||
|
|
d1de958201 | ||
|
|
2b672fc07f | ||
|
|
8453efcfb6 | ||
|
|
0456e8eea6 | ||
|
|
0ec9a81552 | ||
|
|
3b4fedeee5 | ||
|
|
570617f467 | ||
|
|
8262aa34c6 | ||
|
|
1f72736126 | ||
|
|
3328b60f85 | ||
|
|
4abb8601d5 | ||
|
|
9a881d8366 | ||
|
|
657034a1a6 | ||
|
|
bb98f5beef | ||
|
|
4aca8b1cc4 | ||
|
|
800a0e0893 | ||
|
|
2911b04ea1 | ||
|
|
50b381abe0 | ||
|
|
c8696f6012 | ||
|
|
45d795e076 | ||
|
|
8e98a915b9 | ||
|
|
73c65d3edb | ||
|
|
01e3be71aa | ||
|
|
991bbeae8c | ||
|
|
6c342abb97 | ||
|
|
1016aa35f3 | ||
|
|
ceb8f846c2 | ||
|
|
53cb3a7263 | ||
|
|
09188ab271 | ||
|
|
c4ffc1605e | ||
|
|
d90f022966 | ||
|
|
8b8362e4ae | ||
|
|
b5ac163b37 | ||
|
|
92c8944cda | ||
|
|
96581b8094 | ||
|
|
e229f5378f | ||
|
|
19657cc7ff | ||
|
|
be7acb7ab5 | ||
|
|
3c302a055d | ||
|
|
449bd4981e | ||
|
|
4fd36987ba | ||
|
|
01c1ffcaf4 | ||
|
|
b10cc829bd | ||
|
|
3d8ed4c81f | ||
|
|
a344c0e1e8 | ||
|
|
f80f548e2f | ||
|
|
c72ae9f9f6 | ||
|
|
bf0420cd29 | ||
|
|
738ccb1ff6 | ||
|
|
c487bcf1df | ||
|
|
bcaaaddab8 | ||
|
|
4e432fa537 | ||
|
|
817d06e66e | ||
|
|
76a9d17ea7 | ||
|
|
8ce87bfa56 | ||
|
|
2b7e8454e3 | ||
|
|
962a2a733e | ||
|
|
9e13c1e820 | ||
|
|
cadb7b2d1e | ||
|
|
718c82d8e1 | ||
|
|
5d31ecc2bc | ||
|
|
890286a08d | ||
|
|
33ff59a75e | ||
|
|
c2bbba6e78 | ||
|
|
86b85e7d8d | ||
|
|
03a7d3a6ee | ||
|
|
a21db49184 | ||
|
|
6dd6da083d | ||
|
|
656921d776 | ||
|
|
fabde0c2bb | ||
|
|
7680f33879 | ||
|
|
199a996b38 | ||
|
|
4728c64d3f | ||
|
|
0fdf96e795 | ||
|
|
04f3bee0c4 | ||
|
|
140cfe445a | ||
|
|
da55f26cac | ||
|
|
e24130c6e0 | ||
|
|
2a0fe12836 | ||
|
|
4c153da14d | ||
|
|
81166df987 | ||
|
|
ef189b00be | ||
|
|
27e56afd12 | ||
|
|
1c342621c0 | ||
|
|
9b7098cb58 | ||
|
|
9b247a6197 | ||
|
|
862d0a8f09 | ||
|
|
417996ade0 | ||
|
|
1c6161432a | ||
|
|
3f667ae4a8 | ||
|
|
4fb6de564c | ||
|
|
975693e96e | ||
|
|
b84233bf07 | ||
|
|
6dec2f9a13 | ||
|
|
c15e184bb8 | ||
|
|
89740dbbfd | ||
|
|
390a165c57 | ||
|
|
f7fb43b974 | ||
|
|
a32080fadf | ||
|
|
55017fbf70 | ||
|
|
7fd8a3a75b | ||
|
|
0f38c2ad82 | ||
|
|
5198a2cc9c | ||
|
|
ca5f0d2306 | ||
|
|
9eefd5a5c4 | ||
|
|
a797caaa8a | ||
|
|
11323be04b | ||
|
|
b1f886ef8d | ||
|
|
14e75df9e5 | ||
|
|
dd88335654 | ||
|
|
5183a6581a | ||
|
|
fb7c812e50 | ||
|
|
e690ac0060 | ||
|
|
c2190ca74f | ||
|
|
157b7cb79f | ||
|
|
0d2b1c8d88 | ||
|
|
788cb57ff1 | ||
|
|
1634a50b6d | ||
|
|
805b403ae3 | ||
|
|
1672bcdadd | ||
|
|
30b1eb2dda | ||
|
|
99ce876d50 | ||
|
|
602502e502 | ||
|
|
30a3924f43 | ||
|
|
d2e6b4c45e | ||
|
|
419efa485f | ||
|
|
3bb4240cb4 | ||
|
|
1677efc00c | ||
|
|
c7b3105f9f | ||
|
|
6fceead233 | ||
|
|
4a109648a6 | ||
|
|
ea139e207c | ||
|
|
7b3c8cf245 | ||
|
|
efbe0bbdff | ||
|
|
8f645d8870 | ||
|
|
97007d17cc | ||
|
|
0dbaa34a8b | ||
|
|
2fc537f9a7 | ||
|
|
71cf0aea74 | ||
|
|
f6a3028356 | ||
|
|
3182070f4c | ||
|
|
097b58c7a9 | ||
|
|
9584d05708 | ||
|
|
b9ac59b336 |
No files matched your search
@@ -5,6 +5,14 @@ on:
|
||||
|
||||
name: Release workflow
|
||||
|
||||
# Releases push commits to main, so they must never overlap. The group is a
|
||||
# constant rather than per-ref, so two different tags serialize. Queued runs
|
||||
# are never cancelled, because a half-finished release leaves npm, the git
|
||||
# history, and cli-versions.json out of sync.
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Create a release
|
||||
@@ -47,6 +55,10 @@ jobs:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
# The changelog is generated on the branch it is committed to, so the
|
||||
# job never switches branches with a dirty working tree.
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
- uses: ./.github/actions/setup-mise
|
||||
- name: Install dependencies
|
||||
working-directory: ./scripts
|
||||
@@ -57,23 +69,67 @@ jobs:
|
||||
run: |
|
||||
git config user.email "[email protected]"
|
||||
git config user.name "Expo CI"
|
||||
- name: Check whether the changelog is already updated
|
||||
run: |
|
||||
if grep -qF "## [$EAS_CLI_VERSION](" CHANGELOG.md; then
|
||||
echo "CHANGELOG.md already has an entry for v$EAS_CLI_VERSION."
|
||||
echo "CHANGELOG_ALREADY_UPDATED=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "CHANGELOG_ALREADY_UPDATED=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
- name: Update changelog
|
||||
if: env.CHANGELOG_ALREADY_UPDATED == 'false'
|
||||
working-directory: ./scripts
|
||||
run: yarn release-changelog "$EAS_CLI_VERSION" > /tmp/current-changelog
|
||||
- name: Reuse the existing changelog entry
|
||||
if: env.CHANGELOG_ALREADY_UPDATED == 'true'
|
||||
run: |
|
||||
{
|
||||
echo "v$EAS_CLI_VERSION"
|
||||
echo
|
||||
awk -v heading="## [$EAS_CLI_VERSION](" '
|
||||
index($0, heading) == 1 { found = 1; next }
|
||||
found && /^## / { exit }
|
||||
found { print }
|
||||
' CHANGELOG.md | sed '/./,$!d'
|
||||
} > /tmp/current-changelog
|
||||
- name: Publish release
|
||||
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
|
||||
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
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changelog changes to commit."
|
||||
else
|
||||
git commit -m "update CHANGELOG.md for v$EAS_CLI_VERSION"
|
||||
git pull --rebase --autostash origin main
|
||||
git push
|
||||
fi
|
||||
bump-cli-versions:
|
||||
name: Gate on npm and bump cli-versions.json
|
||||
runs-on: ubuntu-latest
|
||||
needs: release-changelog
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
- uses: ./.github/actions/setup-mise
|
||||
- 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: Verify eas-cli is available on npm
|
||||
run: |
|
||||
availability_deadline_epoch=$(( $(date +%s) + 600 ))
|
||||
availability_timeout_seconds=1800
|
||||
availability_deadline_epoch=$(( $(date +%s) + availability_timeout_seconds ))
|
||||
|
||||
while true; do
|
||||
published="$(npm view "eas-cli@$EAS_CLI_VERSION" version --prefer-online 2>/dev/null || true)"
|
||||
@@ -95,7 +151,7 @@ jobs:
|
||||
sleep "$retry_delay"
|
||||
done
|
||||
|
||||
echo "eas-cli@$EAS_CLI_VERSION was not available on npm after 10 minutes." >&2
|
||||
echo "eas-cli@$EAS_CLI_VERSION was not available on npm after ${availability_timeout_seconds}s." >&2
|
||||
exit 1
|
||||
- name: Bump STAGING and PRODUCTION
|
||||
run: |
|
||||
@@ -109,11 +165,20 @@ jobs:
|
||||
- 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 push
|
||||
if git diff --cached --quiet; then
|
||||
echo "cli-versions.json already points at $EAS_CLI_VERSION."
|
||||
else
|
||||
git commit -m "bump STAGING and PRODUCTION to $EAS_CLI_VERSION in cli-versions.json"
|
||||
git pull --rebase --autostash origin main
|
||||
git push
|
||||
fi
|
||||
- name: Prepare changelog for Slack
|
||||
run: |
|
||||
tail -n +3 /tmp/current-changelog > /tmp/changelog.md
|
||||
awk -v heading="## [$EAS_CLI_VERSION](" '
|
||||
index($0, heading) == 1 { found = 1; next }
|
||||
found && /^## / { exit }
|
||||
found { print }
|
||||
' CHANGELOG.md | sed '/./,$!d' > /tmp/changelog.md
|
||||
npx slackmd /tmp/changelog.md
|
||||
{
|
||||
echo 'SLACK_CHANGELOG<<EOF'
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Test recording recovery
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: &recording-paths
|
||||
- packages/build-tools/src/steps/utils/deviceRunSessionScreenRecordings.ts
|
||||
- packages/build-tools/src/steps/utils/deviceRunSessionArtifacts.ts
|
||||
- packages/build-tools/src/steps/utils/__tests__/*.test.ffmpeg.ts
|
||||
- packages/build-tools/jest/ffmpeg-config.ts
|
||||
- .github/workflows/test-recording-recovery.yml
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
paths: *recording-paths
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: test-recording-recovery-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
name: Test recording recovery with ffmpeg
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup tools
|
||||
uses: ./.github/actions/setup-mise
|
||||
- name: Install ffmpeg
|
||||
# The recording recovery test cuts and decodes real MP4 files with ffmpeg and ffprobe.
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Build build-tools dependencies
|
||||
# tsc emits the dist files that the test imports from workspace packages such as @expo/logger.
|
||||
run: yarn run -T lerna run typecheck --scope @expo/build-tools --include-dependencies
|
||||
- name: Run recording recovery test
|
||||
working-directory: packages/build-tools
|
||||
run: yarn jest-ffmpeg
|
||||
@@ -15,9 +15,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: ['20.18.3', '22', '24']
|
||||
node: ['22', '24']
|
||||
include:
|
||||
- node: '20.18.3'
|
||||
- node: '22'
|
||||
coverage: true
|
||||
name: Test with Node ${{ matrix.node }}
|
||||
steps:
|
||||
|
||||
+168
-1
@@ -8,12 +8,177 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Let connected sandboxes run shell commands through the Expo MCP server. ([#4347](https://github.com/expo/eas-cli/pull/4347) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Let the MCP server read text and image files from sandboxes. ([#4555](https://github.com/expo/eas-cli/pull/4555) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [eas-cli][local-build-plugin] Deprecate Node.js 20, and print a warning when running on it. A future release will require Node.js 22 or newer. Development and CI now use Node.js 22. ([#4579](https://github.com/expo/eas-cli/pull/4579) by [@douglowder](https://github.com/douglowder))
|
||||
- [build-tools] Add `eas/run_agent`, a step that runs Claude Code for an agent run and connects it to the Expo MCP server. ([#4552](https://github.com/expo/eas-cli/pull/4552) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [build-tools] Support Codex in `eas/run_agent`. ([#4553](https://github.com/expo/eas-cli/pull/4553) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [build-tools] Write the agent's messages, tool calls, tool results and errors to the `eas/run_agent` job log as plain lines instead of raw JSON events. ([#4568](https://github.com/expo/eas-cli/pull/4568) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Show the average and P75 end-to-end run duration in `eas workflow:insights`. ([#4566](https://github.com/expo/eas-cli/pull/4566) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Ask for the App Store Connect API key type (team or individual) in the submission key prompt and skip the Issuer ID prompt for individual keys. ([#4589](https://github.com/expo/eas-cli/pull/4589) by [@sswrk](https://github.com/sswrk))
|
||||
- [build-tools] Add `eas/download_submission_archive` to request a fresh archive URL when the step runs. ([#4539](https://github.com/expo/eas-cli/pull/4539) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-build-job] Add an `uploadArtifact` command to the sandbox daemon protocol. ([#4562](https://github.com/expo/eas-cli/pull/4562) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [build-tools] Let connected sandboxes upload files as sandbox artifacts through the Expo MCP server. ([#4563](https://github.com/expo/eas-cli/pull/4563) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Look up ffmpeg on PATH instead of running it. ([#4582](https://github.com/expo/eas-cli/pull/4582) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Stop reporting individual App Store Connect API keys as not valid on Apple's servers when adding them in `eas credentials`. ([#4588](https://github.com/expo/eas-cli/pull/4588) by [@sswrk](https://github.com/sswrk))
|
||||
- [build-tools] Install ffmpeg for Appium simulator sessions. ([#4577](https://github.com/expo/eas-cli/pull/4577) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Close the file of an artifact upload before reporting that the upload is done. ([#4609](https://github.com/expo/eas-cli/pull/4609) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [24.12.1](https://github.com/expo/eas-cli/releases/tag/v24.12.1) - 2026-10-08
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Stop a simulator session that the server creates after Ctrl+C lands during `eas simulator` create. ([#4522](https://github.com/expo/eas-cli/pull/4522) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
|
||||
## [24.12.0](https://github.com/expo/eas-cli/releases/tag/v24.12.0) - 2026-10-07
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Fix `eas workflow:ssh` handling of missing builds IDs. ([#4529](https://github.com/expo/eas-cli/pull/4529) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [build-tools] Upload the embedded bundle from custom builds that use `eas/build`. ([#4494](https://github.com/expo/eas-cli/pull/4494) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add the `eas/upload_embedded_bundle` step for custom build configs. ([#4494](https://github.com/expo/eas-cli/pull/4494) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Include `assetMapUrl` and `sourceMapUrl` in the `eas update:view --json` output. Each value is a signed download URL, or null when the update was published without that file. ([#4534](https://github.com/expo/eas-cli/pull/4534) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Improve the embedded bundle upload output in build logs. ([#4557](https://github.com/expo/eas-cli/pull/4557) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Allow the unnotarized mitmproxy cask to run in iOS network capture sessions. ([#4556](https://github.com/expo/eas-cli/pull/4556) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Encode macOS simulator session previews with cwebp, because Homebrew's FFmpeg cannot encode WebP. ([#4549](https://github.com/expo/eas-cli/pull/4549) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Log analytics events when an `eas simulator` session request is sent, cancelled, or fails before the server answers. ([#4483](https://github.com/expo/eas-cli/pull/4483) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
|
||||
## [24.11.0](https://github.com/expo/eas-cli/releases/tag/v24.11.0) - 2026-10-05
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Record iOS Simulator sessions through serve-sim. ([#4474](https://github.com/expo/eas-cli/pull/4474) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add browser device login with resumable commands for chat agents. ([#4502](https://github.com/expo/eas-cli/pull/4502) by [@eiiot](https://github.com/eiiot))
|
||||
- [build-tools] Upload the display rotation agent-device reports for a screenshot as device run session artifact metadata. ([#4508](https://github.com/expo/eas-cli/pull/4508) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Refresh a WebP thumbnail of the simulator session screen every minute as a reusable session preview artifact. ([#4398](https://github.com/expo/eas-cli/pull/4398) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Select the containing iOS app when its archive also includes a nested App Clip. ([#4541](https://github.com/expo/eas-cli/pull/4541) by [@mozzius](https://github.com/mozzius))
|
||||
- [build-tools] Allow expo.test subdomains to open local and staging simulator previews. ([#4480](https://github.com/expo/eas-cli/pull/4480) by [@hirbod](https://github.com/hirbod))
|
||||
- [build-tools] Retry App Store Connect connection failures and safely recover interrupted status reads and upload commits. ([#4520](https://github.com/expo/eas-cli/pull/4520) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [build-tools] Remove the `eas/start_agent_device_remote_session` step; agent-device sessions use `eas/start_agent_device_session`. ([#4518](https://github.com/expo/eas-cli/pull/4518) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Report `eas-cli` as the request origin, and the coding agent when one is detected, when `eas simulator` creates a session, for analytics. ([#4523](https://github.com/expo/eas-cli/pull/4523) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
|
||||
## [24.10.0](https://github.com/expo/eas-cli/releases/tag/v24.10.0) - 2026-10-02
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--network-capture` to `eas simulator` to record HTTP(S) traffic from apps on the device, and `--network-capture-field` to also keep headers, query values, or request and response bodies. ([#4308](https://github.com/expo/eas-cli/pull/4308) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [24.9.0](https://github.com/expo/eas-cli/releases/tag/v24.9.0) - 2026-10-02
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--force-end-active-rollout` to `eas update`, `eas update:republish`, `eas update:roll-back-to-embedded` and `eas update:rollback`. ([#4233](https://github.com/expo/eas-cli/pull/4233) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Allow `eas simulator:start --platform android --egress local`. On Android, `--egress-allow localhost:<port>` also covers `10.0.2.2:<port>`, and refused destinations are shown as warnings. ([#4519](https://github.com/expo/eas-cli/pull/4519) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Support `eas/start_local_egress` on Linux for Android emulators: an always-listening relay for the emulator's proxy, a systemd scope and nftables fence that refuse traffic that cannot use the tunnel, and fence refusals in the session log. ([#4519](https://github.com/expo/eas-cli/pull/4519) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Add `--upload-source-maps` to `eas update`, which uploads the generated source maps to EAS so that stack traces from an update can be symbolicated. Source maps are stripped of `sourcesContent` before upload. ([#4436](https://github.com/expo/eas-cli/pull/4436) by [@douglowder](https://github.com/douglowder))
|
||||
- [build-tools] Upload the embedded bundle after build by default for projects on SDK 58 and later. Set `EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE` to `0` to disable it. On SDK 57 and below the feature remains experimental and opt-in via `EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE`. ([#4433](https://github.com/expo/eas-cli/pull/4433) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Save manual iOS and Android live preview screenshots as session artifacts when supported by the preview server. ([#4481](https://github.com/expo/eas-cli/pull/4481) by [@vonovak](https://github.com/vonovak))
|
||||
- [build-tools] Add the `eas/install_mitmproxy` step. ([#4307](https://github.com/expo/eas-cli/pull/4307) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Start serve-sim with `--network-capture`, and with `--network-capture-field` for each requested field, when a simulator session requests network capture. ([#4307](https://github.com/expo/eas-cli/pull/4307) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Allow workflow submissions to skip loading app config when project and application identifiers are supplied. ([#4471](https://github.com/expo/eas-cli/pull/4471) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Record Android device sessions and upload the screen recording, including the partial recording a killed Device Hub leaves behind. ([#4418](https://github.com/expo/eas-cli/pull/4418) by [@vonovak](https://github.com/vonovak))
|
||||
- [build-tools] Add start and collect steps that attach bounded simulator logs from serve-sim to device run sessions. ([#4463](https://github.com/expo/eas-cli/pull/4463) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
- [build-tools] Add `eas/start_agent_device_session`, one step for agent-device sessions that boots the device, downloads, installs and launches the app, and starts the session, with the independent parts in parallel. ([#4491](https://github.com/expo/eas-cli/pull/4491) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-build-job] Add `sentry` workflow interpolation context schema for Sentry-triggered workflow runs. ([#4453](https://github.com/expo/eas-cli/pull/4453) by [@sswrk](https://github.com/sswrk))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Stop `eas update:rollback` from always forcing non-interactive mode. ([#4504](https://github.com/expo/eas-cli/pull/4504) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Pass the step cancellation signal to `eas/download_build`. ([#4511](https://github.com/expo/eas-cli/pull/4511) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Accept `null` for the TestFlight group `hasAccessToAllBuilds` attribute from App Store Connect. ([#4528](https://github.com/expo/eas-cli/pull/4528) by [@sswrk](https://github.com/sswrk))
|
||||
- [eas-cli] Support `queued` and `starting` status filters in `eas simulator:list` and keep simulator commands compatible with the current GraphQL schema. ([#4527](https://github.com/expo/eas-cli/pull/4527) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Explain how to resolve missing or expired Apple agreements when App Store Connect rejects a request. ([#4513](https://github.com/expo/eas-cli/pull/4513) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Correct the `eas simulator:start --max-duration-minutes` help text: every plan can set a custom duration up to the plan's maximum, which is also the default. ([#4524](https://github.com/expo/eas-cli/pull/4524) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Fix `eas workflow:validate` crashing with certain workflow schemas, and exit with a non-zero code when validation fails. ([#4507](https://github.com/expo/eas-cli/pull/4507) by [@yuvalgnessin-qz](https://github.com/yuvalgnessin-qz))
|
||||
- [build-tools] Reuse App Store Connect tokens and refresh them before expiry so long uploads and processing waits remain authenticated. ([#4497](https://github.com/expo/eas-cli/pull/4497) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Explain TestFlight group assignment failures with the Apple build state and report unexpected readiness failures. ([#4521](https://github.com/expo/eas-cli/pull/4521) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Clear old Maestro CLI HTML reports before each test attempt so retries cannot upload an earlier report. ([#4409](https://github.com/expo/eas-cli/pull/4409) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Poll the web preview readiness every 250 ms instead of every second, so a session goes live sooner after its host is ready. ([#4496](https://github.com/expo/eas-cli/pull/4496) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Keep the `eas simulator` session spinner on one line, so resizing the terminal no longer leaves copies of it behind. The `eas simulator:stop` command is now printed once before the spinner starts. ([#4488](https://github.com/expo/eas-cli/pull/4488) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Disable agent-device's internal idle timeouts in remote sessions, so only the session's own idle and duration limits end it. ([#4485](https://github.com/expo/eas-cli/pull/4485) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Keep `eas simulator:events --follow` running while a session is queued or starting. ([#4506](https://github.com/expo/eas-cli/pull/4506) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
|
||||
## [24.8.0](https://github.com/expo/eas-cli/releases/tag/v24.8.0) - 2026-09-24
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--build-fingerprint` to `eas simulator` to install the newest build with a given fingerprint. ([#4472](https://github.com/expo/eas-cli/pull/4472) by [@tchayen](https://github.com/tchayen))
|
||||
- [eas-cli] Accept build IDs in `workflow:ssh <id>`. ([#4452](https://github.com/expo/eas-cli/pull/4452) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [eas-cli] Make `eas simulator:*` commands visible in help output. ([#4456](https://github.com/expo/eas-cli/pull/4456) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Add `eas/update_testflight_metadata` to set changelogs and TestFlight groups for an uploaded build with an App Store Connect API key. The step fails if a requested group does not exist. ([#4475](https://github.com/expo/eas-cli/pull/4475) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Pass `--share-url` when launching serve-sim so Share copies the expo.dev preview page instead of the tunnel URL. ([#4434](https://github.com/expo/eas-cli/pull/4434) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas integrations:supabase:advisors` to list unresolved Supabase Security and Performance Advisor findings for the linked Supabase project. ([#4342](https://github.com/expo/eas-cli/pull/4342) by [@fiberjw](https://github.com/fiberjw))
|
||||
- [eas-cli] Accept individual App Store Connect API keys for submissions, TestFlight setup, and metadata. ([#4249](https://github.com/expo/eas-cli/pull/4249) by [@sswrk](https://github.com/sswrk))
|
||||
- [build-tools] Add `launch_app_identifier`, `launch_args`, and `open_url` inputs to the serve-sim, agent-device, argent, and Appium remote sessions, so serve-sim launches an installed app before the stream starts. The inputs are iOS only, and Android sessions keep using `eas/launch_application`. ([#4324](https://github.com/expo/eas-cli/pull/4324) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Stream iOS Simulator previews at 1600px and 10 Mbps. ([#4439](https://github.com/expo/eas-cli/pull/4439) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Report early Argent server exits during simulator startup instead of waiting for the full readiness timeout. ([#4454](https://github.com/expo/eas-cli/pull/4454) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
- [eas-cli] Stop spinners from printing a new line on every frame after the terminal gets narrower. ([#4470](https://github.com/expo/eas-cli/pull/4470) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Exclude time waiting for simulator concurrency from the session startup timeout and show queue progress. ([#4411](https://github.com/expo/eas-cli/pull/4411) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] List `eas simulator` only once in `eas --help`. The `sim` shorthand is now a hidden alias, so it still runs but no longer duplicates the entry. ([#4457](https://github.com/expo/eas-cli/pull/4457) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [build-tools] Start the Argent tool-server with `ARGENT_EMULATOR_NO_WINDOW=1`, so Argent can boot the Android emulator again after it stops. Without it, the windowed emulator fails on the Linux image because `libpulse.so.0` is missing. ([#4465](https://github.com/expo/eas-cli/pull/4465) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Fix `eas account:view` printing `Role: undefined` for the new Release Manager role. ([#4300](https://github.com/expo/eas-cli/pull/4300) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
- [eas-cli] Include legacy serve-sim sessions in `eas simulator:list --type web-preview-only`, and list `web-preview-only` only once in the `--type` options. ([#4466](https://github.com/expo/eas-cli/pull/4466) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Stop creating `CLAUDE.md` in new apps; use `AGENTS.md` for agent instructions. ([#4446](https://github.com/expo/eas-cli/pull/4446) by [@davidmokos](https://github.com/davidmokos))
|
||||
- Upgrade `typescript` to 6.0.3, `ts-jest` to 29.4.12, `@types/node` to 20.19.43, `gql.tada` to 1.11.3, and `https-proxy-agent` to 7.0.6. ([#4400](https://github.com/expo/eas-cli/pull/4400) by [@wschurman](https://github.com/wschurman))
|
||||
- [build-tools] Use the website origin for simulator preview CORS and framing, and allow deploy previews and local website dev servers on staging. ([#4437](https://github.com/expo/eas-cli/pull/4437) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Report the web preview as `webPreviewUrl` and `webPreviewToken` in addition to the legacy serve-sim keys. ([#4467](https://github.com/expo/eas-cli/pull/4467) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [24.7.0](https://github.com/expo/eas-cli/releases/tag/v24.7.0) - 2026-09-16
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Stream iOS Simulator previews with H.264. ([#4416](https://github.com/expo/eas-cli/pull/4416) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Report the expo.dev preview page as a simulator session's preview URL, and the preview server behind it as `previewApiUrl`. ([#4353](https://github.com/expo/eas-cli/pull/4353) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] `eas simulator:start` and `eas simulator:get` now report the expo.dev preview page instead of the raw tunnel URL, in both the printed instructions and `--json`. ([#4352](https://github.com/expo/eas-cli/pull/4352) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Skip the managed-workflow Expo SDK and Metro config checks for builds that use a custom build config, since those builds run their own steps instead of the standard managed pipeline. ([#4424](https://github.com/expo/eas-cli/pull/4424) by [@douglowder](https://github.com/douglowder))
|
||||
- [build-tools] Honor `EAS_OVERRIDE_PACKAGE_MANAGER` and `EAS_FALLBACK_PACKAGE_MANAGER` when installing and launching simulator packages (serve-sim, device-hub, Argent, agent-device, Appium) so they can use bun instead of stalling on npm audit. ([#4343](https://github.com/expo/eas-cli/pull/4343) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [24.6.0](https://github.com/expo/eas-cli/releases/tag/v24.6.0) - 2026-09-15
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Display logs in `eas build --wait`. ([#4303](https://github.com/expo/eas-cli/pull/4303) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Skip project source refresh for jobs with no sources. ([#4389](https://github.com/expo/eas-cli/pull/4389) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Stop fetching the primary account in the login query. Commands that need it now fetch it on demand, so scoped access tokens without primary account access no longer fail at login. ([#4380](https://github.com/expo/eas-cli/pull/4380) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Regenerate GraphQL types and update Observe tests for the new `firstSeenAt` and `lastSeenAt` event-name fields. ([#4379](https://github.com/expo/eas-cli/pull/4379) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
## [24.5.0](https://github.com/expo/eas-cli/releases/tag/v24.5.0) - 2026-09-15
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Let connected sandboxes run shell commands through the Expo MCP server. ([#4347](https://github.com/expo/eas-cli/pull/4347) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [24.4.2](https://github.com/expo/eas-cli/releases/tag/v24.4.2) - 2026-09-15
|
||||
|
||||
### 🐛 Bug fixes
|
||||
@@ -91,6 +256,8 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
- [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))
|
||||
|
||||
- [eas-cli] Remove the obsolete `appVersion` runtime policy warning that referenced SDK 46 and expo-updates 0.14.4. ([#4375](https://github.com/expo/eas-cli/pull/4375) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [24.0.0](https://github.com/expo/eas-cli/releases/tag/v24.0.0) - 2026-09-09
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
@@ -164,7 +164,7 @@ Each package has independent TypeScript compilation:
|
||||
|
||||
TypeScript configs:
|
||||
|
||||
- `tsconfig.json` - Base configuration (extends @tsconfig/node20)
|
||||
- `tsconfig.json` - Base configuration (extends @tsconfig/node22)
|
||||
- `tsconfig.build.json` - Production builds
|
||||
- `tsconfig.allowUnused.json` - Development with relaxed rules
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that you d
|
||||
to access the functionality of and directly benefit from the
|
||||
functionality of the Licensed Work.
|
||||
|
||||
Change Date: 2029-07-15
|
||||
Change Date: 2029-10-01
|
||||
|
||||
Change License: MIT
|
||||
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
1. Invoke the GitHub workflow ["Manually trigger a release"](https://github.com/expo/eas-cli/actions/workflows/trigger-release.yml). The next version is chosen automatically based on the changelog entries. If you want to use different version, pass the version string as an input to the workflow, in semver format, e.g. "1.2.3"
|
||||
2. That's it! GitHub Actions is going to take care of the rest. Watch the #eas-cli Slack channel for a successful release notification.
|
||||
|
||||
The release also sets the `STAGING` version in `cli-versions.json`. It does not change the `PRODUCTION` version. See [EAS Build CLI versions](#eas-build-cli-versions).
|
||||
The release also sets the `STAGING` and `PRODUCTION` versions in `cli-versions.json`. See [EAS Build CLI versions](#eas-build-cli-versions).
|
||||
|
||||
## EAS Build CLI versions
|
||||
|
||||
@@ -14,7 +14,7 @@ The release also sets the `STAGING` version in `cli-versions.json`. It does not
|
||||
|
||||
This file replaces the old `latest-eas-build` and `latest-eas-build-staging` npm dist-tags. The build servers read the file from the `main` branch.
|
||||
|
||||
A release updates `STAGING` to the new version automatically. To promote a version to production, invoke the GitHub workflow ["Promote eas-cli to production"](https://github.com/expo/eas-cli/actions/workflows/promote-eas-cli-production.yml). Pass the version to promote as an input. If you leave the input empty, the workflow uses the current `STAGING` version. The workflow checks that the version is published on npm before it updates `PRODUCTION`.
|
||||
A release updates `STAGING` and `PRODUCTION` to the new version automatically. To set a different version to production (to fix a regression, for example), invoke the GitHub workflow ["Promote eas-cli to production"](https://github.com/expo/eas-cli/actions/workflows/promote-eas-cli-production.yml). Pass the version to promote as an input. If you leave the input empty, the workflow uses the current `STAGING` version. The workflow checks that the version is published on npm before it updates `PRODUCTION`.
|
||||
|
||||
## Choosing the next version
|
||||
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "24.4.2",
|
||||
"PRODUCTION": "24.4.2"
|
||||
"STAGING": "24.12.1",
|
||||
"PRODUCTION": "24.12.1"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "24.5.0",
|
||||
"version": "24.13.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -6,4 +6,4 @@ MISE_NODE_COREPACK = "true"
|
||||
actionlint = "1.7.12"
|
||||
gcloud = "570.0.0"
|
||||
jq = "1.8.1"
|
||||
node = "20.19.4"
|
||||
node = "22.23.3"
|
||||
+3
-3
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.11",
|
||||
"@types/node": "20.14.8",
|
||||
"@types/node": "22.20.5",
|
||||
"eslint-plugin-async-protect": "3.1.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-watch-typeahead": "2.2.2",
|
||||
@@ -32,8 +32,8 @@
|
||||
"oxlint": "1.43.0",
|
||||
"oxlint-tsgolint": "0.11.1",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "29.1.1",
|
||||
"typescript": "5.5.4"
|
||||
"ts-jest": "29.4.12",
|
||||
"typescript": "6.0.3"
|
||||
},
|
||||
"packageManager": "[email protected]"
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
bin/record-sim
|
||||
|
||||
.secrets*
|
||||
!.secrets.example
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Config } from '@jest/types';
|
||||
|
||||
import baseConfig from '../../../jest/jest.shared.config';
|
||||
|
||||
const config: Config.InitialOptions = {
|
||||
...baseConfig,
|
||||
rootDir: '../src',
|
||||
testMatch: ['**/__tests__/*.test.ffmpeg.ts'],
|
||||
clearMocks: true,
|
||||
setupFilesAfterEnv: ['<rootDir>/../jest/setup-tests.ts'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "24.5.0",
|
||||
"version": "24.13.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -23,7 +23,6 @@
|
||||
"watch": "tsc --watch --preserveWatchOutput",
|
||||
"prebuild": "yarn gql",
|
||||
"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",
|
||||
"build:egress-guard": "resources/egress-guard/build.sh",
|
||||
"test:egress-guard": "resources/egress-guard/tests/run-policy-tests.sh && resources/egress-guard/tests/run-guard-tests.sh",
|
||||
"typecheck": "tsc",
|
||||
@@ -31,6 +30,7 @@
|
||||
"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",
|
||||
"jest-ffmpeg": "jest --config jest/ffmpeg-config.ts",
|
||||
"jest-unit-watch": "jest --config jest/unit-config.ts --watch",
|
||||
"jest-integration-watch": "jest --config jest/integration-config.ts --watch",
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\"",
|
||||
@@ -41,17 +41,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "24.0.0",
|
||||
"@expo/eas-build-job": "24.5.0",
|
||||
"@expo/downloader": "24.13.0",
|
||||
"@expo/eas-build-job": "24.13.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "24.0.0",
|
||||
"@expo/logger": "24.13.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.5.0",
|
||||
"@expo/template-file": "24.0.0",
|
||||
"@expo/turtle-spawn": "24.0.0",
|
||||
"@expo/steps": "24.13.0",
|
||||
"@expo/template-file": "24.13.0",
|
||||
"@expo/turtle-spawn": "24.13.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
@@ -61,7 +61,7 @@
|
||||
"fast-glob": "^3.3.2",
|
||||
"fast-xml-parser": "^4.4.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
"gql.tada": "^1.8.13",
|
||||
"gql.tada": "^1.11.3",
|
||||
"joi": "^17.13.1",
|
||||
"jose": "5.10.0",
|
||||
"lodash": "^4.18.1",
|
||||
@@ -88,7 +88,7 @@
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.4",
|
||||
"@types/node": "20.14.2",
|
||||
"@types/node": "22.20.5",
|
||||
"@types/node-fetch": "^2.6.11",
|
||||
"@types/node-forge": "^1.3.11",
|
||||
"@types/plist": "^3.0.5",
|
||||
@@ -101,10 +101,10 @@
|
||||
"memfs": "^4.17.1",
|
||||
"nock": "13.4.0",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "^29.1.4",
|
||||
"ts-jest": "^29.4.12",
|
||||
"ts-mockito": "^2.6.1",
|
||||
"tslib": "^2.6.3",
|
||||
"typescript": "^5.5.4",
|
||||
"typescript": "^6.0.3",
|
||||
"uuid": "^9.0.1"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
.build/
|
||||
.swiftpm/
|
||||
DerivedData/
|
||||
.DS_Store
|
||||
@@ -1,28 +0,0 @@
|
||||
// swift-tools-version: 5.9
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "record-sim",
|
||||
platforms: [
|
||||
.macOS(.v14),
|
||||
],
|
||||
products: [
|
||||
.library(name: "RecordSim", targets: ["RecordSim"]),
|
||||
.executable(name: "record-sim", targets: ["record-sim"]),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "RecordSim",
|
||||
linkerSettings: [
|
||||
.linkedFramework("AVFoundation"),
|
||||
.linkedFramework("CoreGraphics"),
|
||||
.linkedFramework("CoreMedia"),
|
||||
.linkedFramework("CoreVideo"),
|
||||
.linkedFramework("IOSurface"),
|
||||
.linkedFramework("UniformTypeIdentifiers"),
|
||||
]
|
||||
),
|
||||
.executableTarget(name: "record-sim", dependencies: ["RecordSim"]),
|
||||
]
|
||||
)
|
||||
@@ -1,79 +0,0 @@
|
||||
# RecordSim
|
||||
|
||||
Small Swift library for non-exclusive iOS Simulator screen recording.
|
||||
|
||||
It implements one recording path:
|
||||
|
||||
1. Listen to SimRenderServer framebuffer callbacks.
|
||||
2. Copy the live `IOSurface` immediately into an owned `CVPixelBuffer`.
|
||||
3. Feed one continuous `AVAssetWriter`.
|
||||
4. Emit Apple HLS/CMAF-style fragmented MP4 segments.
|
||||
|
||||
The output is suitable for uploading while recording. `session.json` contains
|
||||
the ordered segment metadata needed to build a playlist later.
|
||||
|
||||
## Output
|
||||
|
||||
```text
|
||||
session/
|
||||
init.mp4
|
||||
session.json
|
||||
segments/
|
||||
segment-000000.m4s
|
||||
segment-000001.m4s
|
||||
```
|
||||
|
||||
`init.mp4` is the fMP4 initialization segment. Individual `.m4s` files are not
|
||||
standalone MP4 files. `session.json` stores the wall-clock timestamp for the
|
||||
first video frame plus the metadata needed to build a playlist: HLS version,
|
||||
target duration, media sequence, `initSegment`, and the ordered `segments` array
|
||||
with file path and duration for each media segment.
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
swift run record-sim \
|
||||
--udid <BOOTED_SIMULATOR_UDID> \
|
||||
--output /tmp/sim-recording \
|
||||
--segment-duration 120 \
|
||||
--fps 30 \
|
||||
--bitrate 30000000 \
|
||||
--codec h264
|
||||
```
|
||||
|
||||
For sparse 2h sessions, `--segment-duration 120` is a reasonable default: about
|
||||
60 media objects plus the init segment. The recorder appends a single duplicate
|
||||
hold frame near each segment boundary when the simulator is idle, so long idle
|
||||
periods still produce uploadable segments without encoding idle frames at 60fps.
|
||||
Use `--segment-duration 0` to write one continuous `recording.mp4` instead of
|
||||
`init.mp4` plus media segments.
|
||||
|
||||
Frame timestamps use monotonic host time, not wall-clock time. The recorder also
|
||||
probes the framebuffer seed once per second; if the surface changes without
|
||||
callbacks for 5 seconds, it captures that frame and rewires the private callback
|
||||
registration.
|
||||
|
||||
## Library
|
||||
|
||||
```swift
|
||||
let recorder = SimulatorRecorder(
|
||||
configuration: SimulatorRecordingConfiguration(
|
||||
deviceUDID: udid,
|
||||
outputDirectory: sessionDirectory,
|
||||
segmentDuration: 120
|
||||
)
|
||||
)
|
||||
|
||||
recorder.onSegment = { segment in
|
||||
// Enqueue upload of segment.url.
|
||||
}
|
||||
|
||||
try recorder.start()
|
||||
try recorder.waitUntilFirstFrame()
|
||||
// ...
|
||||
let manifest = try recorder.stop()
|
||||
```
|
||||
|
||||
`session.json` contains the precise wall-clock time for video PTS zero. In
|
||||
segmented mode it also contains the init segment and ordered media segment
|
||||
file/duration entries; in single-file mode it points at `recording.mp4`.
|
||||
-192
@@ -1,192 +0,0 @@
|
||||
import Foundation
|
||||
import IOSurface
|
||||
import ObjectiveC
|
||||
|
||||
final class FramebufferDisplaySource {
|
||||
struct SurfaceSnapshot {
|
||||
let surface: IOSurface
|
||||
let width: Int
|
||||
let height: Int
|
||||
let seed: UInt32
|
||||
}
|
||||
|
||||
private let deviceUDID: String
|
||||
private let callbackQueue: DispatchQueue
|
||||
private let onFrame: () -> Void
|
||||
private let onSurfaceChange: () -> Void
|
||||
private var descriptors: [NSObject] = []
|
||||
private var callbackUUIDs: [ObjectIdentifier: NSUUID] = [:]
|
||||
private var retainedBlocks: [AnyObject] = []
|
||||
private var ioClient: NSObject?
|
||||
|
||||
init(
|
||||
deviceUDID: String,
|
||||
callbackQueue: DispatchQueue,
|
||||
onFrame: @escaping () -> Void,
|
||||
onSurfaceChange: @escaping () -> Void
|
||||
) {
|
||||
self.deviceUDID = deviceUDID
|
||||
self.callbackQueue = callbackQueue
|
||||
self.onFrame = onFrame
|
||||
self.onSurfaceChange = onSurfaceChange
|
||||
}
|
||||
|
||||
func start() throws {
|
||||
try PrivateSimulatorFrameworks.load()
|
||||
guard let device = SimulatorDeviceLookup.find(udid: deviceUDID) else {
|
||||
throw RecorderError.make(1, "Simulator \(deviceUDID) not found")
|
||||
}
|
||||
let state = device.value(forKey: "stateString") as? String ?? "unknown"
|
||||
guard state == "Booted" else {
|
||||
throw RecorderError.make(2, "Simulator \(deviceUDID) is not booted (state: \(state))")
|
||||
}
|
||||
guard
|
||||
let io = device.perform(NSSelectorFromString("io"))?.takeUnretainedValue() as? NSObject
|
||||
else {
|
||||
throw RecorderError.make(3, "Failed to get simulator IO client")
|
||||
}
|
||||
ioClient = io
|
||||
try wireUpFramebuffer()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
unregisterCallbacks()
|
||||
descriptors.removeAll()
|
||||
retainedBlocks.removeAll()
|
||||
ioClient = nil
|
||||
}
|
||||
|
||||
func surfaceSnapshot() -> SurfaceSnapshot? {
|
||||
let surfaceSelector = NSSelectorFromString("framebufferSurface")
|
||||
var bestSnapshot: SurfaceSnapshot?
|
||||
var bestArea = 0
|
||||
for descriptor in descriptors {
|
||||
guard let surfaceObject = descriptor.perform(surfaceSelector)?.takeUnretainedValue()
|
||||
else {
|
||||
continue
|
||||
}
|
||||
let surface = unsafeBitCast(surfaceObject, to: IOSurface.self)
|
||||
let width = IOSurfaceGetWidth(surface)
|
||||
let height = IOSurfaceGetHeight(surface)
|
||||
let area = width * height
|
||||
if area > bestArea {
|
||||
bestSnapshot = SurfaceSnapshot(
|
||||
surface: surface,
|
||||
width: width,
|
||||
height: height,
|
||||
seed: IOSurfaceGetSeed(surface)
|
||||
)
|
||||
bestArea = area
|
||||
}
|
||||
}
|
||||
return bestSnapshot
|
||||
}
|
||||
|
||||
func rewireFramebuffer() throws {
|
||||
try wireUpFramebuffer()
|
||||
}
|
||||
|
||||
private func wireUpFramebuffer() throws {
|
||||
guard let io = ioClient else {
|
||||
throw RecorderError.make(3, "No simulator IO client")
|
||||
}
|
||||
io.perform(NSSelectorFromString("updateIOPorts"))
|
||||
let nextDescriptors = try findFramebufferDescriptors(io: io)
|
||||
unregisterCallbacks()
|
||||
descriptors = nextDescriptors
|
||||
retainedBlocks.removeAll()
|
||||
do {
|
||||
for descriptor in descriptors {
|
||||
try registerCallbacks(descriptor: descriptor)
|
||||
}
|
||||
} catch {
|
||||
unregisterCallbacks()
|
||||
descriptors.removeAll()
|
||||
retainedBlocks.removeAll()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func findFramebufferDescriptors(io: NSObject) throws -> [NSObject] {
|
||||
guard let ports = io.value(forKey: "deviceIOPorts") as? [NSObject] else {
|
||||
throw RecorderError.make(4, "Failed to read simulator IO ports")
|
||||
}
|
||||
let portIdentifierSelector = NSSelectorFromString("portIdentifier")
|
||||
let descriptorSelector = NSSelectorFromString("descriptor")
|
||||
let surfaceSelector = NSSelectorFromString("framebufferSurface")
|
||||
|
||||
var candidates: [NSObject] = []
|
||||
for port in ports {
|
||||
guard port.responds(to: portIdentifierSelector),
|
||||
let portIdentifier = port.perform(portIdentifierSelector)?.takeUnretainedValue(),
|
||||
"\(portIdentifier)" == "com.apple.framebuffer.display",
|
||||
port.responds(to: descriptorSelector),
|
||||
let descriptor = port.perform(descriptorSelector)?.takeUnretainedValue()
|
||||
as? NSObject,
|
||||
descriptor.responds(to: surfaceSelector)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
candidates.append(descriptor)
|
||||
}
|
||||
if candidates.isEmpty {
|
||||
throw RecorderError.make(5, "No framebuffer display descriptor found")
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
private func registerCallbacks(descriptor: NSObject) throws {
|
||||
let selector = NSSelectorFromString(
|
||||
"registerScreenCallbacksWithUUID:callbackQueue:frameCallback:surfacesChangedCallback:propertiesChangedCallback:"
|
||||
)
|
||||
guard descriptor.responds(to: selector) else {
|
||||
throw RecorderError.make(6, "Framebuffer descriptor does not support screen callbacks")
|
||||
}
|
||||
guard let msgSendPointer = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "objc_msgSend")
|
||||
else {
|
||||
throw RecorderError.make(7, "objc_msgSend not found")
|
||||
}
|
||||
typealias MsgSend =
|
||||
@convention(c) (
|
||||
AnyObject, Selector, AnyObject, AnyObject, AnyObject, AnyObject, AnyObject
|
||||
) -> Void
|
||||
let msgSend = unsafeBitCast(msgSendPointer, to: MsgSend.self)
|
||||
|
||||
let uuid = NSUUID()
|
||||
callbackUUIDs[ObjectIdentifier(descriptor)] = uuid
|
||||
|
||||
let frameCallback: @convention(block) () -> Void = { [weak self] in
|
||||
self?.onFrame()
|
||||
}
|
||||
let surfacesCallback: @convention(block) (AnyObject?, AnyObject?) -> Void = {
|
||||
[weak self] _, _ in
|
||||
self?.onSurfaceChange()
|
||||
}
|
||||
let propertiesCallback: @convention(block) () -> Void = {}
|
||||
retainedBlocks.append(frameCallback as AnyObject)
|
||||
retainedBlocks.append(surfacesCallback as AnyObject)
|
||||
retainedBlocks.append(propertiesCallback as AnyObject)
|
||||
|
||||
msgSend(
|
||||
descriptor,
|
||||
selector,
|
||||
uuid,
|
||||
callbackQueue as AnyObject,
|
||||
frameCallback as AnyObject,
|
||||
surfacesCallback as AnyObject,
|
||||
propertiesCallback as AnyObject
|
||||
)
|
||||
}
|
||||
|
||||
private func unregisterCallbacks() {
|
||||
let selector = NSSelectorFromString("unregisterScreenCallbacksWithUUID:")
|
||||
for descriptor in descriptors {
|
||||
if let uuid = callbackUUIDs[ObjectIdentifier(descriptor)],
|
||||
descriptor.responds(to: selector)
|
||||
{
|
||||
descriptor.perform(selector, with: uuid)
|
||||
}
|
||||
}
|
||||
callbackUUIDs.removeAll()
|
||||
}
|
||||
}
|
||||
-96
@@ -1,96 +0,0 @@
|
||||
import Foundation
|
||||
import ObjectiveC
|
||||
|
||||
enum XcodeDeveloperDirectory {
|
||||
static let current: String = {
|
||||
if let developerDir = ProcessInfo.processInfo.environment["DEVELOPER_DIR"],
|
||||
!developerDir.isEmpty
|
||||
{
|
||||
return developerDir
|
||||
}
|
||||
|
||||
let fallback = "/Applications/Xcode.app/Contents/Developer"
|
||||
let pipe = Pipe()
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/xcode-select")
|
||||
process.arguments = ["-p"]
|
||||
process.standardOutput = pipe
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
process.waitUntilExit()
|
||||
return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? fallback
|
||||
}()
|
||||
}
|
||||
|
||||
enum PrivateSimulatorFrameworks {
|
||||
static func load() throws {
|
||||
try loadResult.get()
|
||||
}
|
||||
|
||||
private static let loadResult: Result<Void, Error> = {
|
||||
let developerDir = XcodeDeveloperDirectory.current
|
||||
let candidates = [
|
||||
"/Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator",
|
||||
"\(developerDir)/Library/PrivateFrameworks/CoreSimulator.framework/CoreSimulator",
|
||||
"\(developerDir)/../SharedFrameworks/SimulatorKit.framework/SimulatorKit",
|
||||
"\(developerDir)/Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit",
|
||||
]
|
||||
var failures: [String] = []
|
||||
for path in candidates {
|
||||
if dlopen(path, RTLD_NOW) == nil {
|
||||
let message = dlerror().map { String(cString: $0) } ?? "unknown error"
|
||||
failures.append("\(path): \(message)")
|
||||
}
|
||||
}
|
||||
guard NSClassFromString("SimServiceContext") != nil else {
|
||||
let tried = candidates.joined(separator: ", ")
|
||||
let errors = failures.isEmpty ? "no dlopen errors" : failures.joined(separator: "; ")
|
||||
return .failure(
|
||||
RecorderError.make(
|
||||
8,
|
||||
"Failed to load CoreSimulator/SimulatorKit (tried: \(tried); errors: \(errors))"
|
||||
)
|
||||
)
|
||||
}
|
||||
return .success(())
|
||||
}()
|
||||
}
|
||||
|
||||
enum SimulatorDeviceLookup {
|
||||
private static let context: NSObject? = {
|
||||
guard let contextClass = NSClassFromString("SimServiceContext") as? NSObject.Type else {
|
||||
return nil
|
||||
}
|
||||
let sharedSelector = NSSelectorFromString("sharedServiceContextForDeveloperDir:error:")
|
||||
return contextClass.perform(
|
||||
sharedSelector, with: XcodeDeveloperDirectory.current, with: nil)?
|
||||
.takeUnretainedValue() as? NSObject
|
||||
}()
|
||||
|
||||
static func find(udid: String) -> NSObject? {
|
||||
guard let context else {
|
||||
return nil
|
||||
}
|
||||
let deviceSetSelector = NSSelectorFromString("defaultDeviceSetWithError:")
|
||||
guard
|
||||
let deviceSet = context.perform(deviceSetSelector, with: nil)?
|
||||
.takeUnretainedValue() as? NSObject
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
guard let devices = deviceSet.value(forKey: "devices") as? [NSObject] else {
|
||||
return nil
|
||||
}
|
||||
return devices.first {
|
||||
($0.value(forKey: "UDID") as? NSUUID)?.uuidString == udid
|
||||
}
|
||||
}
|
||||
|
||||
static func stateString(udid: String) -> String? {
|
||||
find(udid: udid)?.value(forKey: "stateString") as? String
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public enum RecorderCodec: String, Codable, Sendable {
|
||||
case h264
|
||||
case hevc
|
||||
}
|
||||
|
||||
public struct SimulatorRecordingConfiguration: Sendable {
|
||||
public var deviceUDID: String
|
||||
public var outputDirectory: URL
|
||||
public var fps: Int
|
||||
public var bitrate: Int
|
||||
public var codec: RecorderCodec
|
||||
public var segmentDuration: TimeInterval
|
||||
public var maxPendingFrames: Int
|
||||
public var overwrite: Bool
|
||||
|
||||
public init(
|
||||
deviceUDID: String,
|
||||
outputDirectory: URL,
|
||||
fps: Int = 30,
|
||||
bitrate: Int = 30_000_000,
|
||||
codec: RecorderCodec = .h264,
|
||||
segmentDuration: TimeInterval = 120,
|
||||
maxPendingFrames: Int = 8,
|
||||
overwrite: Bool = true
|
||||
) {
|
||||
self.deviceUDID = deviceUDID
|
||||
self.outputDirectory = outputDirectory
|
||||
self.fps = fps
|
||||
self.bitrate = bitrate
|
||||
self.codec = codec
|
||||
self.segmentDuration = segmentDuration
|
||||
self.maxPendingFrames = maxPendingFrames
|
||||
self.overwrite = overwrite
|
||||
}
|
||||
}
|
||||
|
||||
public enum SegmentKind: String, Codable, Sendable {
|
||||
case initialization
|
||||
case media
|
||||
}
|
||||
|
||||
public struct SegmentOutput: Sendable {
|
||||
public let kind: SegmentKind
|
||||
public let relativePath: String
|
||||
public let url: URL
|
||||
public let byteCount: Int
|
||||
public let durationSeconds: Double?
|
||||
}
|
||||
|
||||
public struct MediaSegmentRecord: Codable, Sendable {
|
||||
public let file: String
|
||||
public let durationSeconds: Double
|
||||
}
|
||||
|
||||
public struct FirstFrameWallClock: Codable, Sendable {
|
||||
public let unixMs: Int64
|
||||
public let iso8601: String
|
||||
}
|
||||
|
||||
public enum RecordingFinalizationStage: Sendable {
|
||||
case captureStopped
|
||||
case videoSaved
|
||||
}
|
||||
|
||||
public struct RecordingManifest: Codable, Sendable {
|
||||
public let firstFrameWallClock: FirstFrameWallClock
|
||||
public let width: Int
|
||||
public let height: Int
|
||||
public let hlsVersion: Int?
|
||||
public let hlsTargetDurationSeconds: Int?
|
||||
public let hlsMediaSequence: Int?
|
||||
public let recording: String?
|
||||
public let initSegment: String?
|
||||
public let segments: [MediaSegmentRecord]
|
||||
}
|
||||
-140
@@ -1,140 +0,0 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
final class RecordingOutputWriter: NSObject, AVAssetWriterDelegate {
|
||||
private let rootDirectory: URL
|
||||
private let segmentsDirectory: URL
|
||||
let singleRecordingURL: URL
|
||||
private let stateQueue = DispatchQueue(label: "record-sim.segment-writer")
|
||||
private let notificationQueue = DispatchQueue(label: "record-sim.segment-notifications")
|
||||
private let onSegment: ((SegmentOutput) -> Void)?
|
||||
private var initSegmentPath: String?
|
||||
private var segments: [MediaSegmentRecord] = []
|
||||
private var nextMediaSegmentIndex = 0
|
||||
private var firstError: Error?
|
||||
|
||||
init(rootDirectory: URL, onSegment: ((SegmentOutput) -> Void)?) {
|
||||
self.rootDirectory = rootDirectory
|
||||
self.segmentsDirectory = rootDirectory.appendingPathComponent("segments", isDirectory: true)
|
||||
self.singleRecordingURL = rootDirectory.appendingPathComponent("recording.mp4")
|
||||
self.onSegment = onSegment
|
||||
super.init()
|
||||
}
|
||||
|
||||
func prepare(overwrite: Bool, segmented: Bool) throws {
|
||||
if overwrite {
|
||||
try? FileManager.default.removeItem(at: rootDirectory)
|
||||
}
|
||||
try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true)
|
||||
if segmented {
|
||||
try FileManager.default.createDirectory(at: segmentsDirectory, withIntermediateDirectories: true)
|
||||
}
|
||||
}
|
||||
|
||||
var error: Error? {
|
||||
stateQueue.sync { firstError }
|
||||
}
|
||||
|
||||
func assetWriter(
|
||||
_ writer: AVAssetWriter,
|
||||
didOutputSegmentData segmentData: Data,
|
||||
segmentType: AVAssetSegmentType,
|
||||
segmentReport: AVAssetSegmentReport?
|
||||
) {
|
||||
var notification: SegmentOutput?
|
||||
|
||||
stateQueue.sync {
|
||||
if firstError != nil {
|
||||
return
|
||||
}
|
||||
do {
|
||||
switch segmentType {
|
||||
case .initialization:
|
||||
let relativePath = "init.mp4"
|
||||
let url = rootDirectory.appendingPathComponent(relativePath)
|
||||
try segmentData.write(to: url, options: .atomic)
|
||||
initSegmentPath = relativePath
|
||||
notification = SegmentOutput(
|
||||
kind: .initialization,
|
||||
relativePath: relativePath,
|
||||
url: url,
|
||||
byteCount: segmentData.count,
|
||||
durationSeconds: nil
|
||||
)
|
||||
case .separable:
|
||||
let index = nextMediaSegmentIndex
|
||||
let relativePath = String(format: "segments/segment-%06d.m4s", index)
|
||||
let url = rootDirectory.appendingPathComponent(relativePath)
|
||||
try segmentData.write(to: url, options: .atomic)
|
||||
|
||||
let videoReport = segmentReport?.trackReports.first(where: { $0.mediaType == .video })
|
||||
let duration = videoReport.flatMap { timeSeconds($0.duration) } ?? 0
|
||||
let record = MediaSegmentRecord(
|
||||
file: relativePath,
|
||||
durationSeconds: duration
|
||||
)
|
||||
nextMediaSegmentIndex += 1
|
||||
segments.append(record)
|
||||
notification = SegmentOutput(
|
||||
kind: .media,
|
||||
relativePath: relativePath,
|
||||
url: url,
|
||||
byteCount: segmentData.count,
|
||||
durationSeconds: duration
|
||||
)
|
||||
default:
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
firstError = error
|
||||
}
|
||||
}
|
||||
|
||||
if let notification, let onSegment {
|
||||
notificationQueue.async {
|
||||
onSegment(notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func writeManifest(
|
||||
configuration: SimulatorRecordingConfiguration,
|
||||
firstFrameWallClock: Date,
|
||||
width: Int,
|
||||
height: Int
|
||||
) throws -> RecordingManifest {
|
||||
try stateQueue.sync {
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
let segmented = configuration.segmentDuration > 0
|
||||
if segmented, initSegmentPath == nil {
|
||||
throw RecorderError.make(50, "Missing initialization segment")
|
||||
}
|
||||
if segmented, segments.isEmpty {
|
||||
throw RecorderError.make(51, "No media segments were written")
|
||||
}
|
||||
let targetDuration = segmented
|
||||
? max(1, Int(ceil(segments.map(\.durationSeconds).max() ?? configuration.segmentDuration)))
|
||||
: nil
|
||||
let manifest = RecordingManifest(
|
||||
firstFrameWallClock: FirstFrameWallClock(
|
||||
unixMs: unixMs(firstFrameWallClock),
|
||||
iso8601: iso8601(firstFrameWallClock)
|
||||
),
|
||||
width: width,
|
||||
height: height,
|
||||
hlsVersion: segmented ? 7 : nil,
|
||||
hlsTargetDurationSeconds: targetDuration,
|
||||
hlsMediaSequence: segmented ? 0 : nil,
|
||||
recording: segmented ? nil : "recording.mp4",
|
||||
initSegment: segmented ? initSegmentPath : nil,
|
||||
segments: segments
|
||||
)
|
||||
let data = try JSONEncoder.prettySorted.encode(manifest)
|
||||
try data.write(to: rootDirectory.appendingPathComponent("session.json"), options: .atomic)
|
||||
return manifest
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,644 +0,0 @@
|
||||
import AVFoundation
|
||||
import CoreVideo
|
||||
import Foundation
|
||||
import IOSurface
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
public final class SimulatorRecorder {
|
||||
public var onSegment: ((SegmentOutput) -> Void)?
|
||||
public var onSimulatorStopped: ((String) -> Void)?
|
||||
public var onFinalizationStage: ((RecordingFinalizationStage) -> Void)?
|
||||
|
||||
private static let callbackStalenessTimeout: TimeInterval = 5
|
||||
private static let firstFrameRewireInterval: TimeInterval = 1
|
||||
private let configuration: SimulatorRecordingConfiguration
|
||||
private let callbackQueue = DispatchQueue(
|
||||
label: "record-sim.frame-callbacks", qos: .userInteractive)
|
||||
private let writerQueue = DispatchQueue(label: "record-sim.asset-writer", qos: .userInitiated)
|
||||
private let eventQueue = DispatchQueue(label: "record-sim.events", qos: .utility)
|
||||
private let pendingLock = NSLock()
|
||||
private var pendingFrames = 0
|
||||
private var displaySource: FramebufferDisplaySource?
|
||||
private var outputWriter: RecordingOutputWriter?
|
||||
private var writer: AVAssetWriter?
|
||||
private var input: AVAssetWriterInput?
|
||||
private var adaptor: AVAssetWriterInputPixelBufferAdaptor?
|
||||
private var boundaryTimer: DispatchSourceTimer?
|
||||
private var monotonicClock = MonotonicClock()
|
||||
private var firstAcceptedCaptureTime: CMTime?
|
||||
private var firstAcceptedWallClock: Date?
|
||||
private var recordingWidth: Int?
|
||||
private var recordingHeight: Int?
|
||||
private var lastPTS: CMTime?
|
||||
private var lastSeed: UInt32?
|
||||
private var lastFrameCallbackElapsed: TimeInterval?
|
||||
private var lastFirstFrameRewireElapsed: TimeInterval = 0
|
||||
private var lastAppendedPixelBuffer: CVPixelBuffer?
|
||||
private var nextBoundaryElapsed: TimeInterval = 0
|
||||
private var simulatorStoppedReason: String?
|
||||
private var stopped = false
|
||||
private var firstFrameReady = false
|
||||
private let firstFrameSemaphore = DispatchSemaphore(value: 0)
|
||||
private var firstError: Error?
|
||||
|
||||
public init(configuration: SimulatorRecordingConfiguration) {
|
||||
self.configuration = configuration
|
||||
}
|
||||
|
||||
public func start() throws {
|
||||
try validateConfiguration()
|
||||
|
||||
let outputWriter = RecordingOutputWriter(
|
||||
rootDirectory: configuration.outputDirectory,
|
||||
onSegment: { [weak self] segment in
|
||||
self?.onSegment?(segment)
|
||||
})
|
||||
try outputWriter.prepare(
|
||||
overwrite: configuration.overwrite, segmented: configuration.segmentDuration > 0)
|
||||
self.outputWriter = outputWriter
|
||||
|
||||
monotonicClock = MonotonicClock()
|
||||
nextBoundaryElapsed = configuration.segmentDuration
|
||||
lastFirstFrameRewireElapsed = 0
|
||||
stopped = false
|
||||
|
||||
let source = FramebufferDisplaySource(
|
||||
deviceUDID: configuration.deviceUDID,
|
||||
callbackQueue: callbackQueue,
|
||||
onFrame: { [weak self] in
|
||||
self?.captureFrame(force: false, reason: .callback)
|
||||
},
|
||||
onSurfaceChange: { [weak self] in
|
||||
self?.captureFrame(force: true, reason: .surfaceChange)
|
||||
}
|
||||
)
|
||||
try source.start()
|
||||
displaySource = source
|
||||
|
||||
startBoundaryTimer()
|
||||
}
|
||||
|
||||
public func waitUntilFirstFrame(timeoutSeconds: TimeInterval = 15) throws {
|
||||
let alreadyReady = try writerQueue.sync {
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
return firstFrameReady
|
||||
}
|
||||
if alreadyReady {
|
||||
return
|
||||
}
|
||||
if firstFrameSemaphore.wait(timeout: .now() + timeoutSeconds) == .timedOut {
|
||||
throw RecorderError.make(20, "Timed out waiting for first frame")
|
||||
}
|
||||
try writerQueue.sync {
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
if !firstFrameReady {
|
||||
throw RecorderError.make(21, "Recorder stopped before first frame")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func simulatorStopReason() -> String? {
|
||||
callbackQueue.sync {
|
||||
simulatorStoppedReason
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func stop() throws -> RecordingManifest {
|
||||
callbackQueue.sync {
|
||||
stopped = true
|
||||
boundaryTimer?.cancel()
|
||||
boundaryTimer = nil
|
||||
displaySource?.stop()
|
||||
displaySource = nil
|
||||
}
|
||||
onFinalizationStage?(.captureStopped)
|
||||
|
||||
return try writerQueue.sync {
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
guard let writer, let input, let outputWriter else {
|
||||
let error = RecorderError.make(22, "No frames were captured")
|
||||
firstError = error
|
||||
signalFirstFrameReadyIfNeeded()
|
||||
throw error
|
||||
}
|
||||
try appendTailFrameIfNeeded(finalPTS: monotonicClock.elapsedTime())
|
||||
input.markAsFinished()
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
writer.finishWriting {
|
||||
semaphore.signal()
|
||||
}
|
||||
if semaphore.wait(timeout: .now() + 60) == .timedOut {
|
||||
throw RecorderError.make(23, "Timed out finishing AVAssetWriter")
|
||||
}
|
||||
if writer.status == .failed {
|
||||
throw writer.error ?? RecorderError.make(24, "AVAssetWriter failed")
|
||||
}
|
||||
if let error = outputWriter.error {
|
||||
throw error
|
||||
}
|
||||
onFinalizationStage?(.videoSaved)
|
||||
guard let firstAcceptedWallClock else {
|
||||
throw RecorderError.make(25, "Missing first frame wall-clock timestamp")
|
||||
}
|
||||
guard let recordingWidth, let recordingHeight else {
|
||||
throw RecorderError.make(27, "Missing recording dimensions")
|
||||
}
|
||||
return try outputWriter.writeManifest(
|
||||
configuration: configuration,
|
||||
firstFrameWallClock: firstAcceptedWallClock,
|
||||
width: recordingWidth,
|
||||
height: recordingHeight
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private enum CaptureReason {
|
||||
case callback
|
||||
case surfaceChange
|
||||
case boundary
|
||||
case healthProbe
|
||||
}
|
||||
|
||||
private func validateConfiguration() throws {
|
||||
guard !configuration.deviceUDID.isEmpty else {
|
||||
throw RecorderError.make(30, "deviceUDID must not be empty")
|
||||
}
|
||||
guard configuration.fps > 0, configuration.fps <= 120 else {
|
||||
throw RecorderError.make(31, "fps must be between 1 and 120")
|
||||
}
|
||||
guard configuration.bitrate > 0 else {
|
||||
throw RecorderError.make(32, "bitrate must be positive")
|
||||
}
|
||||
guard configuration.segmentDuration >= 0 else {
|
||||
throw RecorderError.make(33, "segmentDuration must be non-negative")
|
||||
}
|
||||
guard configuration.maxPendingFrames > 0 else {
|
||||
throw RecorderError.make(34, "maxPendingFrames must be positive")
|
||||
}
|
||||
}
|
||||
|
||||
private func startBoundaryTimer() {
|
||||
let timer = DispatchSource.makeTimerSource(queue: callbackQueue)
|
||||
timer.schedule(deadline: .now() + 1, repeating: 1, leeway: .milliseconds(100))
|
||||
timer.setEventHandler { [weak self] in
|
||||
self?.probeSimulatorState()
|
||||
self?.probeFramebufferHealth()
|
||||
self?.appendBoundaryFrameIfNeeded()
|
||||
}
|
||||
timer.resume()
|
||||
boundaryTimer = timer
|
||||
}
|
||||
|
||||
private func appendBoundaryFrameIfNeeded() {
|
||||
if stopped {
|
||||
return
|
||||
}
|
||||
guard configuration.segmentDuration > 0 else {
|
||||
return
|
||||
}
|
||||
writerQueue.sync {
|
||||
guard firstAcceptedCaptureTime != nil else {
|
||||
return
|
||||
}
|
||||
let currentPTSSeconds = CMTimeGetSeconds(
|
||||
normalizedPresentationTime(for: monotonicClock.elapsedTime()))
|
||||
guard currentPTSSeconds >= nextBoundaryElapsed else {
|
||||
return
|
||||
}
|
||||
let boundary = nextBoundaryElapsed
|
||||
guard let lastPTS else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
if CMTimeGetSeconds(lastPTS) < boundary {
|
||||
let boundaryPTS = CMTime(seconds: boundary, preferredTimescale: 1_000_000_000)
|
||||
guard try appendHeldFrame(at: boundaryPTS) else {
|
||||
return
|
||||
}
|
||||
}
|
||||
nextBoundaryElapsed = boundary + configuration.segmentDuration
|
||||
} catch {
|
||||
firstError = error
|
||||
signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func captureFrame(force: Bool, reason: CaptureReason) {
|
||||
if stopped {
|
||||
return
|
||||
}
|
||||
if reason == .callback {
|
||||
lastFrameCallbackElapsed = monotonicClock.elapsedSeconds()
|
||||
}
|
||||
guard let snapshot = displaySource?.surfaceSnapshot() else {
|
||||
return
|
||||
}
|
||||
let surface = snapshot.surface
|
||||
let surfaceWidth = snapshot.width
|
||||
let surfaceHeight = snapshot.height
|
||||
guard surfaceWidth > 0, surfaceHeight > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
let seed = snapshot.seed
|
||||
let matchesLastAppendedSeed = writerQueue.sync { lastSeed == seed }
|
||||
if !force, matchesLastAppendedSeed {
|
||||
return
|
||||
}
|
||||
|
||||
guard reservePendingFrame() else {
|
||||
return
|
||||
}
|
||||
|
||||
let capturedAt = monotonicClock.elapsedTime()
|
||||
let capturedAtWallClock = Date()
|
||||
do {
|
||||
let pixelBuffer = try copySurfaceToOwnedPixelBuffer(
|
||||
surface,
|
||||
width: surfaceWidth,
|
||||
height: surfaceHeight
|
||||
)
|
||||
appendOwned(
|
||||
pixelBuffer: pixelBuffer,
|
||||
seed: seed,
|
||||
capturedAt: capturedAt,
|
||||
capturedAtWallClock: capturedAtWallClock
|
||||
)
|
||||
} catch {
|
||||
releasePendingFrame()
|
||||
writerQueue.async {
|
||||
self.firstError = error
|
||||
self.signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func probeFramebufferHealth() {
|
||||
guard !stopped, let displaySource else {
|
||||
return
|
||||
}
|
||||
let elapsed = monotonicClock.elapsedSeconds()
|
||||
if !isFirstFrameReady(),
|
||||
elapsed - lastFirstFrameRewireElapsed >= Self.firstFrameRewireInterval
|
||||
{
|
||||
lastFirstFrameRewireElapsed = elapsed
|
||||
rewireFramebuffer()
|
||||
captureFrame(force: true, reason: .healthProbe)
|
||||
return
|
||||
}
|
||||
|
||||
guard let snapshot = displaySource.surfaceSnapshot(),
|
||||
snapshot.width > 0,
|
||||
snapshot.height > 0
|
||||
else {
|
||||
rewireFramebuffer()
|
||||
return
|
||||
}
|
||||
|
||||
let observedSeed = snapshot.seed
|
||||
let lastAppendedSeed = writerQueue.sync { lastSeed }
|
||||
guard let lastAppendedSeed, observedSeed != lastAppendedSeed else {
|
||||
return
|
||||
}
|
||||
|
||||
let lastCallback = lastFrameCallbackElapsed ?? 0
|
||||
if elapsed - lastCallback >= Self.callbackStalenessTimeout {
|
||||
captureFrame(force: false, reason: .healthProbe)
|
||||
rewireFramebuffer()
|
||||
}
|
||||
}
|
||||
|
||||
private func probeSimulatorState() {
|
||||
guard !stopped, simulatorStoppedReason == nil else {
|
||||
return
|
||||
}
|
||||
let state = SimulatorDeviceLookup.stateString(udid: configuration.deviceUDID) ?? "missing"
|
||||
guard state != "Booted" else {
|
||||
return
|
||||
}
|
||||
simulatorStoppedReason = state
|
||||
let error = RecorderError.make(26, "Simulator stopped before first frame (state: \(state))")
|
||||
writerQueue.async {
|
||||
if self.firstError == nil, !self.firstFrameReady {
|
||||
self.firstError = error
|
||||
self.signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
}
|
||||
let onSimulatorStopped = onSimulatorStopped
|
||||
eventQueue.async {
|
||||
onSimulatorStopped?(state)
|
||||
}
|
||||
}
|
||||
|
||||
private func rewireFramebuffer() {
|
||||
guard let displaySource else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try displaySource.rewireFramebuffer()
|
||||
} catch {
|
||||
writerQueue.async {
|
||||
self.firstError = error
|
||||
self.signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reservePendingFrame() -> Bool {
|
||||
pendingLock.lock()
|
||||
defer { pendingLock.unlock() }
|
||||
if pendingFrames >= configuration.maxPendingFrames {
|
||||
return false
|
||||
}
|
||||
pendingFrames += 1
|
||||
return true
|
||||
}
|
||||
|
||||
private func releasePendingFrame() {
|
||||
pendingLock.lock()
|
||||
pendingFrames = max(0, pendingFrames - 1)
|
||||
pendingLock.unlock()
|
||||
}
|
||||
|
||||
private func copySurfaceToOwnedPixelBuffer(
|
||||
_ surface: IOSurface,
|
||||
width: Int,
|
||||
height: Int
|
||||
) throws -> CVPixelBuffer {
|
||||
let pool = try writerQueue.sync {
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
if writer == nil {
|
||||
try startWriter(width: width, height: height)
|
||||
}
|
||||
guard let pool = adaptor?.pixelBufferPool else {
|
||||
throw RecorderError.make(40, "AVAssetWriter pixel buffer pool is unavailable")
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
var output: CVPixelBuffer?
|
||||
let status = CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &output)
|
||||
guard status == kCVReturnSuccess, let destination = output else {
|
||||
throw RecorderError.make(41, "Failed to allocate pixel buffer from pool: \(status)")
|
||||
}
|
||||
|
||||
let destinationLockStatus = CVPixelBufferLockBaseAddress(destination, [])
|
||||
guard destinationLockStatus == kCVReturnSuccess else {
|
||||
throw RecorderError.make(50, "Failed to lock pixel buffer: \(destinationLockStatus)")
|
||||
}
|
||||
let surfaceLockStatus = IOSurfaceLock(surface, .readOnly, nil)
|
||||
guard surfaceLockStatus == KERN_SUCCESS else {
|
||||
CVPixelBufferUnlockBaseAddress(destination, [])
|
||||
throw RecorderError.make(49, "Failed to lock IOSurface: \(surfaceLockStatus)")
|
||||
}
|
||||
defer {
|
||||
IOSurfaceUnlock(surface, .readOnly, nil)
|
||||
CVPixelBufferUnlockBaseAddress(destination, [])
|
||||
}
|
||||
|
||||
guard let destinationAddress = CVPixelBufferGetBaseAddress(destination) else {
|
||||
throw RecorderError.make(42, "Pixel buffer has no base address")
|
||||
}
|
||||
let sourceAddress = IOSurfaceGetBaseAddress(surface)
|
||||
let sourceStride = IOSurfaceGetBytesPerRow(surface)
|
||||
let destinationStride = CVPixelBufferGetBytesPerRow(destination)
|
||||
let copyWidth = min(width, IOSurfaceGetWidth(surface), CVPixelBufferGetWidth(destination))
|
||||
let copyHeight = min(
|
||||
height, IOSurfaceGetHeight(surface), CVPixelBufferGetHeight(destination))
|
||||
let copyBytes = min(copyWidth * 4, sourceStride, destinationStride)
|
||||
for row in 0..<copyHeight {
|
||||
memcpy(
|
||||
destinationAddress.advanced(by: row * destinationStride),
|
||||
sourceAddress.advanced(by: row * sourceStride),
|
||||
copyBytes
|
||||
)
|
||||
}
|
||||
return destination
|
||||
}
|
||||
|
||||
private func appendOwned(
|
||||
pixelBuffer: CVPixelBuffer,
|
||||
seed: UInt32,
|
||||
capturedAt: CMTime,
|
||||
capturedAtWallClock: Date
|
||||
) {
|
||||
writerQueue.async {
|
||||
defer { self.releasePendingFrame() }
|
||||
if self.firstError != nil {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try self.appendOnWriterQueue(
|
||||
pixelBuffer: pixelBuffer,
|
||||
seed: seed,
|
||||
capturedAt: capturedAt,
|
||||
capturedAtWallClock: capturedAtWallClock
|
||||
)
|
||||
} catch {
|
||||
self.firstError = error
|
||||
self.signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func appendOnWriterQueue(
|
||||
pixelBuffer: CVPixelBuffer,
|
||||
seed: UInt32,
|
||||
capturedAt: CMTime,
|
||||
capturedAtWallClock: Date
|
||||
) throws {
|
||||
guard let writer, let input, let adaptor else {
|
||||
throw RecorderError.make(43, "AVAssetWriter is not initialized")
|
||||
}
|
||||
let isFirstFrame = firstAcceptedCaptureTime == nil
|
||||
let pts = isFirstFrame ? .zero : normalizedPresentationTime(for: capturedAt)
|
||||
if let lastPTS, CMTimeCompare(pts, lastPTS) <= 0 {
|
||||
return
|
||||
}
|
||||
guard try appendPendingBoundaryFrames(before: pts) else {
|
||||
return
|
||||
}
|
||||
guard input.isReadyForMoreMediaData else {
|
||||
return
|
||||
}
|
||||
if !adaptor.append(pixelBuffer, withPresentationTime: pts) {
|
||||
throw writer.error ?? RecorderError.make(44, "Failed to append pixel buffer")
|
||||
}
|
||||
if isFirstFrame {
|
||||
firstAcceptedCaptureTime = capturedAt
|
||||
firstAcceptedWallClock = capturedAtWallClock
|
||||
}
|
||||
lastPTS = pts
|
||||
lastSeed = seed
|
||||
lastAppendedPixelBuffer = pixelBuffer
|
||||
signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
|
||||
private func appendTailFrameIfNeeded(finalPTS: CMTime) throws {
|
||||
let deadline = Date().addingTimeInterval(5)
|
||||
while true {
|
||||
if try appendTailFrame(finalPTS: finalPTS) {
|
||||
return
|
||||
}
|
||||
if Date() >= deadline {
|
||||
throw RecorderError.make(45, "Timed out waiting to append tail frame")
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.05)
|
||||
}
|
||||
}
|
||||
|
||||
private func appendTailFrame(finalPTS: CMTime) throws -> Bool {
|
||||
let pts = normalizedPresentationTime(for: finalPTS)
|
||||
return try appendHeldFrame(at: pts)
|
||||
}
|
||||
|
||||
private func appendPendingBoundaryFrames(before pts: CMTime) throws -> Bool {
|
||||
guard configuration.segmentDuration > 0,
|
||||
firstAcceptedCaptureTime != nil
|
||||
else {
|
||||
return true
|
||||
}
|
||||
|
||||
while CMTimeGetSeconds(pts) > nextBoundaryElapsed {
|
||||
if let lastPTS, CMTimeGetSeconds(lastPTS) >= nextBoundaryElapsed {
|
||||
nextBoundaryElapsed += configuration.segmentDuration
|
||||
continue
|
||||
}
|
||||
|
||||
let boundaryPTS = CMTime(
|
||||
seconds: nextBoundaryElapsed, preferredTimescale: 1_000_000_000)
|
||||
guard try appendHeldFrame(at: boundaryPTS) else {
|
||||
return false
|
||||
}
|
||||
nextBoundaryElapsed += configuration.segmentDuration
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func appendHeldFrame(at pts: CMTime) throws -> Bool {
|
||||
guard let input,
|
||||
let adaptor,
|
||||
let lastPTS,
|
||||
let lastAppendedPixelBuffer
|
||||
else {
|
||||
return true
|
||||
}
|
||||
if CMTimeCompare(pts, lastPTS) <= 0 {
|
||||
return true
|
||||
}
|
||||
guard input.isReadyForMoreMediaData else {
|
||||
return false
|
||||
}
|
||||
if !adaptor.append(lastAppendedPixelBuffer, withPresentationTime: pts) {
|
||||
throw writer?.error ?? RecorderError.make(45, "Failed to append held frame")
|
||||
}
|
||||
self.lastPTS = pts
|
||||
return true
|
||||
}
|
||||
|
||||
private func normalizedPresentationTime(for capturedAt: CMTime) -> CMTime {
|
||||
let start = firstAcceptedCaptureTime ?? capturedAt
|
||||
return CMTimeSubtract(capturedAt, start)
|
||||
}
|
||||
|
||||
private func startWriter(width: Int, height: Int) throws {
|
||||
guard let outputWriter else {
|
||||
throw RecorderError.make(46, "Missing output writer")
|
||||
}
|
||||
|
||||
let writer: AVAssetWriter
|
||||
if configuration.segmentDuration > 0 {
|
||||
writer = AVAssetWriter(contentType: .mpeg4Movie)
|
||||
writer.delegate = outputWriter
|
||||
writer.outputFileTypeProfile = .mpeg4AppleHLS
|
||||
writer.preferredOutputSegmentInterval = CMTime(
|
||||
seconds: configuration.segmentDuration,
|
||||
preferredTimescale: 600
|
||||
)
|
||||
} else {
|
||||
writer = try AVAssetWriter(outputURL: outputWriter.singleRecordingURL, fileType: .mp4)
|
||||
}
|
||||
writer.initialSegmentStartTime = .zero
|
||||
|
||||
let input = AVAssetWriterInput(
|
||||
mediaType: .video,
|
||||
outputSettings: assetWriterSettings(width: width, height: height)
|
||||
)
|
||||
input.expectsMediaDataInRealTime = true
|
||||
|
||||
let attributes: [String: Any] = [
|
||||
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
|
||||
kCVPixelBufferWidthKey as String: width,
|
||||
kCVPixelBufferHeightKey as String: height,
|
||||
kCVPixelBufferIOSurfacePropertiesKey as String: [:],
|
||||
]
|
||||
let adaptor = AVAssetWriterInputPixelBufferAdaptor(
|
||||
assetWriterInput: input,
|
||||
sourcePixelBufferAttributes: attributes
|
||||
)
|
||||
|
||||
guard writer.canAdd(input) else {
|
||||
throw RecorderError.make(47, "AVAssetWriter cannot add video input")
|
||||
}
|
||||
writer.add(input)
|
||||
guard writer.startWriting() else {
|
||||
throw writer.error ?? RecorderError.make(48, "AVAssetWriter failed to start")
|
||||
}
|
||||
writer.startSession(atSourceTime: .zero)
|
||||
|
||||
self.writer = writer
|
||||
self.input = input
|
||||
self.adaptor = adaptor
|
||||
recordingWidth = width
|
||||
recordingHeight = height
|
||||
}
|
||||
|
||||
private func assetWriterSettings(width: Int, height: Int) -> [String: Any] {
|
||||
let codecType: AVVideoCodecType = configuration.codec == .hevc ? .hevc : .h264
|
||||
var compression: [String: Any] = [
|
||||
AVVideoAverageBitRateKey: configuration.bitrate,
|
||||
AVVideoExpectedSourceFrameRateKey: configuration.fps,
|
||||
AVVideoAllowFrameReorderingKey: true,
|
||||
]
|
||||
if configuration.segmentDuration > 0 {
|
||||
compression[AVVideoMaxKeyFrameIntervalKey] = max(
|
||||
1,
|
||||
Int((configuration.segmentDuration * Double(configuration.fps)).rounded())
|
||||
)
|
||||
}
|
||||
if configuration.codec == .h264 {
|
||||
compression[AVVideoProfileLevelKey] = AVVideoProfileLevelH264HighAutoLevel
|
||||
}
|
||||
return [
|
||||
AVVideoCodecKey: codecType,
|
||||
AVVideoWidthKey: width,
|
||||
AVVideoHeightKey: height,
|
||||
AVVideoCompressionPropertiesKey: compression,
|
||||
]
|
||||
}
|
||||
|
||||
private func signalFirstFrameReadyIfNeeded() {
|
||||
if firstFrameReady {
|
||||
return
|
||||
}
|
||||
firstFrameReady = true
|
||||
firstFrameSemaphore.signal()
|
||||
}
|
||||
|
||||
private func isFirstFrameReady() -> Bool {
|
||||
writerQueue.sync {
|
||||
firstFrameReady
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
|
||||
enum RecorderError {
|
||||
static func make(_ code: Int, _ message: String) -> NSError {
|
||||
NSError(
|
||||
domain: "RecordSim",
|
||||
code: code,
|
||||
userInfo: [NSLocalizedDescriptionKey: message]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension JSONEncoder {
|
||||
static var prettySorted: JSONEncoder {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
return encoder
|
||||
}
|
||||
}
|
||||
|
||||
func timeSeconds(_ time: CMTime) -> Double? {
|
||||
guard time.isValid, !time.isIndefinite, !time.isNegativeInfinity, !time.isPositiveInfinity else {
|
||||
return nil
|
||||
}
|
||||
return CMTimeGetSeconds(time)
|
||||
}
|
||||
|
||||
func iso8601(_ date: Date) -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
func unixMs(_ date: Date) -> Int64 {
|
||||
Int64((date.timeIntervalSince1970 * 1000).rounded())
|
||||
}
|
||||
|
||||
struct MonotonicClock {
|
||||
private static let timescale: CMTimeScale = 1_000_000_000
|
||||
|
||||
let startedAtUptimeNanoseconds: UInt64
|
||||
|
||||
init() {
|
||||
startedAtUptimeNanoseconds = DispatchTime.now().uptimeNanoseconds
|
||||
}
|
||||
|
||||
func elapsedSeconds() -> TimeInterval {
|
||||
TimeInterval(elapsedNanoseconds()) / TimeInterval(Self.timescale)
|
||||
}
|
||||
|
||||
func elapsedTime() -> CMTime {
|
||||
CMTime(value: CMTimeValue(elapsedNanoseconds()), timescale: Self.timescale)
|
||||
}
|
||||
|
||||
private func elapsedNanoseconds() -> UInt64 {
|
||||
DispatchTime.now().uptimeNanoseconds - startedAtUptimeNanoseconds
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import Foundation
|
||||
import RecordSim
|
||||
|
||||
struct CLIOptions {
|
||||
var udid: String?
|
||||
var output: String?
|
||||
var segmentDuration: TimeInterval = 120
|
||||
var fps = 30
|
||||
var bitrate = 30_000_000
|
||||
var codec: RecorderCodec = .h264
|
||||
}
|
||||
|
||||
private var requestedStopSignal: CInt = 0
|
||||
private var simulatorStopped: CInt = 0
|
||||
|
||||
private func handleStopSignal(_ signal: CInt) {
|
||||
requestedStopSignal = signal
|
||||
}
|
||||
|
||||
private func isStopRequested() -> Bool {
|
||||
requestedStopSignal != 0 || simulatorStopped != 0
|
||||
}
|
||||
|
||||
func usage() -> String {
|
||||
"""
|
||||
Usage:
|
||||
record-sim --udid <UDID> --output <session-dir> [options]
|
||||
|
||||
Options:
|
||||
--segment-duration <seconds> Target fMP4 media segment duration. Use 0 for one MP4. Default: 120
|
||||
--fps <fps> Expected source frame rate. Default: 30
|
||||
--bitrate <bits/sec> AVAssetWriter average bitrate. Default: 30000000
|
||||
--codec <h264|hevc> Video codec. Default: h264
|
||||
-h, --help Show this help
|
||||
"""
|
||||
}
|
||||
|
||||
func parseOptions(_ args: [String]) throws -> CLIOptions {
|
||||
var options = CLIOptions()
|
||||
var i = 0
|
||||
|
||||
func next(_ flag: String) throws -> String {
|
||||
i += 1
|
||||
guard i < args.count else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing value for \(flag)"])
|
||||
}
|
||||
return args[i]
|
||||
}
|
||||
|
||||
while i < args.count {
|
||||
let arg = args[i]
|
||||
switch arg {
|
||||
case "--udid", "-u":
|
||||
options.udid = try next(arg)
|
||||
case "--output", "--output-dir", "--out", "-o":
|
||||
options.output = try next(arg)
|
||||
case "--segment-duration":
|
||||
options.segmentDuration = Double(try next(arg)) ?? -1
|
||||
case "--fps":
|
||||
options.fps = Int(try next(arg)) ?? 0
|
||||
case "--bitrate":
|
||||
options.bitrate = Int(try next(arg)) ?? 0
|
||||
case "--codec":
|
||||
let raw = try next(arg)
|
||||
guard let codec = RecorderCodec(rawValue: raw) else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--codec must be h264 or hevc"])
|
||||
}
|
||||
options.codec = codec
|
||||
case "--help", "-h":
|
||||
print(usage())
|
||||
exit(0)
|
||||
default:
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Unknown argument: \(arg)"])
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
|
||||
guard options.udid != nil else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing required --udid"])
|
||||
}
|
||||
guard options.output != nil else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing required --output"])
|
||||
}
|
||||
guard options.segmentDuration >= 0 else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--segment-duration must be non-negative"])
|
||||
}
|
||||
guard options.fps > 0, options.fps <= 120 else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--fps must be between 1 and 120"])
|
||||
}
|
||||
guard options.bitrate > 0 else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--bitrate must be positive"])
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
do {
|
||||
signal(SIGINT, handleStopSignal)
|
||||
signal(SIGTERM, handleStopSignal)
|
||||
|
||||
let options = try parseOptions(Array(CommandLine.arguments.dropFirst()))
|
||||
let configuration = SimulatorRecordingConfiguration(
|
||||
deviceUDID: options.udid!,
|
||||
outputDirectory: URL(fileURLWithPath: options.output!),
|
||||
fps: options.fps,
|
||||
bitrate: options.bitrate,
|
||||
codec: options.codec,
|
||||
segmentDuration: options.segmentDuration
|
||||
)
|
||||
|
||||
let recorder = SimulatorRecorder(configuration: configuration)
|
||||
recorder.onSimulatorStopped = { state in
|
||||
simulatorStopped = 1
|
||||
fputs("[record-sim] simulator stopped state=\(state); finalizing recording\n", stderr)
|
||||
}
|
||||
recorder.onFinalizationStage = { stage in
|
||||
switch stage {
|
||||
case .captureStopped:
|
||||
fputs("[record-sim] screen capture stopped; saving final video\n", stderr)
|
||||
case .videoSaved:
|
||||
fputs("[record-sim] final video saved; writing recording details\n", stderr)
|
||||
}
|
||||
}
|
||||
recorder.onSegment = { segment in
|
||||
switch segment.kind {
|
||||
case .initialization:
|
||||
fputs("[record-sim] init \(segment.relativePath) bytes=\(segment.byteCount)\n", stderr)
|
||||
case .media:
|
||||
let duration = segment.durationSeconds ?? 0
|
||||
fputs(String(format: "[record-sim] segment %@ duration=%.3fs bytes=%d\n", segment.relativePath, duration, segment.byteCount), stderr)
|
||||
}
|
||||
}
|
||||
|
||||
try recorder.start()
|
||||
try recorder.waitUntilFirstFrame()
|
||||
fputs("[record-sim] recording -> \(options.output!)\n", stderr)
|
||||
|
||||
while !isStopRequested() {
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
}
|
||||
|
||||
if requestedStopSignal != 0 {
|
||||
let signalName = switch requestedStopSignal {
|
||||
case SIGINT: "SIGINT"
|
||||
case SIGTERM: "SIGTERM"
|
||||
default: "signal \(requestedStopSignal)"
|
||||
}
|
||||
fputs("[record-sim] received \(signalName); finishing recording\n", stderr)
|
||||
}
|
||||
let manifest = try recorder.stop()
|
||||
if let recording = manifest.recording {
|
||||
fputs("[record-sim] done recording=\(recording)\n", stderr)
|
||||
} else {
|
||||
fputs("[record-sim] done segments=\(manifest.segments.count)\n", stderr)
|
||||
}
|
||||
fputs("[record-sim] manifest \(URL(fileURLWithPath: options.output!).appendingPathComponent("session.json").path)\n", stderr)
|
||||
} catch {
|
||||
fputs("error: \(error.localizedDescription)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Android } from '@expo/eas-build-job';
|
||||
|
||||
export function resolveArtifactPath(job: Android.Job): string {
|
||||
return job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}';
|
||||
}
|
||||
@@ -60,7 +60,8 @@ jest.mock('../../utils/expoUpdates', () => ({
|
||||
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync: jest.fn(async () => null),
|
||||
}));
|
||||
jest.mock('../../utils/expoUpdatesEmbedded', () => ({
|
||||
uploadEmbeddedBundleAsync: jest.fn(),
|
||||
...jest.requireActual('../../utils/expoUpdatesEmbedded'),
|
||||
uploadEmbeddedBundleAsync: jest.fn(async () => ({ status: 'uploaded' })),
|
||||
}));
|
||||
jest.mock('../../utils/hooks', () => ({
|
||||
Hook: {
|
||||
@@ -317,36 +318,108 @@ describe(androidBuilder, () => {
|
||||
expect(runBuilderWithHooksAsync).toHaveBeenCalledWith(ctx, expect.any(Function));
|
||||
});
|
||||
|
||||
it('runs the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is set', async () => {
|
||||
const ctx = new BuildContext(createTestAndroidJob(), {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
describe('embedded bundle upload phase', () => {
|
||||
function createContext({
|
||||
env = {},
|
||||
sdkVersion,
|
||||
}: {
|
||||
env?: Record<string, string>;
|
||||
sdkVersion?: string;
|
||||
}): BuildContext<Android.Job> {
|
||||
return new BuildContext(createTestAndroidJob(), {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
...env,
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
metadata: sdkVersion ? { sdkVersion } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
it('runs by default on SDK 58 and later', async () => {
|
||||
const ctx = createContext({ sdkVersion: '58.0.0' });
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ job: ctx.job, env: ctx.env, projectDir: '/workingdir/build' })
|
||||
);
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
it.each([
|
||||
['uploaded', BuildPhaseResult.SUCCESS],
|
||||
['skipped', BuildPhaseResult.SKIPPED],
|
||||
['failed', BuildPhaseResult.WARNING],
|
||||
] as const)('marks the phase from the %s result', async (uploadResult, phaseResult) => {
|
||||
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValueOnce({ status: uploadResult });
|
||||
const ctx = createContext({ sdkVersion: '58.0.0' });
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
|
||||
});
|
||||
await androidBuilder(ctx);
|
||||
|
||||
it('skips the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is not set', async () => {
|
||||
const ctx = new BuildContext(createTestAndroidJob(), {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
expect(datadogDistributionMock).toHaveBeenCalledWith(
|
||||
'eas.build.phase_duration',
|
||||
expect.any(Number),
|
||||
expect.objectContaining({
|
||||
build_phase: BuildPhase.UPLOAD_EMBEDDED_BUNDLE.toLowerCase(),
|
||||
result: phaseResult,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
it('skips by default on SDK 57 and earlier', async () => {
|
||||
const ctx = createContext({ sdkVersion: '57.0.0' });
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips by default when the SDK version is unknown', async () => {
|
||||
const ctx = createContext({});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs on SDK 57 and earlier when EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE is 1', async () => {
|
||||
const ctx = createContext({
|
||||
sdkVersion: '57.0.0',
|
||||
env: { EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE: '1' },
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ job: ctx.job })
|
||||
);
|
||||
});
|
||||
|
||||
it('skips on SDK 58 and later when EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE is 0', async () => {
|
||||
const ctx = createContext({
|
||||
sdkVersion: '58.0.0',
|
||||
env: { EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE: '0' },
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs on SDK 57 and earlier when the experimental EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is 1', async () => {
|
||||
const ctx = createContext({
|
||||
sdkVersion: '57.0.0',
|
||||
env: { EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1' },
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ job: ctx.job })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
runGradleCommand,
|
||||
} from '../android/gradle';
|
||||
import { formatGradleProfileReport, parseGradleProfile } from '../android/gradleProfile';
|
||||
import { resolveArtifactPath } from '../android/resolve';
|
||||
import { Sentry } from '../sentry';
|
||||
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
|
||||
import { prebuildAsync } from '../common/prebuild';
|
||||
@@ -32,7 +33,10 @@ import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
|
||||
} from '../utils/expoUpdates';
|
||||
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
|
||||
import {
|
||||
shouldUploadEmbeddedBundle,
|
||||
uploadEmbeddedBundleAsync,
|
||||
} from '../utils/expoUpdatesEmbedded';
|
||||
import { Hook, runHookIfPresent } from '../utils/hooks';
|
||||
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
|
||||
|
||||
@@ -217,15 +221,26 @@ async function buildInnerAsync(
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
|
||||
await uploadApplicationArchive(ctx, {
|
||||
patternOrPath: ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}',
|
||||
patternOrPath: resolveArtifactPath(ctx.job),
|
||||
rootDir: ctx.getReactNativeProjectDirectory(),
|
||||
logger: ctx.logger,
|
||||
});
|
||||
});
|
||||
|
||||
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
|
||||
if (shouldUploadEmbeddedBundle(ctx)) {
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
const { status } = await uploadEmbeddedBundleAsync({
|
||||
job: ctx.job,
|
||||
env: ctx.env,
|
||||
logger: ctx.logger,
|
||||
projectDir: ctx.getReactNativeProjectDirectory(),
|
||||
appConfig: await ctx.appConfig,
|
||||
});
|
||||
if (status === 'skipped') {
|
||||
ctx.markBuildPhaseSkipped();
|
||||
} else if (status === 'failed') {
|
||||
ctx.markBuildPhaseHasWarnings();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,10 @@ import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
|
||||
} from '../utils/expoUpdates';
|
||||
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
|
||||
import {
|
||||
shouldUploadEmbeddedBundle,
|
||||
uploadEmbeddedBundleAsync,
|
||||
} from '../utils/expoUpdatesEmbedded';
|
||||
import { Hook, runHookIfPresent } from '../utils/hooks';
|
||||
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
|
||||
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
|
||||
@@ -234,15 +237,26 @@ async function buildInnerAsync(
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
|
||||
await uploadApplicationArchive(ctx, {
|
||||
patternOrPath: resolveArtifactPath(ctx),
|
||||
patternOrPath: resolveArtifactPath(ctx.job),
|
||||
rootDir: ctx.getReactNativeProjectDirectory(),
|
||||
logger: ctx.logger,
|
||||
});
|
||||
});
|
||||
|
||||
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
|
||||
if (shouldUploadEmbeddedBundle(ctx)) {
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
const { status } = await uploadEmbeddedBundleAsync({
|
||||
job: ctx.job,
|
||||
env: ctx.env,
|
||||
logger: ctx.logger,
|
||||
projectDir: ctx.getReactNativeProjectDirectory(),
|
||||
appConfig: await ctx.appConfig,
|
||||
});
|
||||
if (status === 'skipped') {
|
||||
ctx.markBuildPhaseSkipped();
|
||||
} else if (status === 'failed') {
|
||||
ctx.markBuildPhaseHasWarnings();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -63,12 +63,12 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
public readonly logger: bunyan;
|
||||
public readonly graphqlClient: Client;
|
||||
public readonly runtimeApi: BuilderRuntimeApi;
|
||||
public readonly expoApiV2BaseUrl?: string;
|
||||
public readonly mcpServerUrl?: string;
|
||||
public job: TJob;
|
||||
public metadata?: Metadata;
|
||||
|
||||
private _env: Env;
|
||||
private readonly expoApiV2BaseUrl?: string;
|
||||
private readonly pendingMetricUploads: Promise<void>[] = [];
|
||||
|
||||
constructor(buildCtx: BuildContext<TJob>) {
|
||||
|
||||
+17
-24
@@ -1,11 +1,9 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import { createLogger } from '@expo/logger';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { BuildContext } from '../../../context';
|
||||
import { distributionCertificate } from '../__tests__/fixtures';
|
||||
import Keychain from '../keychain';
|
||||
|
||||
@@ -17,11 +15,9 @@ jest.setTimeout(60 * 1000);
|
||||
// We need the JS code to modify the file system, so we need to mock it.
|
||||
jest.unmock('fs');
|
||||
|
||||
let ctx: BuildContext<Ios.Job>;
|
||||
|
||||
describe('Keychain class', () => {
|
||||
describe('ensureCertificateImported method', () => {
|
||||
let keychain: Keychain<Ios.Job>;
|
||||
let keychain: Keychain;
|
||||
const certificatePath = path.join(os.tmpdir(), `cert-${uuid()}.p12`);
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -36,37 +32,34 @@ describe('Keychain class', () => {
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = new BuildContext({ projectRootDirectory: '.' } as Ios.Job, {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: mockLogger,
|
||||
env: {},
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
keychain = new Keychain(ctx);
|
||||
await keychain.create();
|
||||
keychain = new Keychain();
|
||||
await keychain.create({ logger: mockLogger });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await keychain.destroy();
|
||||
await keychain.destroy({ logger: mockLogger });
|
||||
});
|
||||
|
||||
it("should throw an error if the certificate hasn't been imported", async () => {
|
||||
await expect(
|
||||
keychain.ensureCertificateImported(
|
||||
distributionCertificate.teamId,
|
||||
distributionCertificate.fingerprint
|
||||
)
|
||||
keychain.ensureCertificateImported({
|
||||
teamId: distributionCertificate.teamId,
|
||||
fingerprint: distributionCertificate.fingerprint,
|
||||
})
|
||||
).rejects.toThrowError(/hasn't been imported successfully/);
|
||||
});
|
||||
|
||||
it("shouldn't throw any error if the certificate has been imported successfully", async () => {
|
||||
await keychain.importCertificate(certificatePath, distributionCertificate.password);
|
||||
await keychain.importCertificate({
|
||||
logger: mockLogger,
|
||||
certPath: certificatePath,
|
||||
certPassword: distributionCertificate.password,
|
||||
});
|
||||
await expect(
|
||||
keychain.ensureCertificateImported(
|
||||
distributionCertificate.teamId,
|
||||
distributionCertificate.fingerprint
|
||||
)
|
||||
keychain.ensureCertificateImported({
|
||||
teamId: distributionCertificate.teamId,
|
||||
fingerprint: distributionCertificate.fingerprint,
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { errors } from '@expo/eas-build-job';
|
||||
import { createLogger } from '@expo/logger';
|
||||
import { SpawnResult } from '@expo/turtle-spawn';
|
||||
|
||||
import { runFastlane } from '../../fastlane';
|
||||
import Keychain from '../keychain';
|
||||
|
||||
jest.mock('../../fastlane');
|
||||
const runFastlaneMock = jest.mocked(runFastlane);
|
||||
const logger = createLogger({ name: 'test' });
|
||||
const logError = jest.spyOn(logger, 'error').mockImplementation();
|
||||
let keychain: Keychain;
|
||||
const options = {
|
||||
logger,
|
||||
certPath: '/test.p12',
|
||||
certPassword: 'certificate-password',
|
||||
};
|
||||
const result = (stdout: string, stderr = ''): SpawnResult => ({
|
||||
stdout,
|
||||
stderr,
|
||||
output: [stdout, stderr],
|
||||
pid: 1,
|
||||
status: 0,
|
||||
signal: null,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
keychain = new Keychain();
|
||||
runFastlaneMock.mockResolvedValueOnce(result(''));
|
||||
await keychain.create({ logger });
|
||||
runFastlaneMock.mockClear();
|
||||
});
|
||||
|
||||
it('runs the import with the certificate and keychain credentials, without streaming output', async () => {
|
||||
runFastlaneMock.mockResolvedValueOnce(result('Result: true'));
|
||||
await keychain.importCertificate(options);
|
||||
expect(runFastlaneMock).toHaveBeenCalledWith([
|
||||
'run',
|
||||
'import_certificate',
|
||||
'certificate_path:/test.p12',
|
||||
'certificate_password:certificate-password',
|
||||
`keychain_path:${keychain.data.path}`,
|
||||
`keychain_password:${keychain.data.password}`,
|
||||
]);
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe.each(['success', 'failure'])('Fastlane %s', outcome => {
|
||||
it.each([
|
||||
{
|
||||
output: 'SecKeychainItemImport: MAC verification failed during PKCS12 import',
|
||||
codes: ['PKCS12_MAC_VERIFICATION_FAILED'],
|
||||
},
|
||||
{
|
||||
output: 'SecKeychainItemImport: Unknown format in import',
|
||||
codes: ['PKCS12_UNKNOWN_FORMAT'],
|
||||
},
|
||||
{
|
||||
output: 'SecKeychainItemImport: Some other error',
|
||||
codes: ['CERTIFICATE_IMPORT_FAILED'],
|
||||
},
|
||||
{
|
||||
output:
|
||||
'SecKeychainItemImport: Unknown format in import\nSecKeychainItemSetAccessWithPassword: Access denied',
|
||||
codes: ['PKCS12_UNKNOWN_FORMAT', 'PRIVATE_KEY_ACCESS_FAILED'],
|
||||
},
|
||||
{
|
||||
output:
|
||||
'seckeychainitemimport: unknown format in import\nSecKeychainItemImport: Some other error',
|
||||
codes: ['PKCS12_UNKNOWN_FORMAT', 'CERTIFICATE_IMPORT_FAILED'],
|
||||
},
|
||||
])('reports only the applicable codes: $codes', async ({ output, codes }) => {
|
||||
if (outcome === 'success') {
|
||||
runFastlaneMock.mockResolvedValueOnce(result(output));
|
||||
await keychain.importCertificate(options);
|
||||
} else {
|
||||
runFastlaneMock.mockRejectedValueOnce(
|
||||
Object.assign(new Error('import failed'), {
|
||||
stderr: output,
|
||||
})
|
||||
);
|
||||
const failure = await keychain.importCertificate(options).catch(error => error);
|
||||
expect(failure.metadata.diagnosticCodes).toEqual(codes);
|
||||
}
|
||||
expect(logError.mock.calls.map(([fields]) => fields.diagnosticCode)).toEqual(codes);
|
||||
});
|
||||
});
|
||||
|
||||
it('never forwards output, command arguments, private key data, or the original error', async () => {
|
||||
const secret = 'very-secret-password';
|
||||
const error = Object.assign(new Error(`fastlane certificate_password:${secret}`), {
|
||||
stdout: `private key data ${secret}`,
|
||||
stderr: `security: SecKeychainItemSetAccessWithPassword: ${secret}`,
|
||||
});
|
||||
runFastlaneMock.mockRejectedValueOnce(error);
|
||||
const failure = await keychain.importCertificate(options).catch(error => error);
|
||||
expect(failure).toBeInstanceOf(errors.UserError);
|
||||
expect(failure.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
|
||||
expect(failure.metadata).toEqual({ diagnosticCodes: ['PRIVATE_KEY_ACCESS_FAILED'] });
|
||||
expect(failure.toExternalExpoError().message).toContain(
|
||||
'Fastlane could not complete certificate import'
|
||||
);
|
||||
expect(failure.cause).toBeUndefined();
|
||||
expect(JSON.stringify([failure, logError.mock.calls])).not.toContain(secret);
|
||||
expect(logError).toHaveBeenCalledWith(
|
||||
{ diagnosticCode: 'PRIVATE_KEY_ACCESS_FAILED' },
|
||||
expect.stringContaining('private key')
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['ENOENT', 'EACCES'])(
|
||||
'classifies a %s process launch failure as a system error',
|
||||
async code => {
|
||||
runFastlaneMock.mockRejectedValueOnce(Object.assign(new Error('secret command'), { code }));
|
||||
const error = await keychain.importCertificate(options).catch(error => error);
|
||||
expect(error).toBeInstanceOf(errors.SystemError);
|
||||
expect(error.trackingCode).toBe('IOS_CERTIFICATE_IMPORT_PROCESS_START_FAILED');
|
||||
expect(error.cause).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
null,
|
||||
new Error('failure'),
|
||||
{ stderr: 'SecKeychainItemImport: Unknown format in import' },
|
||||
Object.assign(new Error('failure'), { stdout: 123, stderr: {} }),
|
||||
])('handles rejected values without usable process output: %p', async value => {
|
||||
runFastlaneMock.mockRejectedValueOnce(value);
|
||||
await expect(keychain.importCertificate(options)).rejects.toThrow(
|
||||
'Fastlane could not complete certificate import'
|
||||
);
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -18,7 +18,7 @@ jest.unmock('fs');
|
||||
describe('ProvisioningProfile class', () => {
|
||||
describe('verifyCertificate method', () => {
|
||||
let ctx: BuildContext<Ios.Job>;
|
||||
let keychain: Keychain<Ios.Job>;
|
||||
let keychain: Keychain;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = new BuildContext({ projectRootDirectory: '.' } as Ios.Job, {
|
||||
@@ -30,12 +30,12 @@ describe('ProvisioningProfile class', () => {
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
keychain = new Keychain(ctx);
|
||||
await keychain.create();
|
||||
keychain = new Keychain();
|
||||
await keychain.create({ logger: mockLogger });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await keychain.destroy();
|
||||
await keychain.destroy({ logger: mockLogger });
|
||||
});
|
||||
|
||||
it("shouldn't throw any error if the provisioning profile and distribution certificate match", async () => {
|
||||
|
||||
@@ -1,19 +1,53 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { errors } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import spawn, { SpawnResult } from '@expo/turtle-spawn';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { BuildContext } from '../../context';
|
||||
import { runFastlane } from '../fastlane';
|
||||
|
||||
export default class Keychain<TJob extends Ios.Job> {
|
||||
// Do not log raw fastlane output or spawn errors: they can contain passwords,
|
||||
// command arguments, and private key attributes from set-key-partition-list.
|
||||
const IMPORT_CERTIFICATE_DIAGNOSTICS = [
|
||||
{
|
||||
code: 'PKCS12_MAC_VERIFICATION_FAILED',
|
||||
pattern: /SecKeychainItemImport: MAC verification failed during PKCS12 import/i,
|
||||
message:
|
||||
'macOS could not verify the PKCS#12 MAC. Check the certificate password and PKCS#12 export format; this error does not prove that the password is wrong.',
|
||||
},
|
||||
{
|
||||
code: 'PKCS12_UNKNOWN_FORMAT',
|
||||
pattern: /SecKeychainItemImport: Unknown format in import/i,
|
||||
message: 'macOS did not recognize the certificate import format.',
|
||||
},
|
||||
{
|
||||
code: 'CERTIFICATE_IMPORT_FAILED',
|
||||
pattern:
|
||||
/SecKeychainItemImport:(?! MAC verification failed during PKCS12 import| Unknown format in import)/i,
|
||||
message: 'macOS reported a certificate import error (SecKeychainItemImport).',
|
||||
},
|
||||
{
|
||||
code: 'PRIVATE_KEY_ACCESS_FAILED',
|
||||
pattern: /SecKeychainItemSetAccessWithPassword:/,
|
||||
message:
|
||||
'macOS could not set access to the imported private key (SecKeychainItemSetAccessWithPassword).',
|
||||
},
|
||||
{
|
||||
code: 'KEYCHAIN_ITEM_LOOKUP_FAILED',
|
||||
pattern: /SecItemCopyMatching:/,
|
||||
message:
|
||||
'macOS could not find an item while configuring private key access (SecItemCopyMatching).',
|
||||
},
|
||||
];
|
||||
|
||||
export default class Keychain {
|
||||
private readonly keychainPath: string;
|
||||
private readonly keychainPassword: string;
|
||||
private created = false;
|
||||
private destroyed = false;
|
||||
|
||||
constructor(private readonly ctx: BuildContext<TJob>) {
|
||||
constructor() {
|
||||
this.keychainPath = path.join(os.tmpdir(), `eas-build-${uuid()}.keychain`);
|
||||
this.keychainPassword = uuid();
|
||||
}
|
||||
@@ -25,8 +59,8 @@ export default class Keychain<TJob extends Ios.Job> {
|
||||
};
|
||||
}
|
||||
|
||||
public async create(): Promise<void> {
|
||||
this.ctx.logger.debug(`Creating keychain - ${this.keychainPath}`);
|
||||
public async create({ logger }: { logger: bunyan }): Promise<void> {
|
||||
logger.debug(`Creating keychain - ${this.keychainPath}`);
|
||||
await runFastlane([
|
||||
'run',
|
||||
'create_keychain',
|
||||
@@ -38,23 +72,77 @@ export default class Keychain<TJob extends Ios.Job> {
|
||||
this.created = true;
|
||||
}
|
||||
|
||||
public async importCertificate(certPath: string, certPassword: string): Promise<void> {
|
||||
public async importCertificate({
|
||||
logger,
|
||||
certPath,
|
||||
certPassword,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
certPath: string;
|
||||
certPassword: string;
|
||||
}): Promise<void> {
|
||||
if (!this.created) {
|
||||
throw new Error('You must create a keychain first.');
|
||||
}
|
||||
|
||||
this.ctx.logger.debug(`Importing certificate ${certPath} into keychain ${this.keychainPath}`);
|
||||
await runFastlane([
|
||||
'run',
|
||||
'import_certificate',
|
||||
`certificate_path:${certPath}`,
|
||||
`certificate_password:${certPassword}`,
|
||||
`keychain_path:${this.keychainPath}`,
|
||||
`keychain_password:${this.keychainPassword}`,
|
||||
]);
|
||||
logger.debug(`Importing certificate ${certPath} into keychain ${this.keychainPath}`);
|
||||
try {
|
||||
const result = await runFastlane([
|
||||
'run',
|
||||
'import_certificate',
|
||||
`certificate_path:${certPath}`,
|
||||
`certificate_password:${certPassword}`,
|
||||
`keychain_path:${this.keychainPath}`,
|
||||
`keychain_password:${this.keychainPassword}`,
|
||||
]);
|
||||
const output = [result.stdout, result.stderr].join('\n');
|
||||
for (const diagnostic of IMPORT_CERTIFICATE_DIAGNOSTICS) {
|
||||
if (diagnostic.pattern.test(output)) {
|
||||
logger.error({ diagnosticCode: diagnostic.code }, diagnostic.message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const processError =
|
||||
error instanceof Error
|
||||
? (error as Error &
|
||||
Partial<Pick<SpawnResult, 'stdout' | 'stderr'> & NodeJS.ErrnoException>)
|
||||
: undefined;
|
||||
const output = [processError?.stdout, processError?.stderr]
|
||||
.filter(value => typeof value === 'string')
|
||||
.join('\n');
|
||||
const diagnosticCodes: string[] = [];
|
||||
for (const diagnostic of IMPORT_CERTIFICATE_DIAGNOSTICS) {
|
||||
if (diagnostic.pattern.test(output)) {
|
||||
diagnosticCodes.push(diagnostic.code);
|
||||
logger.error({ diagnosticCode: diagnostic.code }, diagnostic.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Never attach the original error: its message includes passwords.
|
||||
|
||||
if (processError?.code === 'ENOENT' || processError?.code === 'EACCES') {
|
||||
throw new errors.SystemError('Fastlane could not be started to import the certificate.', {
|
||||
trackingCode: 'IOS_CERTIFICATE_IMPORT_PROCESS_START_FAILED',
|
||||
});
|
||||
}
|
||||
throw new errors.UserError(
|
||||
errors.ErrorCode.UNKNOWN_ERROR,
|
||||
'Fastlane could not complete certificate import. Check the certificate import diagnostics in the Prepare credentials logs.',
|
||||
{
|
||||
trackingCode: 'IOS_CERTIFICATE_IMPORT_FAILED',
|
||||
metadata: { diagnosticCodes },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async ensureCertificateImported(teamId: string, fingerprint: string): Promise<void> {
|
||||
public async ensureCertificateImported({
|
||||
teamId,
|
||||
fingerprint,
|
||||
}: {
|
||||
teamId: string;
|
||||
fingerprint: string;
|
||||
}): Promise<void> {
|
||||
const identities = await this.findIdentitiesByTeamId(teamId);
|
||||
if (!identities.includes(fingerprint)) {
|
||||
throw new Error(
|
||||
@@ -63,27 +151,33 @@ export default class Keychain<TJob extends Ios.Job> {
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(keychainPath?: string): Promise<void> {
|
||||
public async destroy({
|
||||
logger,
|
||||
keychainPath,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
keychainPath?: string;
|
||||
}): Promise<void> {
|
||||
if (!keychainPath && !this.created) {
|
||||
this.ctx.logger.warn("There is nothing to destroy, a keychain hasn't been created yet.");
|
||||
logger.warn("There is nothing to destroy, a keychain hasn't been created yet.");
|
||||
return;
|
||||
}
|
||||
if (this.destroyed) {
|
||||
this.ctx.logger.warn('The keychain has been already destroyed');
|
||||
logger.warn('The keychain has been already destroyed');
|
||||
return;
|
||||
}
|
||||
const keychainToDeletePath = keychainPath ?? this.keychainPath;
|
||||
this.ctx.logger.info(`Destroying keychain - ${keychainToDeletePath}`);
|
||||
logger.info(`Destroying keychain - ${keychainToDeletePath}`);
|
||||
try {
|
||||
await runFastlane(['run', 'delete_keychain', `keychain_path:${keychainToDeletePath}`]);
|
||||
this.destroyed = true;
|
||||
} catch (err) {
|
||||
this.ctx.logger.error({ err }, 'Failed to delete the keychain\n');
|
||||
logger.error({ err }, 'Failed to delete the keychain\n');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
public async cleanUpKeychains(): Promise<void> {
|
||||
public async cleanUpKeychains({ logger }: { logger: bunyan }): Promise<void> {
|
||||
const { stdout } = await spawn('security', ['list-keychains'], { stdio: 'pipe' });
|
||||
const keychainList = (/"(.*)"/g.exec(stdout) ?? ([] as string[])).map(i =>
|
||||
i.slice(1, i.length - 1)
|
||||
@@ -92,7 +186,7 @@ export default class Keychain<TJob extends Ios.Job> {
|
||||
/eas-build-[\w-]+\.keychain$/.exec(keychain)
|
||||
);
|
||||
for (const turtleKeychainPath of turtleKeychainList) {
|
||||
await this.destroy(turtleKeychainPath);
|
||||
await this.destroy({ logger, keychainPath: turtleKeychainPath });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface Credentials {
|
||||
export type TargetProvisioningProfiles = Record<string, ProvisioningProfileData>;
|
||||
|
||||
export default class IosCredentialsManager<TJob extends Ios.Job> {
|
||||
private keychain?: Keychain<TJob>;
|
||||
private keychain?: Keychain;
|
||||
private readonly provisioningProfiles: ProvisioningProfile<TJob>[] = [];
|
||||
private cleanedUp = false;
|
||||
|
||||
@@ -48,8 +48,8 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
|
||||
this.ctx.logger.info('Preparing credentials');
|
||||
|
||||
this.ctx.logger.info('Creating keychain');
|
||||
this.keychain = new Keychain(this.ctx);
|
||||
await this.keychain.create();
|
||||
this.keychain = new Keychain();
|
||||
await this.keychain.create({ logger: this.ctx.logger });
|
||||
|
||||
const targets = Object.keys(buildCredentials);
|
||||
const targetProvisioningProfiles: TargetProvisioningProfiles = {};
|
||||
@@ -82,7 +82,7 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
|
||||
}
|
||||
|
||||
if (this.keychain) {
|
||||
await this.keychain.destroy();
|
||||
await this.keychain.destroy({ logger: this.ctx.logger });
|
||||
}
|
||||
if (this.provisioningProfiles) {
|
||||
for (const provisioningProfile of this.provisioningProfiles) {
|
||||
@@ -120,10 +120,11 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
|
||||
);
|
||||
|
||||
this.ctx.logger.info('Importing distribution certificate into the keychain');
|
||||
await this.keychain.importCertificate(
|
||||
distCertPath,
|
||||
targetCredentials.distributionCertificate.password
|
||||
);
|
||||
await this.keychain.importCertificate({
|
||||
logger: this.ctx.logger,
|
||||
certPath: distCertPath,
|
||||
certPassword: targetCredentials.distributionCertificate.password,
|
||||
});
|
||||
|
||||
this.ctx.logger.info('Initializing provisioning profile');
|
||||
const provisioningProfile = new ProvisioningProfile(
|
||||
@@ -138,10 +139,10 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
|
||||
this.ctx.logger.info(
|
||||
'Validating whether the distribution certificate has been imported successfully'
|
||||
);
|
||||
await this.keychain.ensureCertificateImported(
|
||||
provisioningProfile.data.teamId,
|
||||
certificateFingerprint
|
||||
);
|
||||
await this.keychain.ensureCertificateImported({
|
||||
teamId: provisioningProfile.data.teamId,
|
||||
fingerprint: certificateFingerprint,
|
||||
});
|
||||
|
||||
this.ctx.logger.info(
|
||||
'Verifying whether the distribution certificate and provisioning profile match'
|
||||
|
||||
@@ -15,10 +15,10 @@ export function resolveScheme(ctx: BuildContext<Ios.Job>): string {
|
||||
return schemes[0];
|
||||
}
|
||||
|
||||
export function resolveArtifactPath(ctx: BuildContext<Ios.Job>): string {
|
||||
if (ctx.job.applicationArchivePath) {
|
||||
return ctx.job.applicationArchivePath;
|
||||
} else if (ctx.job.simulator) {
|
||||
export function resolveArtifactPath(job: Ios.Job): string {
|
||||
if (job.applicationArchivePath) {
|
||||
return job.applicationArchivePath;
|
||||
} else if (job.simulator) {
|
||||
return 'ios/build/Build/Products/*simulator/*.app';
|
||||
} else {
|
||||
return 'ios/build/*.ipa';
|
||||
|
||||
@@ -10,6 +10,7 @@ import { configureIosVersionFunction } from './functions/configureIosVersion';
|
||||
import { createSubmissionEntityFunction } from './functions/createSubmissionEntity';
|
||||
import { createDownloadArtifactFunction } from './functions/downloadArtifact';
|
||||
import { createDownloadBuildFunction } from './functions/downloadBuild';
|
||||
import { createDownloadSubmissionArchiveFunction } from './functions/downloadSubmissionArchive';
|
||||
import { createEasDeployBuildFunction } from './functions/deploy';
|
||||
import { createEasExportBuildFunction } from './functions/export';
|
||||
import { eagerBundleBuildFunction } from './functions/eagerBundle';
|
||||
@@ -19,6 +20,7 @@ import { generateGymfileFromTemplateFunction } from './functions/generateGymfile
|
||||
import { createGetCredentialsForBuildTriggeredByGithubIntegration } from './functions/getCredentialsForBuildTriggeredByGitHubIntegration';
|
||||
import { injectAndroidCredentialsFunction } from './functions/injectAndroidCredentials';
|
||||
import { createInstallMaestroBuildFunction } from './functions/installMaestro';
|
||||
import { createInstallMitmproxyBuildFunction } from './functions/installMitmproxy';
|
||||
import { createInstallBuildFunction } from './functions/installBuild';
|
||||
import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules';
|
||||
import { createInstallPodsBuildFunction } from './functions/installPods';
|
||||
@@ -26,6 +28,7 @@ import { createLaunchApplicationFunction } from './functions/launchApplication';
|
||||
import { createPrebuildBuildFunction } from './functions/prebuild';
|
||||
import { createReadAppConfigBuildFunction } from './functions/readAppConfig';
|
||||
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
|
||||
import { createReadAndroidAppInfoBuildFunction } from './functions/readAndroidAppInfo';
|
||||
import { createReadPackageJsonBuildFunction } from './functions/readPackageJson';
|
||||
import { createRepackBuildFunction } from './functions/repack';
|
||||
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
|
||||
@@ -42,9 +45,10 @@ import { runGradleFunction } from './functions/runGradle';
|
||||
import { createMaestroTestsBuildFunction } from './functions/maestroTests';
|
||||
import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
|
||||
import { createRolloutPosthogFlagFunction } from './functions/rolloutPosthogFlag';
|
||||
import { createRunAgentBuildFunction } from './functions/runAgent';
|
||||
import { createSaveCacheFunction } from './functions/saveCache';
|
||||
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
|
||||
import { createStartAgentDeviceSessionBuildFunction } from './functions/startAgentDeviceSession';
|
||||
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
|
||||
import { createStartArgentRemoteSessionBuildFunction } from './functions/startArgentRemoteSession';
|
||||
import { createStartAppiumRemoteSessionBuildFunction } from './functions/startAppiumRemoteSession';
|
||||
@@ -54,11 +58,15 @@ import { createStartIosSimulatorRecordingsBuildFunction } from './functions/star
|
||||
import { createStartLocalEgressBuildFunction } from './functions/startLocalEgress';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/startWebPreviewRemoteSession';
|
||||
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
|
||||
import { createStartServeSimLogsBuildFunction } from './functions/startServeSimLogs';
|
||||
import { createCollectServeSimLogsBuildFunction } from './functions/collectServeSimLogs';
|
||||
import { createStartSandboxBuildFunction } from './functions/startSandbox';
|
||||
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
|
||||
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
|
||||
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings';
|
||||
import { createUploadEmbeddedBundleBuildFunction } from './functions/uploadEmbeddedBundle';
|
||||
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
|
||||
import { createUpdateTestFlightMetadataBuildFunction } from './functions/updateTestFlightMetadata';
|
||||
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
|
||||
import { createWaitForPosthogMetricFunction } from './functions/waitForPosthogMetric';
|
||||
import { createUploadPosthogSourcemapsFunction } from './functions/uploadPosthogSourcemaps';
|
||||
@@ -77,7 +85,9 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createInstallNodeModulesBuildFunction(),
|
||||
createPrebuildBuildFunction(),
|
||||
createReadIpaInfoBuildFunction(),
|
||||
createReadAndroidAppInfoBuildFunction(),
|
||||
createDownloadBuildFunction(ctx),
|
||||
createDownloadSubmissionArchiveFunction(ctx),
|
||||
createInstallBuildFunction(),
|
||||
createLaunchApplicationFunction(),
|
||||
createEasExportBuildFunction(),
|
||||
@@ -99,7 +109,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
generateGymfileFromTemplateFunction(),
|
||||
runFastlaneFunction(),
|
||||
parseXcactivitylogFunction(),
|
||||
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
|
||||
createStartAgentDeviceSessionBuildFunction(ctx),
|
||||
createStartArgentRemoteSessionBuildFunction(ctx),
|
||||
createStartAppiumRemoteSessionBuildFunction(ctx),
|
||||
createStartAndroidEmulatorBuildFunction(),
|
||||
@@ -111,9 +121,13 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
|
||||
createStartWebPreviewRemoteSessionBuildFunction(ctx),
|
||||
createStartServeSimMetricsBuildFunction(),
|
||||
createStartServeSimLogsBuildFunction(),
|
||||
createCollectServeSimLogsBuildFunction(ctx),
|
||||
createStartSandboxBuildFunction(ctx),
|
||||
createRunAgentBuildFunction(ctx),
|
||||
createCollectServeSimMetricsBuildFunction(ctx),
|
||||
createInstallMaestroBuildFunction(),
|
||||
createInstallMitmproxyBuildFunction(),
|
||||
|
||||
createInstallPodsBuildFunction(),
|
||||
createSendSlackMessageFunction(),
|
||||
@@ -128,6 +142,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
|
||||
createSubmissionEntityFunction(),
|
||||
createUploadToAscBuildFunction(),
|
||||
createUpdateTestFlightMetadataBuildFunction(),
|
||||
|
||||
createReportMaestroTestResultsFunction(ctx),
|
||||
createMaestroTestsBuildFunction(ctx),
|
||||
@@ -137,6 +152,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
functions.push(
|
||||
...[
|
||||
createFindAndUploadBuildArtifactsBuildFunction(ctx),
|
||||
createUploadEmbeddedBundleBuildFunction(ctx),
|
||||
createResolveBuildConfigBuildFunction(ctx),
|
||||
createGetCredentialsForBuildTriggeredByGithubIntegration(ctx),
|
||||
]
|
||||
|
||||
@@ -219,4 +219,71 @@ describe(createEasBuildBuildFunctionGroup, () => {
|
||||
'Build function group is not supported in generic jobs.'
|
||||
);
|
||||
});
|
||||
|
||||
describe('embedded bundle upload', () => {
|
||||
function getStepNames(
|
||||
options: Parameters<typeof createMockBuildToolsContext>[0],
|
||||
{ sdkVersion, env = {} }: { sdkVersion?: string; env?: Record<string, string> }
|
||||
): string[] {
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(createMockBuildToolsContext(options));
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
staticContextContent: { metadata: sdkVersion ? { sdkVersion } : null },
|
||||
});
|
||||
globalCtx.updateEnv(env);
|
||||
return functionGroup
|
||||
.createBuildStepsFromFunctionGroupCall(globalCtx)
|
||||
.map(step => step.displayName);
|
||||
}
|
||||
|
||||
it.each([
|
||||
['Android', { platform: Platform.ANDROID }],
|
||||
['Android with credentials', { platform: Platform.ANDROID, buildCredentials: { test: {} } }],
|
||||
['iOS with credentials', { platform: Platform.IOS, buildCredentials: { test: {} } }],
|
||||
])('uploads the embedded bundle after the build artifacts on SDK 58 (%s)', (_, options) => {
|
||||
const stepNames = getStepNames(options, { sdkVersion: '58.0.0' });
|
||||
|
||||
expect(stepNames.indexOf('Upload embedded bundle')).toBe(
|
||||
stepNames.indexOf('Find and upload build artifacts') + 1
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Android', { platform: Platform.ANDROID }],
|
||||
['Android with credentials', { platform: Platform.ANDROID, buildCredentials: { test: {} } }],
|
||||
['iOS with credentials', { platform: Platform.IOS, buildCredentials: { test: {} } }],
|
||||
])('passes ignore_error to the embedded bundle step (%s)', (_, options) => {
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(createMockBuildToolsContext(options));
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
staticContextContent: { metadata: { sdkVersion: '58.0.0' } },
|
||||
});
|
||||
|
||||
const uploadStep = functionGroup
|
||||
.createBuildStepsFromFunctionGroupCall(globalCtx)
|
||||
.find(step => step.displayName === 'Upload embedded bundle');
|
||||
|
||||
expect(uploadStep).toBeDefined();
|
||||
expect(uploadStep!.inputs?.find(input => input.id === 'ignore_error')?.rawValue).toBe(true);
|
||||
});
|
||||
|
||||
it('does not upload the embedded bundle on SDK 57 without the opt-in', () => {
|
||||
expect(getStepNames({}, { sdkVersion: '57.0.0' })).not.toContain('Upload embedded bundle');
|
||||
});
|
||||
|
||||
it('uploads the embedded bundle on SDK 57 with the opt-in', () => {
|
||||
expect(
|
||||
getStepNames(
|
||||
{},
|
||||
{ sdkVersion: '57.0.0', env: { EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1' } }
|
||||
)
|
||||
).toContain('Upload embedded bundle');
|
||||
});
|
||||
|
||||
it('does not upload the embedded bundle for iOS simulator builds', () => {
|
||||
expect(
|
||||
getStepNames({ platform: Platform.IOS, simulator: true }, { sdkVersion: '58.0.0' })
|
||||
).not.toContain('Upload embedded bundle');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import path from 'path';
|
||||
|
||||
import { shouldUseEagerBundle } from '../../common/eagerBundle';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { shouldUploadEmbeddedBundle } from '../../utils/expoUpdatesEmbedded';
|
||||
import { calculateEASUpdateRuntimeVersionFunction } from '../functions/calculateEASUpdateRuntimeVersion';
|
||||
import { createCheckoutBuildFunction } from '../functions/checkout';
|
||||
import { configureAndroidVersionFunction } from '../functions/configureAndroidVersion';
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
import { runFastlaneFunction } from '../functions/runFastlane';
|
||||
import { runGradleFunction } from '../functions/runGradle';
|
||||
import { createSaveBuildCacheFunction } from '../functions/saveBuildCache';
|
||||
import { createUploadEmbeddedBundleBuildFunction } from '../functions/uploadEmbeddedBundle';
|
||||
import { createSetUpNpmrcBuildFunction } from '../functions/useNpmToken';
|
||||
|
||||
interface HelperFunctionsInput {
|
||||
@@ -303,6 +305,19 @@ function createStepsForIosBuildWithCredentials({
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
...(shouldUploadEmbeddedBundle({
|
||||
env: globalCtx.env,
|
||||
metadata: globalCtx.staticContext.metadata,
|
||||
})
|
||||
? [
|
||||
createUploadEmbeddedBundleBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: { ignore_error: true },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
@@ -390,6 +405,19 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
...(shouldUploadEmbeddedBundle({
|
||||
env: globalCtx.env,
|
||||
metadata: globalCtx.staticContext.metadata,
|
||||
})
|
||||
? [
|
||||
createUploadEmbeddedBundleBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: { ignore_error: true },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
@@ -483,6 +511,19 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
...(shouldUploadEmbeddedBundle({
|
||||
env: globalCtx.env,
|
||||
metadata: globalCtx.staticContext.metadata,
|
||||
})
|
||||
? [
|
||||
createUploadEmbeddedBundleBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: { ignore_error: true },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+47
@@ -0,0 +1,47 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { readAndroidArtifactInfoAsync } from '../../utils/android/appArtifact';
|
||||
import { createReadAndroidAppInfoBuildFunction } from '../readAndroidAppInfo';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
jest.unmock('@expo/spawn-async');
|
||||
|
||||
const fixturesDirectory = path.join(__dirname, 'fixtures/android-app-info');
|
||||
|
||||
it.each(['apk', 'aab'])('reads a real %s and publishes build step outputs', async type => {
|
||||
const step = createReadAndroidAppInfoBuildFunction().createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({
|
||||
projectTargetDirectory: fixturesDirectory,
|
||||
staticContextContent: { job: {} },
|
||||
}),
|
||||
{ callInputs: { artifact_path: `app.${type}` } }
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(step.outputById.artifact_type.value).toBe(type);
|
||||
expect(step.outputById.package_name.value).toBe('com.example.app');
|
||||
});
|
||||
|
||||
it('reports an invalid ZIP as a user error', async () => {
|
||||
await expect(readAndroidArtifactInfoAsync(__filename)).rejects.toMatchObject({
|
||||
constructor: UserError,
|
||||
errorCode: 'EAS_READ_ANDROID_APP_INFO_INVALID_ARTIFACT',
|
||||
cause: expect.any(Error),
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['apk', 'aab'])('retains the native tool error for an invalid %s manifest', async type => {
|
||||
await expect(
|
||||
readAndroidArtifactInfoAsync(path.join(fixturesDirectory, `invalid-manifest-${type}.zip`))
|
||||
).rejects.toMatchObject({
|
||||
constructor: UserError,
|
||||
errorCode: 'EAS_READ_ANDROID_APP_INFO_INVALID_MANIFEST',
|
||||
cause: expect.any(Error),
|
||||
});
|
||||
});
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
|
||||
import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
|
||||
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils');
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/agentDeviceEvents');
|
||||
jest.mock('../../utils/appiumEvents');
|
||||
jest.mock('../../utils/agentDeviceArtifacts');
|
||||
// The agent-device session step boots the emulator itself; this test covers the session host.
|
||||
jest.mock('../startAndroidEmulator', () => ({
|
||||
startAndroidEmulatorAsync: jest.fn().mockResolvedValue({
|
||||
serialId: 'emulator-5554',
|
||||
emulatorPromise: Promise.resolve(),
|
||||
shouldAdjustAnimationScale: true,
|
||||
}),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
spawnDetached: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
waitForFileAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const finishHost = jest.fn();
|
||||
const closePreview = jest.fn();
|
||||
const stopTool = jest.fn();
|
||||
const stopTunnel = jest.fn();
|
||||
const stopEvents = jest.fn();
|
||||
const openPreview = jest.fn();
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// The daemon package is external. No installation or daemon process runs in these tests.
|
||||
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
// Only the emulator logcat folder: the Appium step writes into its own temp folder.
|
||||
const mkdtemp = fs.promises.mkdtemp.bind(fs.promises);
|
||||
jest
|
||||
.spyOn(fs.promises, 'mkdtemp')
|
||||
.mockImplementation(async prefix =>
|
||||
String(prefix).includes('eas-android-emulator-logcat-')
|
||||
? '/tmp/eas-android-emulator-logcat-test'
|
||||
: mkdtemp(prefix)
|
||||
);
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '{}' } as never);
|
||||
jest
|
||||
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
|
||||
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' } as never]);
|
||||
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
|
||||
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 4567, token: 'daemon-token' });
|
||||
for (const stop of [finishHost, closePreview, stopTool, stopTunnel, stopEvents]) {
|
||||
stop.mockResolvedValue(undefined);
|
||||
}
|
||||
openPreview.mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://preview.example.test',
|
||||
closeAsync: closePreview,
|
||||
});
|
||||
jest
|
||||
.mocked(startDeviceSessionHostAsync)
|
||||
.mockResolvedValue({ openPreviewAsync: openPreview, finishAsync: finishHost });
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: undefined,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopTool,
|
||||
});
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
|
||||
url: 'https://tool.example.test',
|
||||
subdomainId: 'tool-id',
|
||||
stopAsync: stopTunnel,
|
||||
});
|
||||
const events = { stopAsync: stopEvents, getLastEventObservedAt: () => undefined };
|
||||
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue(events);
|
||||
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue(events);
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe.each([
|
||||
['Appium', createStartAppiumRemoteSessionBuildFunction],
|
||||
['Agent Device', createStartAgentDeviceSessionBuildFunction],
|
||||
] as const)('%s host ownership', (name, createFunction) => {
|
||||
async function runAsync() {
|
||||
const fn = createFunction({} as CustomBuildContext);
|
||||
await fn.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
// Inputs a step does not get default to undefined, like in a real step call.
|
||||
inputs: new Proxy({} as Record<string, { value: unknown }>, {
|
||||
get: (target, id: string) => target[id] ?? { value: undefined },
|
||||
}),
|
||||
outputs: {},
|
||||
env: {
|
||||
DEVICE_RUN_SESSION_ID: 'session-id',
|
||||
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
|
||||
NGROK_AUTHTOKEN: 'token',
|
||||
},
|
||||
} as never
|
||||
);
|
||||
}
|
||||
|
||||
it('holds the host until session completion', async () => {
|
||||
await runAsync();
|
||||
expect(openPreview).toHaveBeenCalledWith({ baseDomain: 'example.test' });
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewApiUrl: 'https://preview.example.test',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
expect(closePreview).not.toHaveBeenCalled();
|
||||
expect(finishHost.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(stopTool).toHaveBeenCalledTimes(1);
|
||||
expect(stopEvents).toHaveBeenCalledTimes(1);
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops automation while recording finalization or upload is pending', async () => {
|
||||
let release!: () => void;
|
||||
let stopped!: () => void;
|
||||
const pendingFinish = new Promise<void>(resolve => {
|
||||
release = resolve;
|
||||
});
|
||||
const toolStopped = new Promise<void>(resolve => {
|
||||
stopped = resolve;
|
||||
});
|
||||
finishHost.mockReturnValueOnce(pendingFinish);
|
||||
stopTool.mockImplementationOnce(async () => {
|
||||
stopped();
|
||||
});
|
||||
let completed = false;
|
||||
const running = runAsync().then(() => {
|
||||
completed = true;
|
||||
});
|
||||
try {
|
||||
await toolStopped;
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
expect(completed).toBe(false);
|
||||
} finally {
|
||||
release();
|
||||
await running;
|
||||
}
|
||||
});
|
||||
|
||||
it('finalizes recording and stops automation while the tunnel close is pending', async () => {
|
||||
let release!: () => void;
|
||||
let finished!: () => void;
|
||||
const pendingClose = new Promise<void>(resolve => {
|
||||
release = resolve;
|
||||
});
|
||||
const hostFinished = new Promise<void>(resolve => {
|
||||
finished = resolve;
|
||||
});
|
||||
const toolStopped = new Promise<void>(resolve => {
|
||||
stopTool.mockImplementationOnce(async () => resolve());
|
||||
});
|
||||
stopTunnel.mockReturnValueOnce(pendingClose);
|
||||
finishHost.mockImplementationOnce(async () => {
|
||||
finished();
|
||||
});
|
||||
const running = runAsync();
|
||||
try {
|
||||
// Both settle while the tunnel close is still held open.
|
||||
await Promise.all([hostFinished, toolStopped]);
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
release();
|
||||
await running;
|
||||
}
|
||||
});
|
||||
|
||||
it('still stops the automation process when event collection stop rejects', async () => {
|
||||
stopEvents.mockRejectedValueOnce(new Error('collector failed'));
|
||||
// Agent Device wraps its collector stop in a catch-all helper; Appium does not.
|
||||
const outcome = await runAsync().then(
|
||||
() => 'resolved',
|
||||
(err: Error) => err.message
|
||||
);
|
||||
expect(outcome).toBe(name === 'Appium' ? 'collector failed' : 'resolved');
|
||||
expect(stopTool).toHaveBeenCalledTimes(1);
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('still stops automation when recording finalization rejects', async () => {
|
||||
finishHost.mockRejectedValueOnce(new Error('recording cleanup failed'));
|
||||
await expect(runAsync()).rejects.toThrow('recording cleanup failed');
|
||||
expect(stopTool).toHaveBeenCalledTimes(1);
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['preview', 'config', 'wait'])('finishes the host after %s fails', async phase => {
|
||||
const error = new Error(`${phase} failed`);
|
||||
// A teardown failure must not replace the error that ended the session.
|
||||
finishHost.mockRejectedValueOnce(new Error('recording cleanup failed'));
|
||||
if (phase === 'preview') {
|
||||
openPreview.mockRejectedValueOnce(error);
|
||||
} else if (phase === 'config') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
|
||||
} else {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
|
||||
}
|
||||
await expect(runAsync()).rejects.toBe(error);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
// An agent-device preview failure aborts the daemon task, which then may not start its
|
||||
// daemon or tunnel. Everything that started must be stopped.
|
||||
const abortsDaemonTask = name === 'Agent Device' && phase === 'preview';
|
||||
expect(stopTool).toHaveBeenCalledTimes(
|
||||
abortsDaemonTask ? jest.mocked(spawnDetached).mock.calls.length : 1
|
||||
);
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(
|
||||
abortsDaemonTask ? jest.mocked(startNgrokTunnelAsync).mock.calls.length : 1
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.objectContaining({ message: 'recording cleanup failed' }) },
|
||||
'Could not stop the session host during remote session teardown.'
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not stop the session host after the remote session failed',
|
||||
expect.objectContaining({ message: 'recording cleanup failed' }),
|
||||
{ level: 'warning' }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Appium ffmpeg setup', () => {
|
||||
async function runAppiumAsync(): Promise<void> {
|
||||
const fn = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await fn.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: new Proxy({} as Record<string, { value: unknown }>, {
|
||||
get: (target, id: string) => target[id] ?? { value: undefined },
|
||||
}),
|
||||
outputs: {},
|
||||
env: {
|
||||
DEVICE_RUN_SESSION_ID: 'session-id',
|
||||
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
|
||||
NGROK_AUTHTOKEN: 'token',
|
||||
},
|
||||
} as never
|
||||
);
|
||||
}
|
||||
|
||||
function holdFfmpegInstall({ appiumError }: { appiumError?: Error } = {}) {
|
||||
let finish!: (outcome: 'installed' | 'failed') => void;
|
||||
const install = new Promise((resolve, reject) => {
|
||||
finish = outcome =>
|
||||
outcome === 'installed' ? resolve({}) : reject(new Error('apt-get failed'));
|
||||
});
|
||||
jest.mocked(spawn).mockImplementation(((command: string, args: string[]) => {
|
||||
if (command === 'ffmpeg') {
|
||||
return Promise.reject(new Error('ffmpeg missing'));
|
||||
}
|
||||
if (args.includes('install') && args.includes('ffmpeg')) {
|
||||
return install;
|
||||
}
|
||||
return command === 'npm' && appiumError
|
||||
? Promise.reject(appiumError)
|
||||
: Promise.resolve({ stdout: '{}' });
|
||||
}) as never);
|
||||
return finish;
|
||||
}
|
||||
|
||||
it.each(['installed', 'failed'] as const)(
|
||||
'starts Appium after the ffmpeg install settles (%s)',
|
||||
async outcome => {
|
||||
const finishFfmpegInstall = holdFfmpegInstall();
|
||||
|
||||
const run = runAppiumAsync();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
|
||||
finishFfmpegInstall(outcome);
|
||||
await run;
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('waits for the ffmpeg install before failing on an Appium install error', async () => {
|
||||
const appiumError = new Error('npm failed');
|
||||
const finishFfmpegInstall = holdFfmpegInstall({ appiumError });
|
||||
|
||||
let settled = false;
|
||||
const run = runAppiumAsync().finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(settled).toBe(false);
|
||||
|
||||
finishFfmpegInstall('installed');
|
||||
await expect(run).rejects.toBe(appiumError);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import * as tar from 'tar';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import * as files from '../../../utils/files';
|
||||
import { createDownloadBuildFunction, downloadBuildAsync } from '../downloadBuild';
|
||||
|
||||
// contains a 'TestApp.app/TestApp' file with 'i am executable' content
|
||||
@@ -255,6 +256,144 @@ describe('downloadBuild', () => {
|
||||
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
|
||||
});
|
||||
|
||||
it('passes the abort signal to the build lookup', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(
|
||||
createSuccessfulResponse({ body: APP_TAR_GZ_BUFFER, url: APPLICATION_ARCHIVE_URL })
|
||||
);
|
||||
const buildId = randomUUID();
|
||||
|
||||
await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(graphqlClient.query).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ buildId },
|
||||
{ fetch: expect.any(Function) }
|
||||
);
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
APPLICATION_ARCHIVE_URL,
|
||||
expect.objectContaining({ signal: expect.any(Object) })
|
||||
);
|
||||
});
|
||||
|
||||
it('stops a pending build lookup when aborted', async () => {
|
||||
// The lookup request runs through the abort-aware fetch from graphqlAbortContext and
|
||||
// hangs until its signal fires, like a stalled API call.
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = jest.fn(
|
||||
(_input: unknown, init?: RequestInit) =>
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason));
|
||||
})
|
||||
) as typeof globalThis.fetch;
|
||||
const graphqlClient = {
|
||||
query: jest.fn((_query, _variables, context: { fetch: typeof globalThis.fetch }) => ({
|
||||
toPromise: () =>
|
||||
context.fetch('https://api.expo.dev/graphql', {}).then(
|
||||
() => ({ data: undefined }),
|
||||
(networkError: Error) => ({ error: new CombinedError({ networkError }) })
|
||||
),
|
||||
})),
|
||||
} as unknown as Client;
|
||||
const controller = new AbortController();
|
||||
const failure = new Error('daemon failed');
|
||||
|
||||
try {
|
||||
const download = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
signal: controller.signal,
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
controller.abort(failure);
|
||||
|
||||
await expect(download).rejects.toBe(failure);
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('lets a started extraction finish before it reports an abort', async () => {
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(
|
||||
createSuccessfulResponse({ body: APP_TAR_GZ_BUFFER, url: APPLICATION_ARCHIVE_URL })
|
||||
);
|
||||
const { decompressTarAsync } = jest.requireActual<typeof files>('../../../utils/files');
|
||||
const controller = new AbortController();
|
||||
const failure = new Error('daemon failed');
|
||||
let extractionDirectory: string | undefined;
|
||||
let extractionFinished = false;
|
||||
const decompressSpy = jest.spyOn(files, 'decompressTarAsync').mockImplementation(async args => {
|
||||
extractionDirectory = args.destinationDirectory;
|
||||
// The startup fails while the archive is being extracted.
|
||||
controller.abort(failure);
|
||||
await decompressTarAsync(args);
|
||||
extractionFinished = true;
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient: createMockGraphqlClient({}),
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toBe(failure);
|
||||
|
||||
// The download settled only after the extraction wrote its last file.
|
||||
expect(extractionFinished).toBe(true);
|
||||
expect(
|
||||
await fs.promises.readFile(
|
||||
path.join(extractionDirectory!, 'TestApp.app', 'TestApp'),
|
||||
'utf8'
|
||||
)
|
||||
).toBe('i am executable\n');
|
||||
} finally {
|
||||
decompressSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not look up the build when already aborted', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
const failure = new Error('boot failed');
|
||||
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
signal: AbortSignal.abort(failure),
|
||||
})
|
||||
).rejects.toBe(failure);
|
||||
expect(graphqlClient.query).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects missing or ambiguous build sources', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
@@ -424,6 +563,34 @@ describe('downloadBuild', () => {
|
||||
});
|
||||
|
||||
describe('createDownloadBuildFunction', () => {
|
||||
it('aborts a pending archive request when the step times out', async () => {
|
||||
let requestAborted = false;
|
||||
jest.mocked(fetch).mockImplementation(
|
||||
async (_url, options) =>
|
||||
await new Promise<Response>((_resolve, reject) => {
|
||||
options?.signal?.addEventListener('abort', () => {
|
||||
requestAborted = true;
|
||||
reject(new Error('Archive request aborted'));
|
||||
});
|
||||
})
|
||||
);
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
const step = createDownloadBuildFunction({
|
||||
graphqlClient,
|
||||
} as any).createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ staticContextContent: { job: {} } }),
|
||||
{
|
||||
callInputs: { application_archive_url: APPLICATION_ARCHIVE_URL, extensions: ['ipa'] },
|
||||
timeoutMs: 100,
|
||||
}
|
||||
);
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow('timed out');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(requestAborted).toBe(true);
|
||||
expect(graphqlClient.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should download a build', async () => {
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Client, CombinedError } from '@urql/core';
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
import fs from 'node:fs';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { createDownloadSubmissionArchiveFunction } from '../downloadSubmissionArchive';
|
||||
|
||||
const submissionId = 'aabbccdd-1234-4321-abcd-123456789012';
|
||||
const url = 'https://storage.example/app.ipa?signature=secret';
|
||||
|
||||
it('reports invalid submission ID and extensions together', async () => {
|
||||
const mutation = jest.fn();
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: 'invalid', extensions: [123] },
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toMatchObject({
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({ path: ['submissionId'] }),
|
||||
expect.objectContaining({ path: ['extensions', 0] }),
|
||||
]),
|
||||
});
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requests a fresh URL on each execution, downloads without auth and does not log the URL', async () => {
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
submission: { generateSubmissionArchiveDownloadUrl: url },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
const logger = createMockLogger();
|
||||
const fn = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
url,
|
||||
body: Readable.from(Buffer.from('IPA bytes')),
|
||||
headers: { get: () => null },
|
||||
} as unknown as Response);
|
||||
const step = fn.createBuildStepFromFunctionCall(createGlobalContextMock({ logger }), {
|
||||
callInputs: { submission_id: submissionId },
|
||||
});
|
||||
await step.executeAsync();
|
||||
expect(await fs.promises.readFile(step.getOutputValueByName('artifact_path')!, 'utf8')).toBe(
|
||||
'IPA bytes'
|
||||
);
|
||||
}
|
||||
expect(mutation).toHaveBeenCalledTimes(2);
|
||||
expect(fetch).toHaveBeenCalledWith(url, { headers: undefined, signal: undefined });
|
||||
expect(JSON.stringify(jest.mocked(logger.info).mock.calls)).not.toContain(url);
|
||||
});
|
||||
|
||||
it('preserves the API error as the cause without downloading', async () => {
|
||||
const error = new CombinedError({ networkError: new Error('Connection reset') });
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({
|
||||
error,
|
||||
}),
|
||||
}));
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: submissionId },
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toMatchObject({
|
||||
message: 'Could not request the submission archive. Try again later.',
|
||||
cause: error,
|
||||
});
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(mutation).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('retries a temporary URL request failure and then downloads the archive', async () => {
|
||||
const mutation = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
toPromise: async () => ({
|
||||
error: new CombinedError({ networkError: new Error('Connection reset') }),
|
||||
}),
|
||||
})
|
||||
.mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
data: { submission: { generateSubmissionArchiveDownloadUrl: url } },
|
||||
}),
|
||||
});
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
url,
|
||||
body: Readable.from(Buffer.from('IPA bytes')),
|
||||
headers: { get: () => null },
|
||||
} as unknown as Response);
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: submissionId },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mutation).toHaveBeenCalledTimes(2);
|
||||
expect(await fs.promises.readFile(step.getOutputValueByName('artifact_path')!, 'utf8')).toBe(
|
||||
'IPA bytes'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not retry an access error', async () => {
|
||||
const error = new CombinedError({ graphQLErrors: [{ message: 'Not authorized' }] });
|
||||
const mutation = jest.fn(() => ({ toPromise: async () => ({ error }) }));
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: submissionId },
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toMatchObject({ cause: error });
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not send another URL request after the step times out during retry backoff', async () => {
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({
|
||||
error: new CombinedError({ networkError: new Error('Connection reset') }),
|
||||
}),
|
||||
}));
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: submissionId },
|
||||
timeoutMs: 100,
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow('timed out');
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the requested extension for an archive without a filename extension', async () => {
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
submission: { generateSubmissionArchiveDownloadUrl: 'https://storage.example/archive' },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
url: 'https://storage.example/archive',
|
||||
body: Readable.from(Buffer.from('Android archive bytes')),
|
||||
headers: { get: () => null },
|
||||
} as unknown as Response);
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: submissionId, extensions: ['aab'] },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
const artifactPath = step.getOutputValueByName('artifact_path')!;
|
||||
expect(artifactPath).toMatch(/\.aab$/);
|
||||
expect(await fs.promises.readFile(artifactPath, 'utf8')).toBe('Android archive bytes');
|
||||
});
|
||||
|
||||
it('propagates the original download error', async () => {
|
||||
const error = new Error('Download failed');
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({
|
||||
data: { submission: { generateSubmissionArchiveDownloadUrl: url } },
|
||||
}),
|
||||
}));
|
||||
jest.mocked(fetch).mockRejectedValue(error);
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: submissionId },
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toBe(error);
|
||||
});
|
||||
@@ -463,114 +463,127 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
}
|
||||
});
|
||||
|
||||
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 }
|
||||
it.each(['1.1.27', '1.1.28', '1.2.3'])(
|
||||
'prepares the WDA cache only for legacy Runner versions (%s)',
|
||||
async version => {
|
||||
const homeDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'install-maestro-test-')
|
||||
);
|
||||
for (const runtimeVersion of ['18.3', '26.0']) {
|
||||
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'maestro-runner') {
|
||||
return { stdout: `maestro-runner ${version}\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(step.getOutputValueByName('maestro_version')).toBe(version);
|
||||
if (version !== '1.1.27') {
|
||||
expect(mockedDownloadFile).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn.mock.calls.map(([command]) => command)).not.toContain('xcrun');
|
||||
await expect(
|
||||
fs.promises.access(path.join(homeDirectory, '.maestro-runner', 'cache'))
|
||||
).rejects.toThrow();
|
||||
return;
|
||||
}
|
||||
|
||||
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.readFile(
|
||||
path.join(
|
||||
homeDirectory,
|
||||
'.maestro-runner',
|
||||
'cache',
|
||||
'wda-builds',
|
||||
`sim-ios${runtimeVersion}-iphone`,
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products',
|
||||
'WebDriverAgentRunner.xctestrun'
|
||||
),
|
||||
'utf8'
|
||||
fs.promises.access(
|
||||
path.join(homeDirectory, '.maestro-runner', 'cache', 'wda-builds', 'sim-ios17.5-iphone')
|
||||
)
|
||||
).resolves.toBe('cached');
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
|
||||
}
|
||||
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',
|
||||
command === 'xcodebuild' ? 'Xcode 26.5\nBuild version 17F90\n' : 'maestro-runner 1.1.27\n',
|
||||
})) as any);
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
@@ -593,7 +606,7 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
switch (command) {
|
||||
case 'maestro-runner':
|
||||
return { stdout: 'maestro-runner 1.2.3\n' };
|
||||
return { stdout: 'maestro-runner 1.1.27\n' };
|
||||
case 'xcodebuild':
|
||||
return { stdout: 'Xcode 26.5\nBuild version 17F90\n' };
|
||||
case 'xcrun':
|
||||
@@ -633,7 +646,7 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).resolves.toBeUndefined();
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.2.3');
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.1.27');
|
||||
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',
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { BuildRuntimePlatform, BuildStep } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { fs, vol } from 'memfs';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createInstallMitmproxyBuildFunction } from '../installMitmproxy';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
// An x86 Homebrew prefix, to check that the app is found from PATH and not from process.arch.
|
||||
const APP = '/usr/local/Caskroom/mitmproxy/12.2.3/mitmproxy.app';
|
||||
const LINK = '/usr/local/bin/mitmdump';
|
||||
|
||||
function installCask(): void {
|
||||
vol.fromJSON({ [`${APP}/Contents/MacOS/mitmdump`]: '', '/usr/local/bin': null });
|
||||
fs.symlinkSync(`${APP}/Contents/MacOS/mitmdump`, LINK);
|
||||
}
|
||||
|
||||
/**
|
||||
* `which` finds mitmdump once the cask is installed, `brew` installs it, `xattr -p` reports
|
||||
* quarantine while `stuck` is set, and each `mitmdump --version` takes the next result.
|
||||
*/
|
||||
function mockCommands({
|
||||
installed = false,
|
||||
stuck = false,
|
||||
runs = [],
|
||||
}: {
|
||||
installed?: boolean;
|
||||
stuck?: boolean;
|
||||
runs?: boolean[];
|
||||
}): void {
|
||||
if (installed) {
|
||||
installCask();
|
||||
}
|
||||
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'which' && !installed) {
|
||||
throw new Error('not found');
|
||||
}
|
||||
if (command === 'which') {
|
||||
return { stdout: `${LINK}\n` };
|
||||
}
|
||||
if (command === 'brew' && !installed) {
|
||||
installed = true;
|
||||
installCask();
|
||||
}
|
||||
if (command === 'xattr' && args[0] === '-p' && !stuck) {
|
||||
throw new Error('No such xattr: com.apple.quarantine');
|
||||
}
|
||||
if (command === 'mitmdump' && !runs.shift()) {
|
||||
throw new Error('not found');
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as never);
|
||||
}
|
||||
|
||||
function spawnedCommands(): string[] {
|
||||
return mockedSpawn.mock.calls.map(([command, args]) =>
|
||||
command === 'xattr' ? `xattr ${args?.[0]}` : command
|
||||
);
|
||||
}
|
||||
|
||||
describe('createInstallMitmproxyBuildFunction', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function createStep(env: Record<string, string>): BuildStep {
|
||||
const globalCtx = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.DARWIN });
|
||||
globalCtx.updateEnv(env);
|
||||
return createInstallMitmproxyBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: {},
|
||||
});
|
||||
}
|
||||
|
||||
it('takes the app behind mitmdump on PATH out of quarantine before running it', async () => {
|
||||
mockCommands({ installed: true, runs: [true] });
|
||||
|
||||
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xattr',
|
||||
['-dr', 'com.apple.quarantine', APP],
|
||||
expect.anything()
|
||||
);
|
||||
expect(spawnedCommands()).toEqual(['which', 'xattr -dr', 'xattr -p', 'mitmdump']);
|
||||
});
|
||||
|
||||
it('kills a mitmdump --version that does not return', async () => {
|
||||
mockCommands({ installed: true, runs: [true] });
|
||||
|
||||
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'mitmdump',
|
||||
['--version'],
|
||||
expect.objectContaining({ stdio: 'ignore', timeout: 60_000, killSignal: 'SIGKILL' })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not run a mitmdump that stays quarantined', async () => {
|
||||
mockCommands({ installed: true, stuck: true });
|
||||
|
||||
await expect(
|
||||
createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync()
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(spawnedCommands()).not.toContain('mitmdump');
|
||||
});
|
||||
|
||||
it('does not change quarantine outside EAS Build VMs', async () => {
|
||||
mockCommands({ installed: true, stuck: true, runs: [true] });
|
||||
|
||||
await createStep({}).executeAsync();
|
||||
|
||||
expect(spawnedCommands()).toEqual(['mitmdump']);
|
||||
});
|
||||
|
||||
it('installs the cask with Homebrew and takes it out of quarantine', async () => {
|
||||
mockCommands({ runs: [false, true] });
|
||||
|
||||
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'brew',
|
||||
['install', '--cask', 'mitmproxy'],
|
||||
expect.objectContaining({ env: expect.objectContaining({ HOMEBREW_NO_AUTO_UPDATE: '1' }) })
|
||||
);
|
||||
expect(spawnedCommands()).toEqual([
|
||||
'which',
|
||||
'mitmdump',
|
||||
'brew',
|
||||
'which',
|
||||
'xattr -dr',
|
||||
'xattr -p',
|
||||
'mitmdump',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not fail the job when mitmdump is still not runnable', async () => {
|
||||
mockCommands({ runs: [false, false] });
|
||||
|
||||
await expect(
|
||||
createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync()
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -256,7 +256,9 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects sharding with maestro-runner', async () => {
|
||||
it('passes shards to maestro-runner as parallel devices', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
@@ -264,26 +266,129 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
shards: 2,
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
'maestro-runner does not support EAS Maestro test sharding'
|
||||
);
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls[0][1]).toContain('--parallel=2');
|
||||
});
|
||||
|
||||
it('rejects a non-junit output_format with maestro-runner', async () => {
|
||||
it.each(['html', 'allure'])(
|
||||
'accepts %s output_format with maestro-runner and keeps JUnit',
|
||||
async format => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const copyFileSpy = jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
output_format: format,
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining([
|
||||
'--output=/home/expo/.maestro/tests/android-maestro-runner-attempt-0',
|
||||
])
|
||||
);
|
||||
expect(copyFileSpy).toHaveBeenCalledWith(
|
||||
'/home/expo/.maestro/tests/android-maestro-runner-attempt-0/junit-report.xml',
|
||||
'/home/expo/.maestro/tests/junit-reports/android-maestro-junit-attempt-0.xml'
|
||||
);
|
||||
expect(parser.mergeJUnitReports).not.toHaveBeenCalled();
|
||||
expect(step.getOutputValueByName('final_report_path')).toBe(
|
||||
format === 'html'
|
||||
? '/home/expo/.maestro/tests/android-maestro-runner-attempt-0/report.html'
|
||||
: '/home/expo/.maestro/tests/android-maestro-runner-attempt-0/allure-results'
|
||||
);
|
||||
expect(mockedRunnerHarvest).not.toHaveBeenCalled();
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
type: GenericArtifactType.OTHER,
|
||||
name:
|
||||
format === 'html' ? 'Maestro Runner HTML Report' : 'Maestro Runner Allure Results',
|
||||
paths: [
|
||||
format === 'html'
|
||||
? '/home/expo/.maestro/tests/android-maestro-runner-attempt-0'
|
||||
: '/home/expo/.maestro/tests/android-maestro-runner-attempt-0/allure-results',
|
||||
],
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not expose a selected runner report before an attempt starts', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
flow_path: [],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
output_format: 'html',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
expect(step.getOutputValueByName('final_report_path')).toBeUndefined();
|
||||
expect(step.getOutputValueByName('junit_report_directory')).toBe(
|
||||
'/home/expo/.maestro/tests/junit-reports'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unknown output_format with maestro-runner', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
output_format: 'csv',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
'maestro-runner only supports the "junit" output_format'
|
||||
'maestro-runner supports "junit", "html", and "allure" output_format values'
|
||||
);
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads the last runner HTML report even when the test fails', async () => {
|
||||
mockedSpawn.mockRejectedValue(rejectExit1());
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
output_format: 'html',
|
||||
retries: 1,
|
||||
retry_failed_only: false,
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
name: 'Maestro Runner HTML Report',
|
||||
paths: ['/home/expo/.maestro/tests/android-maestro-runner-attempt-1'],
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(step.getOutputValueByName('final_report_path')).toBe(
|
||||
'/home/expo/.maestro/tests/android-maestro-runner-attempt-1/report.html'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not fail a passing test when the runner report upload fails', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
mockUploadArtifact.mockRejectedValue(new Error('upload failed'));
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
output_format: 'allure',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('logs that maestro-runner does not support direct DADB', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
@@ -965,7 +1070,7 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('uses $HOME/.maestro/tests output path for non-junit formats (e.g. html)', async () => {
|
||||
it('uses $HOME/.maestro/tests output path and uploads the Maestro CLI HTML report', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
@@ -976,11 +1081,119 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
await step.executeAsync();
|
||||
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests so the
|
||||
// whole-directory upload picks it up.
|
||||
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests.
|
||||
const outputArg = args.find(a => a.startsWith('--output='));
|
||||
expect(outputArg).toMatch(/\.maestro\/tests\/android-maestro-html\.html$/);
|
||||
expect(outputArg).not.toMatch(/junit-reports/);
|
||||
expect(step.getOutputValueByName('final_report_path')).toBe(
|
||||
'/home/expo/.maestro/tests/android-maestro-html.html'
|
||||
);
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
type: GenericArtifactType.OTHER,
|
||||
name: 'Maestro HTML Report',
|
||||
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads the Maestro CLI HTML report even when tests fail', async () => {
|
||||
mockedSpawn.mockRejectedValue(rejectExit1());
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'html',
|
||||
platform: 'android',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
name: 'Maestro HTML Report',
|
||||
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('warns that Maestro CLI does not support Allure reports', async () => {
|
||||
mockedSpawn.mockRejectedValue(rejectExit1());
|
||||
const logger = createMockLogger();
|
||||
jest.mocked(logger.child).mockReturnValue(logger);
|
||||
const step = createStep(
|
||||
{
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'allure',
|
||||
platform: 'android',
|
||||
},
|
||||
{ logger }
|
||||
);
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Maestro CLI does not support Allure reports; no Allure artifact was uploaded.'
|
||||
);
|
||||
expect(mockUploadArtifact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears the Maestro CLI HTML report before every retry attempt', async () => {
|
||||
const removeSpy = jest.spyOn(fs, 'rm').mockResolvedValue();
|
||||
mockedSpawn.mockRejectedValue(rejectExit1());
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'html',
|
||||
platform: 'android',
|
||||
retries: 1,
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
|
||||
expect(
|
||||
removeSpy.mock.calls.filter(
|
||||
([target]) => target === '/home/expo/.maestro/tests/android-maestro-html.html'
|
||||
)
|
||||
).toHaveLength(2);
|
||||
expect(removeSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/home/expo/.maestro/tests/android-maestro-html.html',
|
||||
{
|
||||
force: true,
|
||||
}
|
||||
);
|
||||
expect(removeSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/home/expo/.maestro/tests/android-maestro-html.html',
|
||||
{
|
||||
force: true,
|
||||
}
|
||||
);
|
||||
expect(removeSpy.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockedSpawn.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(removeSpy.mock.invocationCallOrder[1]).toBeLessThan(
|
||||
mockedSpawn.mock.invocationCallOrder[1]
|
||||
);
|
||||
});
|
||||
|
||||
it('does not run Maestro CLI or upload old HTML if the report cannot be cleared', async () => {
|
||||
jest.spyOn(fs, 'rm').mockImplementation(async target => {
|
||||
if (target === '/home/expo/.maestro/tests/android-maestro-html.html') {
|
||||
throw new Error('permission denied');
|
||||
}
|
||||
});
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'html',
|
||||
platform: 'android',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(SystemError);
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
expect(mockUploadArtifact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses lowercase extension for non-junit formats regardless of input casing', async () => {
|
||||
@@ -1215,28 +1428,31 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('discovers maestro-runner flow results from the attempt report directory', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const shot = makeShot(0);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
mockedRunnerHarvest.mockResolvedValue([shot]);
|
||||
const parseFlowResultsSpy = jest.spyOn(parser, 'parseMaestroRunnerReport').mockResolvedValue({
|
||||
flows: [{ name: 'Login', sourceFile: 'a.yaml', status: 'failed' }],
|
||||
});
|
||||
it.each(['junit', undefined])(
|
||||
'uploads runner screenshots with output_format=%s',
|
||||
async format => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const shot = makeShot(0);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
mockedRunnerHarvest.mockResolvedValue([shot]);
|
||||
const parseFlowResultsSpy = jest.spyOn(parser, 'parseMaestroRunnerReport').mockResolvedValue({
|
||||
flows: [{ name: 'Login', sourceFile: 'a.yaml', status: 'failed' }],
|
||||
});
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
platform: 'ios',
|
||||
output_format: 'junit',
|
||||
backend: 'maestro-runner',
|
||||
});
|
||||
await step.executeAsync();
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
platform: 'ios',
|
||||
...(format === undefined ? {} : { output_format: format }),
|
||||
backend: 'maestro-runner',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(parseFlowResultsSpy).toHaveBeenCalledWith(
|
||||
'/home/expo/.maestro/tests/ios-maestro-runner-attempt-0'
|
||||
);
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(parseFlowResultsSpy).toHaveBeenCalledWith(
|
||||
'/home/expo/.maestro/tests/ios-maestro-runner-attempt-0'
|
||||
);
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('uploads screenshots even when all attempts fail, before throwing ERR_MAESTRO_TESTS_FAILED', async () => {
|
||||
mockedSpawn.mockRejectedValue(rejectExit1());
|
||||
@@ -1300,14 +1516,15 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(30);
|
||||
});
|
||||
|
||||
it('does not harvest or upload screenshots when output_format is not junit', async () => {
|
||||
it('does not harvest or upload screenshots for Maestro CLI HTML', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'html' });
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedHarvest).not.toHaveBeenCalled();
|
||||
expect(mockUploadArtifact).not.toHaveBeenCalled();
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
|
||||
expect(mockUploadArtifact.mock.calls[0][0].artifact.name).toBe('Maestro HTML Report');
|
||||
});
|
||||
|
||||
it('uploads only the final attempt for a pure-failure flow, but every attempt for a flaky flow', async () => {
|
||||
|
||||
+39
-16
@@ -7,6 +7,7 @@ import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
monitorLocalEgressAsync,
|
||||
readLocalEgressHandoffAsync,
|
||||
@@ -14,11 +15,10 @@ import {
|
||||
stopLocalEgressResourcesAsync,
|
||||
} from '../../utils/localEgress';
|
||||
import {
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
|
||||
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
|
||||
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
|
||||
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
|
||||
@@ -27,7 +27,14 @@ jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/IosSimulatorUtils');
|
||||
jest.mock('../../../utils/processes');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/agentDeviceArtifacts');
|
||||
// The agent-device session step boots the Simulator itself; this test covers local egress.
|
||||
jest.mock('../startIosSimulator', () => ({
|
||||
bootIosSimulatorAsync: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ deviceIdentifier: 'sim', udid: 'sim', displayName: 'iPhone' }),
|
||||
}));
|
||||
jest.mock('../../utils/argentArtifacts');
|
||||
jest.mock('../../utils/agentDeviceEvents', () => ({
|
||||
startAgentDeviceEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
|
||||
@@ -45,6 +52,7 @@ jest.mock('../../utils/localEgress', () => ({
|
||||
monitorLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
getDeviceRunSessionIdOrThrow: () => 'session-id',
|
||||
getNgrokTunnelDomainOrThrow: () => 'example.test',
|
||||
getNgrokAuthtokenOrThrow: () => 'ngrok-token',
|
||||
@@ -52,13 +60,20 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
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(),
|
||||
startNgrokTunnelAsync: async () => ({
|
||||
url: 'https://controller.test',
|
||||
subdomainId: 'controller-id',
|
||||
stopAsync: jest.fn(),
|
||||
}),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
child: jest.fn().mockReturnThis(),
|
||||
} 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 };
|
||||
@@ -69,7 +84,7 @@ const fields = {
|
||||
egressPort: handoff.port,
|
||||
};
|
||||
const controllers = [
|
||||
['agent-device', createStartAgentDeviceRemoteSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
|
||||
['agent-device', createStartAgentDeviceSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
|
||||
['Appium', createStartAppiumRemoteSessionBuildFunction, 'appiumUrl'],
|
||||
['Argent', createStartArgentRemoteSessionBuildFunction, 'toolsUrl'],
|
||||
['web preview', createStartWebPreviewRemoteSessionBuildFunction, 'previewUrl'],
|
||||
@@ -77,16 +92,17 @@ const controllers = [
|
||||
|
||||
describe.each(controllers)('%s local egress', (_name, createFunction, controllerField) => {
|
||||
const stopPreview = jest.fn();
|
||||
const openPreview = 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 },
|
||||
},
|
||||
// Inputs a step does not get default to undefined, like in a real step call.
|
||||
inputs: new Proxy({} as Record<string, { value: unknown }>, {
|
||||
get: (target, id: string) => target[id] ?? { value: undefined },
|
||||
}),
|
||||
outputs: {},
|
||||
env,
|
||||
signal,
|
||||
@@ -114,10 +130,15 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
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',
|
||||
openPreview.mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://preview.test',
|
||||
previewToken: 'preview-secret',
|
||||
stopAsync: stopPreview,
|
||||
closeAsync: jest.fn(),
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: openPreview,
|
||||
finishAsync: stopPreview,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
|
||||
@@ -163,12 +184,14 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(stopEgress).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(['startup', 'report', 'wait', 'teardown'])(
|
||||
it.each(['startup', 'preview', 'report', 'wait', 'teardown'])(
|
||||
'releases egress after %s fails',
|
||||
async phase => {
|
||||
const error = new Error(`${phase} failed`);
|
||||
if (phase === 'startup') {
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockRejectedValue(error);
|
||||
jest.mocked(startDeviceSessionHostAsync).mockRejectedValue(error);
|
||||
} else if (phase === 'preview') {
|
||||
openPreview.mockRejectedValue(error);
|
||||
} else if (phase === 'report') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValue(error);
|
||||
} else if (phase === 'wait') {
|
||||
@@ -180,7 +203,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
expect(lifetime.aborted).toBe(true);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(
|
||||
phase === 'startup' || phase === 'report' ? 0 : 1
|
||||
phase === 'startup' || phase === 'preview' || phase === 'report' ? 0 : 1
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import turtleSpawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { EventEmitter, once } from 'node:events';
|
||||
import path from 'node:path';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import * as agentRun from '../../utils/agentRun';
|
||||
import { leaseAgentRunProviderCredentialsAsync } from '../../utils/agentRunLease';
|
||||
import { createRunAgentBuildFunction } from '../runAgent';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('node:child_process', () => ({
|
||||
...jest.requireActual('node:child_process'),
|
||||
spawn: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/agentRunLease');
|
||||
|
||||
const installMock = jest.mocked(turtleSpawn);
|
||||
const spawnMock = jest.mocked(spawn);
|
||||
const leaseMock = jest.mocked(leaseAgentRunProviderCredentialsAsync);
|
||||
|
||||
const ctx = {
|
||||
expoApiV2BaseUrl: 'https://staging-api.expo.test/v2/',
|
||||
mcpServerUrl: 'wss://staging-mcp.expo.test',
|
||||
} as any;
|
||||
const inputs = {
|
||||
agent_kind: { value: 'claude-code' },
|
||||
agent_run_id: { value: '0199b0a0-1111-7222-8333-444455556666' },
|
||||
max_duration_seconds: { value: 1800 },
|
||||
prompt: { value: 'Fix the failing test.' },
|
||||
};
|
||||
const env = {
|
||||
PATH: '/usr/local/bin:/usr/bin',
|
||||
HOME: '/home/expo',
|
||||
EXPO_TOKEN: 'expo-token',
|
||||
__API_SERVER_URL: 'https://staging-api.expo.test',
|
||||
EAS_BUILD_ID: 'build-id',
|
||||
NODE_OPTIONS: '--max-old-space-size=3072',
|
||||
USER_DEFINED_SECRET: 'secret',
|
||||
};
|
||||
|
||||
async function runAsync(
|
||||
overrides: {
|
||||
ctx?: any;
|
||||
inputs?: Record<string, { value: unknown }>;
|
||||
env?: Record<string, string>;
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
const fn = createRunAgentBuildFunction(overrides.ctx ?? ctx);
|
||||
await fn.fn!({ logger: createMockLogger() } as any, {
|
||||
inputs: overrides.inputs ?? inputs,
|
||||
outputs: {},
|
||||
env: overrides.env ?? env,
|
||||
});
|
||||
}
|
||||
|
||||
function mockSuccessfulRun(): { mcpConfig?: any } {
|
||||
const captured: { mcpConfig?: any } = {};
|
||||
leaseMock.mockResolvedValue({ provider: 'anthropic', accessToken: 'anthropic-access-token' });
|
||||
installMock.mockResolvedValue({} as any);
|
||||
spawnMock.mockImplementation(((_command: string, _args: string[], spawnOptions: any) => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdin: { on: jest.fn(), end: jest.fn() },
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
});
|
||||
captured.mcpConfig = JSON.parse(
|
||||
vol.readFileSync(path.join(spawnOptions.env.HOME, 'mcp.json'), 'utf8') as string
|
||||
);
|
||||
process.nextTick(() => {
|
||||
child.emit('exit', 0, null);
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
void Promise.all([once(child.stdout, 'end'), once(child.stderr, 'end')]).then(() =>
|
||||
child.emit('close')
|
||||
);
|
||||
});
|
||||
return child;
|
||||
}) as any);
|
||||
return captured;
|
||||
}
|
||||
|
||||
describe(createRunAgentBuildFunction, () => {
|
||||
it('is the eas/run_agent function', () => {
|
||||
expect(createRunAgentBuildFunction(ctx).getFullId()).toBe('eas/run_agent');
|
||||
});
|
||||
|
||||
it('passes none of the job environment to npm or the agent', async () => {
|
||||
mockSuccessfulRun();
|
||||
|
||||
await runAsync();
|
||||
|
||||
expect(leaseMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
expoApiV2BaseUrl: 'https://staging-api.expo.test/v2/',
|
||||
expoToken: 'expo-token',
|
||||
agentRunId: '0199b0a0-1111-7222-8333-444455556666',
|
||||
})
|
||||
);
|
||||
const npmEnv = installMock.mock.calls[0][2]!.env!;
|
||||
const agentEnv = spawnMock.mock.calls[0][2].env!;
|
||||
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'LANG', 'PATH', 'TMPDIR']);
|
||||
expect(Object.keys(agentEnv).sort()).toEqual([
|
||||
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
|
||||
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
'HOME',
|
||||
'LANG',
|
||||
'PATH',
|
||||
'TMPDIR',
|
||||
]);
|
||||
expect(agentEnv.PATH).toBe('/usr/local/bin:/usr/bin');
|
||||
expect(agentEnv.HOME).not.toBe('/home/expo');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['wss://staging-mcp.expo.test', 'https://staging-mcp.expo.test/mcp'],
|
||||
['ws://localhost:8787', 'http://localhost:8787/mcp'],
|
||||
])('points the agent at the HTTP endpoint of %s', async (mcpServerUrl, mcpUrl) => {
|
||||
const captured = mockSuccessfulRun();
|
||||
|
||||
await runAsync({ ctx: { ...ctx, mcpServerUrl } });
|
||||
|
||||
expect(captured.mcpConfig.mcpServers.expo.url).toBe(mcpUrl);
|
||||
});
|
||||
|
||||
it('stops the run at max_duration_seconds and reports a timeout', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
||||
const runAgentSpy = jest.spyOn(agentRun, 'runAgentAsync').mockImplementation(
|
||||
({ signal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
signal!.addEventListener('abort', () => reject(signal!.reason));
|
||||
})
|
||||
);
|
||||
try {
|
||||
const run = runAsync({ inputs: { ...inputs, max_duration_seconds: { value: 60 } } });
|
||||
const rejection = expect(run).rejects.toMatchObject({
|
||||
errorCode: 'EAS_RUN_AGENT_TIMEOUT',
|
||||
message: expect.stringContaining('60 seconds'),
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(59_000);
|
||||
expect(runAgentSpy.mock.calls[0][0].signal!.aborted).toBe(false);
|
||||
await jest.advanceTimersByTimeAsync(1_000);
|
||||
await rejection;
|
||||
} finally {
|
||||
runAgentSpy.mockRestore();
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an unknown agent_kind', { inputs: { ...inputs, agent_kind: { value: 'gemini' } } }],
|
||||
['a malformed agent_run_id', { inputs: { ...inputs, agent_run_id: { value: 'not-a-uuid' } } }],
|
||||
['an empty agent_run_id', { inputs: { ...inputs, agent_run_id: { value: '' } } }],
|
||||
['a zero duration', { inputs: { ...inputs, max_duration_seconds: { value: 0 } } }],
|
||||
['a negative duration', { inputs: { ...inputs, max_duration_seconds: { value: -300 } } }],
|
||||
['a fractional duration', { inputs: { ...inputs, max_duration_seconds: { value: 12.5 } } }],
|
||||
['an empty prompt', { inputs: { ...inputs, prompt: { value: '' } } }],
|
||||
['no EXPO_TOKEN', { env: { ...env, EXPO_TOKEN: '' } }],
|
||||
['no PATH', { env: { ...env, PATH: '' } }],
|
||||
['no Expo API URL', { ctx: { ...ctx, expoApiV2BaseUrl: undefined } }],
|
||||
['no MCP server URL', { ctx: { ...ctx, mcpServerUrl: undefined } }],
|
||||
])('throws a system error for %s', async (_name, overrides) => {
|
||||
await expect(runAsync(overrides)).rejects.toBeInstanceOf(SystemError);
|
||||
|
||||
expect(leaseMock).not.toHaveBeenCalled();
|
||||
expect(installMock).not.toHaveBeenCalled();
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports every invalid input at once', async () => {
|
||||
await expect(
|
||||
runAsync({
|
||||
inputs: {
|
||||
...inputs,
|
||||
agent_run_id: { value: 'not-a-uuid' },
|
||||
max_duration_seconds: { value: 0 },
|
||||
},
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'The "agent_run_id" input must be the UUID of an agent run, but received "not-a-uuid". ' +
|
||||
'The "max_duration_seconds" input must be a positive whole number of seconds, but received "0".'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type BuildStepContext, type BuildStepEnv } from '@expo/steps';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { getDeviceRunSessionIdOrThrow } from '../../utils/remoteDeviceRunSession';
|
||||
import { uploadServeSimLogsFileAsync } from '../../utils/serveSimLogsArtifacts';
|
||||
import { ServeSimLogsRecorder } from '../../utils/serveSimLogsRecorder';
|
||||
import { createCollectServeSimLogsBuildFunction } from '../collectServeSimLogs';
|
||||
import { createStartServeSimLogsBuildFunction } from '../startServeSimLogs';
|
||||
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../utils/serveSimLogsRecorder');
|
||||
jest.mock('../../utils/serveSimLogsArtifacts');
|
||||
jest.mock('../../utils/remoteDeviceRunSession');
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const step = { logger } as BuildStepContext;
|
||||
const args = { inputs: {}, outputs: {}, env: {} as BuildStepEnv };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('session-id');
|
||||
jest.mocked(ServeSimLogsRecorder.finishAsync).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('starts collection and tolerates startup failures', async () => {
|
||||
jest.mocked(ServeSimLogsRecorder.startAsync).mockRejectedValueOnce(new Error('disk failure'));
|
||||
await expect(createStartServeSimLogsBuildFunction().fn?.(step, args)).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not start serve-sim simulator logs',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads each device with its own bounded signal', async () => {
|
||||
jest.mocked(ServeSimLogsRecorder.finishAsync).mockResolvedValue([
|
||||
{ udid: 'A', filePath: '/tmp/A.ndjson' },
|
||||
{ udid: 'B', filePath: '/tmp/B.ndjson' },
|
||||
]);
|
||||
await createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args);
|
||||
expect(uploadServeSimLogsFileAsync).toHaveBeenCalledTimes(2);
|
||||
expect(uploadServeSimLogsFileAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
deviceRunSessionId: 'session-id',
|
||||
udid: 'A',
|
||||
filePath: '/tmp/A.ndjson',
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
);
|
||||
expect(jest.mocked(uploadServeSimLogsFileAsync).mock.calls[0][1].signal).not.toBe(
|
||||
jest.mocked(uploadServeSimLogsFileAsync).mock.calls[1][1].signal
|
||||
);
|
||||
});
|
||||
|
||||
it('skips empty collection and warns on finalization failure', async () => {
|
||||
await createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args);
|
||||
expect(uploadServeSimLogsFileAsync).not.toHaveBeenCalled();
|
||||
jest.mocked(ServeSimLogsRecorder.finishAsync).mockRejectedValueOnce(new Error('failure'));
|
||||
await expect(
|
||||
createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args)
|
||||
).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not finalize serve-sim simulator logs',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
+374
@@ -0,0 +1,374 @@
|
||||
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 { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { type StartupTasks, createStartupTasks } from '../../utils/startupTasks';
|
||||
import { runAgentDeviceRemoteSessionAsync } from '../startAgentDeviceRemoteSession';
|
||||
|
||||
// The daemon entry path and the state directory are resolved from the home directory when
|
||||
// the module loads, so point it at a temp home we can populate.
|
||||
jest.mock('node:os', () => {
|
||||
const actual = jest.requireActual('node:os');
|
||||
const actualPath = jest.requireActual('node:path');
|
||||
return {
|
||||
...actual,
|
||||
homedir: () => actualPath.join(actual.tmpdir(), 'eas-agent-device-orchestration-home'),
|
||||
};
|
||||
});
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../utils/agentDeviceArtifacts', () => ({
|
||||
pollAgentDeviceArtifactsForUploadAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/agentDeviceEvents', () => ({
|
||||
startAgentDeviceEventCollectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
getDeviceRunSessionIdOrThrow: jest.fn(),
|
||||
getNgrokAuthtokenOrThrow: jest.fn(),
|
||||
getNgrokTunnelDomainOrThrow: jest.fn(),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
spawnDetached: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
waitForFileAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const TEST_HOME = path.join(os.tmpdir(), 'eas-agent-device-orchestration-home');
|
||||
const DAEMON_ENTRY_PATH = path.join(
|
||||
TEST_HOME,
|
||||
'.bun/install/global/node_modules/agent-device/dist/src/internal/daemon.js'
|
||||
);
|
||||
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const mockPreviewStopAsync = jest.fn();
|
||||
const mockTunnelStopAsync = jest.fn();
|
||||
const mockDaemonStopAsync = jest.fn();
|
||||
const mockEventCollectionStopAsync = jest.fn();
|
||||
|
||||
type CaptureInputs = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['capture'];
|
||||
const NO_CAPTURE: CaptureInputs = { networkCapture: false, networkCaptureFields: [] };
|
||||
|
||||
/** Runs the session for a device that is already booted, with the app (if any) launched. */
|
||||
async function runAsync(
|
||||
logger: { info: jest.Mock; warn: jest.Mock; child: jest.Mock },
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
capture: CaptureInputs = NO_CAPTURE
|
||||
): Promise<void> {
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env: {},
|
||||
logger: logger as never,
|
||||
runtimePlatform,
|
||||
sessionEnv: {
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
ngrokTunnelDomain: 'tunnel.example.com',
|
||||
ngrokAuthtoken: 'ngrok-token',
|
||||
},
|
||||
packageVersion: undefined,
|
||||
maxIdleTimeMinutes: undefined,
|
||||
maxDurationSeconds: undefined,
|
||||
capture,
|
||||
tasks: createStartupTasks(logger as never),
|
||||
device: { booted: Promise.resolve(), ready: Promise.resolve() },
|
||||
});
|
||||
}
|
||||
|
||||
describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
jest.mocked(spawn).mockResolvedValue(undefined as never);
|
||||
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue({
|
||||
stopAsync: mockEventCollectionStopAsync,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
});
|
||||
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('device-run-session-id');
|
||||
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
|
||||
jest.mocked(getNgrokAuthtokenOrThrow).mockReturnValue('ngrok-token');
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 4242,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: mockDaemonStopAsync,
|
||||
});
|
||||
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 5678, token: 'daemon-token' });
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
|
||||
url: 'https://agent-device-abc.tunnel.example.com',
|
||||
subdomainId: 'agent-device-abc',
|
||||
stopAsync: mockTunnelStopAsync,
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
|
||||
await fs.promises.mkdir(path.dirname(DAEMON_ENTRY_PATH), { recursive: true });
|
||||
await fs.promises.writeFile(DAEMON_ENTRY_PATH, '');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(TEST_HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports the preview URL and tears every resource down', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.LINUX);
|
||||
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
|
||||
);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
agentDeviceRemoteSessionUrl: 'https://agent-device-abc.tunnel.example.com',
|
||||
agentDeviceRemoteSessionToken: 'daemon-token',
|
||||
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewApiUrl: 'https://web-preview.tunnel.example.com',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventCollectionStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hands network capture to serve-sim', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
|
||||
networkCapture: true,
|
||||
networkCaptureFields: ['header'],
|
||||
});
|
||||
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ networkCapture: true, networkCaptureFields: ['header'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('starts the session host without waiting for the agent-device daemon', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const sessionHost = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
ctx,
|
||||
{} as never
|
||||
);
|
||||
let markHostStarted!: () => void;
|
||||
const hostStarted = new Promise<void>(resolve => {
|
||||
markHostStarted = resolve;
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async () => {
|
||||
markHostStarted();
|
||||
return sessionHost;
|
||||
});
|
||||
// The daemon credentials only appear after the session host started. A sequential
|
||||
// startup would wait here forever.
|
||||
jest.mocked(waitForFileAsync).mockImplementation(async () => {
|
||||
await hostStarted;
|
||||
return { port: 5678, token: 'daemon-token' };
|
||||
});
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.DARWIN);
|
||||
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
agentDeviceRemoteSessionUrl: 'https://agent-device-abc.tunnel.example.com',
|
||||
agentDeviceRemoteSessionToken: 'daemon-token',
|
||||
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('stops the daemon and its tunnel when the session host fails to start', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
jest
|
||||
.mocked(startDeviceSessionHostAsync)
|
||||
.mockRejectedValue(new Error('serve-sim did not start'));
|
||||
|
||||
await expect(runAsync(logger, BuildRuntimePlatform.DARWIN)).rejects.toThrow(
|
||||
'serve-sim did not start'
|
||||
);
|
||||
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
// The failure aborts the daemon task, which then may not start its daemon or tunnel.
|
||||
// Whatever started is stopped.
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(jest.mocked(spawnDetached).mock.calls.length);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(
|
||||
jest.mocked(startNgrokTunnelAsync).mock.calls.length
|
||||
);
|
||||
});
|
||||
|
||||
it('stops the session host and the daemon when the daemon credentials never appear', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
jest.mocked(waitForFileAsync).mockRejectedValue(new Error('no daemon credentials'));
|
||||
|
||||
await expect(runAsync(logger, BuildRuntimePlatform.DARWIN)).rejects.toThrow(
|
||||
'no daemon credentials'
|
||||
);
|
||||
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('runAgentDeviceRemoteSessionAsync with a device that is still starting', () => {
|
||||
function deferred(): {
|
||||
promise: Promise<void>;
|
||||
resolve: () => void;
|
||||
reject: (e: Error) => void;
|
||||
} {
|
||||
let resolve!: () => void;
|
||||
let reject!: (e: Error) => void;
|
||||
const promise = new Promise<void>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
promise.catch(() => {});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
type Device = { booted: Promise<unknown>; ready: Promise<unknown> };
|
||||
function startSession(device: Device | ((tasks: StartupTasks) => Device)) {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
child: jest.fn().mockReturnThis(),
|
||||
} as never;
|
||||
const tasks = createStartupTasks(logger);
|
||||
return runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env: {},
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
sessionEnv: {
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
ngrokTunnelDomain: 'tunnel.example.com',
|
||||
ngrokAuthtoken: 'ngrok-token',
|
||||
},
|
||||
packageVersion: undefined,
|
||||
maxIdleTimeMinutes: undefined,
|
||||
maxDurationSeconds: undefined,
|
||||
capture: NO_CAPTURE,
|
||||
tasks,
|
||||
device: typeof device === 'function' ? device(tasks) : device,
|
||||
});
|
||||
}
|
||||
|
||||
async function flushAsync(): Promise<void> {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
it('starts the daemon during the boot and the session host after it', async () => {
|
||||
const booted = deferred();
|
||||
const session = startSession({ booted: booted.promise, ready: booted.promise });
|
||||
await flushAsync();
|
||||
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(1);
|
||||
expect(startNgrokTunnelAsync).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
|
||||
booted.resolve();
|
||||
await session;
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports the session as ready only after the app is launched', async () => {
|
||||
const ready = deferred();
|
||||
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
|
||||
await flushAsync();
|
||||
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
|
||||
ready.resolve();
|
||||
await session;
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops waiting for the boot when the daemon fails', async () => {
|
||||
jest.mocked(waitForFileAsync).mockRejectedValue(new Error('no daemon credentials'));
|
||||
const neverBooted = new Promise<void>(() => {});
|
||||
|
||||
// In the combined step, `ready` stops waiting for the boot on an abort, like this.
|
||||
await expect(
|
||||
startSession(tasks => ({ booted: neverBooted, ready: tasks.untilAborted(neverBooted) }))
|
||||
).rejects.toThrow('no daemon credentials');
|
||||
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops the session host when the app fails while the daemon install hangs', async () => {
|
||||
jest.mocked(spawn).mockImplementation(((
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: { signal?: AbortSignal }
|
||||
) =>
|
||||
command === 'bun' && args[0] === 'add'
|
||||
? new Promise((_resolve, reject) => {
|
||||
options!.signal!.addEventListener('abort', () => reject(options!.signal!.reason));
|
||||
})
|
||||
: Promise.resolve(undefined)) as never);
|
||||
const ready = deferred();
|
||||
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
|
||||
await flushAsync();
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
|
||||
ready.reject(new Error('simctl install failed'));
|
||||
|
||||
await expect(session).rejects.toThrow('simctl install failed');
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops everything it started when the app install fails', async () => {
|
||||
const ready = deferred();
|
||||
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
|
||||
await flushAsync();
|
||||
ready.reject(new Error('simctl install failed'));
|
||||
|
||||
await expect(session).rejects.toThrow('simctl install failed');
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
+229
-2
@@ -1,9 +1,38 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { stopAgentDeviceEventCollectionSafelyAsync } from '../startAgentDeviceRemoteSession';
|
||||
import { spawnDetached } from '../../utils/remoteDeviceRunSession';
|
||||
import {
|
||||
startAgentDeviceDaemonAsync,
|
||||
stopAgentDeviceEventCollectionSafelyAsync,
|
||||
} from '../startAgentDeviceRemoteSession';
|
||||
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
spawnDetached: jest.fn(),
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
|
||||
async function writeDaemonEntry(cwd: string): Promise<string> {
|
||||
const daemonPath = path.join(
|
||||
cwd,
|
||||
'node_modules',
|
||||
'agent-device',
|
||||
'dist',
|
||||
'src',
|
||||
'internal',
|
||||
'daemon.js'
|
||||
);
|
||||
await fs.promises.mkdir(path.dirname(daemonPath), { recursive: true });
|
||||
await fs.promises.writeFile(daemonPath, '');
|
||||
return daemonPath;
|
||||
}
|
||||
|
||||
describe(stopAgentDeviceEventCollectionSafelyAsync, () => {
|
||||
beforeEach(() => {
|
||||
@@ -12,7 +41,6 @@ describe(stopAgentDeviceEventCollectionSafelyAsync, () => {
|
||||
|
||||
it('reports an unexpected stop failure without rejecting', async () => {
|
||||
const error = new Error('stop failed');
|
||||
const logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
|
||||
await expect(
|
||||
stopAgentDeviceEventCollectionSafelyAsync({
|
||||
@@ -37,3 +65,202 @@ describe(stopAgentDeviceEventCollectionSafelyAsync, () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(startAgentDeviceDaemonAsync, () => {
|
||||
const stopAsync = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
stopAsync.mockResolvedValue(undefined);
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 1234,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync,
|
||||
});
|
||||
jest.mocked(spawn).mockImplementation((async (
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: { cwd?: string }
|
||||
) => {
|
||||
if (command === 'bun' && args[0] === '--version') {
|
||||
return { stdout: '1.3.11\n' };
|
||||
}
|
||||
if (typeof options?.cwd === 'string' && command !== 'git') {
|
||||
await writeDaemonEntry(options.cwd);
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as never);
|
||||
});
|
||||
|
||||
it('installs with bun add by default and launches the published daemon', async () => {
|
||||
const handle = await startAgentDeviceDaemonAsync({
|
||||
packageVersion: '1.2.3',
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['add', '[email protected]'],
|
||||
expect.objectContaining({ cwd: expect.stringContaining('eas-agent-device-') })
|
||||
);
|
||||
const addCwd = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
|
||||
expect(spawnDetached).toHaveBeenCalledWith({
|
||||
command: 'node',
|
||||
args: [path.join(addCwd, 'node_modules/agent-device/dist/src/internal/daemon.js')],
|
||||
env: expect.objectContaining({
|
||||
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
|
||||
AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0',
|
||||
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS: '0',
|
||||
AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS: '0',
|
||||
}),
|
||||
});
|
||||
|
||||
await handle.stopAsync();
|
||||
await expect(fs.promises.access(addCwd)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('installs with npm when EAS_OVERRIDE_PACKAGE_MANAGER is npm', async () => {
|
||||
await startAgentDeviceDaemonAsync({
|
||||
packageVersion: undefined,
|
||||
env: { EAS_OVERRIDE_PACKAGE_MANAGER: 'npm' },
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'npm',
|
||||
['install', '--no-audit', 'agent-device@latest'],
|
||||
expect.objectContaining({ cwd: expect.stringContaining('eas-agent-device-') })
|
||||
);
|
||||
});
|
||||
|
||||
it('kills the install and does not fall back to git when aborted', async () => {
|
||||
jest.mocked(spawn).mockImplementation(
|
||||
((_command: string, _args: string[], options?: { signal?: AbortSignal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
options!.signal!.addEventListener('abort', () => reject(options!.signal!.reason));
|
||||
})) as never
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const failure = new Error('app install failed');
|
||||
|
||||
const daemon = startAgentDeviceDaemonAsync({
|
||||
packageVersion: '1.2.3',
|
||||
env: {},
|
||||
logger,
|
||||
signal: controller.signal,
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
controller.abort(failure);
|
||||
|
||||
await expect(daemon).rejects.toBe(failure);
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['add', '[email protected]'],
|
||||
expect.objectContaining({ signal: controller.signal })
|
||||
);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to git clone when the published daemon is missing', async () => {
|
||||
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'bun' && args[0] === '--version') {
|
||||
return { stdout: '1.3.11\n' };
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as never);
|
||||
|
||||
await startAgentDeviceDaemonAsync({
|
||||
packageVersion: '1.2.3',
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'git',
|
||||
[
|
||||
'clone',
|
||||
'--depth',
|
||||
'1',
|
||||
'--branch',
|
||||
'v1.2.3',
|
||||
'https://github.com/callstack/agent-device.git',
|
||||
'/tmp/agent-device-src',
|
||||
],
|
||||
expect.objectContaining({ env: {}, logger })
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['install', '--production'],
|
||||
expect.objectContaining({ cwd: '/tmp/agent-device-src' })
|
||||
);
|
||||
expect(spawnDetached).toHaveBeenCalledWith({
|
||||
command: 'bun',
|
||||
args: ['run', 'src/daemon.ts'],
|
||||
cwd: '/tmp/agent-device-src',
|
||||
env: expect.objectContaining({
|
||||
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
|
||||
AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0',
|
||||
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS: '0',
|
||||
AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS: '0',
|
||||
}),
|
||||
});
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
|
||||
expect.any(Error),
|
||||
expect.objectContaining({
|
||||
extras: expect.objectContaining({
|
||||
packageSpec: '[email protected]',
|
||||
packageManager: 'bun',
|
||||
bunVersion: '1.3.11',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('clones latest and reports unknown bun when diagnostics fail', async () => {
|
||||
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'bun' && args[0] === '--version') {
|
||||
throw new Error('bun missing');
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as never);
|
||||
|
||||
await startAgentDeviceDaemonAsync({
|
||||
packageVersion: undefined,
|
||||
env: { EAS_OVERRIDE_PACKAGE_MANAGER: 'npm' },
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'git',
|
||||
[
|
||||
'clone',
|
||||
'--depth',
|
||||
'1',
|
||||
'https://github.com/callstack/agent-device.git',
|
||||
'/tmp/agent-device-src',
|
||||
],
|
||||
expect.objectContaining({ logger })
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'npm',
|
||||
['install', '--no-audit', '--omit=dev'],
|
||||
expect.objectContaining({ cwd: '/tmp/agent-device-src' })
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
|
||||
expect.any(Error),
|
||||
expect.objectContaining({
|
||||
extras: expect.objectContaining({
|
||||
packageVersion: 'latest',
|
||||
packageManager: 'npm',
|
||||
bunVersion: 'unknown',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
|
||||
import { downloadBuildAsync } from '../downloadBuild';
|
||||
import { installBuildAsync } from '../installBuild';
|
||||
import { launchApplicationAsync } from '../launchApplication';
|
||||
import {
|
||||
getAgentDeviceRemoteSessionEnvOrThrow,
|
||||
runAgentDeviceRemoteSessionAsync,
|
||||
} from '../startAgentDeviceRemoteSession';
|
||||
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
|
||||
import { startAndroidEmulatorAsync } from '../startAndroidEmulator';
|
||||
import { bootIosSimulatorAsync } from '../startIosSimulator';
|
||||
|
||||
jest.mock('../../utils/localEgressSession', () => ({
|
||||
withLocalEgressSession: (fn: unknown) => fn,
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../startIosSimulator', () => ({ bootIosSimulatorAsync: jest.fn() }));
|
||||
jest.mock('../startAndroidEmulator', () => ({ startAndroidEmulatorAsync: jest.fn() }));
|
||||
jest.mock('../downloadBuild', () => ({ downloadBuildAsync: jest.fn() }));
|
||||
jest.mock('../installBuild', () => ({ installBuildAsync: jest.fn() }));
|
||||
jest.mock('../launchApplication', () => ({
|
||||
...jest.requireActual('../launchApplication'),
|
||||
launchApplicationAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../startAgentDeviceRemoteSession', () => ({
|
||||
getAgentDeviceRemoteSessionEnvOrThrow: jest.fn(),
|
||||
runAgentDeviceRemoteSessionAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const graphqlClient = { query: jest.fn() };
|
||||
const ctx = { graphqlClient } as unknown as CustomBuildContext;
|
||||
const sessionEnv = {
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
ngrokTunnelDomain: 'tunnel.example.com',
|
||||
ngrokAuthtoken: 'ngrok-token',
|
||||
};
|
||||
|
||||
type Device = { booted: Promise<unknown>; ready: Promise<unknown> };
|
||||
|
||||
function deferred<T = void>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (err: Error) => void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (err: Error) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
promise.catch(() => {});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function flushAsync(): Promise<void> {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
function runStep(
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
inputValues: Record<string, unknown> = {}
|
||||
): Promise<void> {
|
||||
const buildFunction = createStartAgentDeviceSessionBuildFunction(ctx);
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const inputs = Object.fromEntries(
|
||||
[
|
||||
'device_identifier',
|
||||
'system_image_package',
|
||||
'lcd_width',
|
||||
'lcd_height',
|
||||
'lcd_density',
|
||||
'build_id',
|
||||
'application_archive_url',
|
||||
'launch_args',
|
||||
'open_url',
|
||||
'network_capture',
|
||||
'network_capture_fields',
|
||||
'package_version',
|
||||
'max_idle_time_minutes',
|
||||
'max_duration_seconds',
|
||||
].map(id => [id, { value: inputValues[id] }])
|
||||
);
|
||||
return buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: {
|
||||
runtimePlatform,
|
||||
staticContext: { job: { secrets: { robotAccessToken: 'robot-token' } } },
|
||||
},
|
||||
} as unknown as BuildStepContext,
|
||||
{ inputs, outputs: {}, env: {}, signal: undefined } as never
|
||||
) as Promise<void>;
|
||||
}
|
||||
|
||||
/** The `device` promises handed to the shared agent-device session code. */
|
||||
function sessionDevice(): Device {
|
||||
return jest.mocked(runAgentDeviceRemoteSessionAsync).mock.calls[0][1].device;
|
||||
}
|
||||
|
||||
describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(getAgentDeviceRemoteSessionEnvOrThrow).mockReturnValue(sessionEnv);
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async (_ctx, { device }) => {
|
||||
await device.ready;
|
||||
});
|
||||
jest.mocked(bootIosSimulatorAsync).mockResolvedValue({
|
||||
deviceIdentifier: 'iPhone 17' as never,
|
||||
udid: 'udid' as never,
|
||||
displayName: 'iPhone 17',
|
||||
});
|
||||
jest.mocked(startAndroidEmulatorAsync).mockResolvedValue({
|
||||
serialId: 'emulator-5554' as never,
|
||||
emulatorPromise: Promise.resolve(),
|
||||
shouldAdjustAnimationScale: true,
|
||||
});
|
||||
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/App.app' });
|
||||
jest
|
||||
.mocked(installBuildAsync)
|
||||
.mockResolvedValue({ applicationIdentifier: 'dev.example.app', activityName: '.Main' });
|
||||
jest.mocked(launchApplicationAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('downloads the build and starts the session while the iOS Simulator boots', async () => {
|
||||
const boot = deferred<Awaited<ReturnType<typeof bootIosSimulatorAsync>>>();
|
||||
jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise);
|
||||
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, {
|
||||
build_id: 'build-id',
|
||||
launch_args: ['-flag'],
|
||||
open_url: 'exp://example.test',
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
expect(
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(jest.mocked(bootIosSimulatorAsync).mock.invocationCallOrder[0]);
|
||||
expect(downloadBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
buildId: 'build-id',
|
||||
extensions: ['app'],
|
||||
graphqlClient,
|
||||
robotAccessToken: 'robot-token',
|
||||
})
|
||||
);
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(1);
|
||||
// The install needs the booted Simulator.
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
|
||||
boot.resolve({
|
||||
deviceIdentifier: 'iPhone 17' as never,
|
||||
udid: 'udid' as never,
|
||||
displayName: '',
|
||||
});
|
||||
await step;
|
||||
|
||||
expect(installBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ artifactPath: '/tmp/App.app' })
|
||||
);
|
||||
expect(launchApplicationAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
applicationIdentifier: 'dev.example.app',
|
||||
launchArgs: ['-flag'],
|
||||
openUrl: 'exp://example.test',
|
||||
})
|
||||
);
|
||||
expect(jest.mocked(installBuildAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(launchApplicationAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('waits for the download when the Simulator boots first', async () => {
|
||||
const download = deferred<{ artifactPath: string }>();
|
||||
jest.mocked(downloadBuildAsync).mockReturnValue(download.promise);
|
||||
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, {
|
||||
application_archive_url: 'https://example.test/app.tar.gz',
|
||||
});
|
||||
await flushAsync();
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
|
||||
download.resolve({ artifactPath: '/tmp/App.app' });
|
||||
await step;
|
||||
expect(installBuildAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('makes the session ready when the device boots if there is no app', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN);
|
||||
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
await expect(sessionDevice().ready).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('boots the Android Emulator with the device inputs', async () => {
|
||||
await runStep(BuildRuntimePlatform.LINUX, {
|
||||
device_identifier: 'pixel_7',
|
||||
system_image_package: 'system-images;android-35-ext15;google_apis_playstore;x86_64',
|
||||
lcd_width: 720,
|
||||
lcd_height: 1600,
|
||||
lcd_density: 300,
|
||||
build_id: 'build-id',
|
||||
});
|
||||
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deviceIdentifier: 'pixel_7',
|
||||
systemImagePackage: 'system-images;android-35-ext15;google_apis_playstore;x86_64',
|
||||
lcdWidth: 720,
|
||||
lcdHeight: 1600,
|
||||
lcdDensity: 300,
|
||||
})
|
||||
);
|
||||
expect(downloadBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ extensions: ['apk'] })
|
||||
);
|
||||
expect(launchApplicationAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ applicationIdentifier: 'dev.example.app', activityName: '.Main' })
|
||||
);
|
||||
});
|
||||
|
||||
it('fails the session when the download fails, without an unhandled rejection', async () => {
|
||||
const boot = deferred<Awaited<ReturnType<typeof bootIosSimulatorAsync>>>();
|
||||
jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise);
|
||||
jest.mocked(downloadBuildAsync).mockRejectedValue(new Error('download failed'));
|
||||
|
||||
const stepFailure = expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })
|
||||
).rejects.toThrow('download failed');
|
||||
// The download fails while the boot still runs. Nothing inside the step may leave
|
||||
// that rejection unhandled; Jest fails the test if it does.
|
||||
await flushAsync();
|
||||
boot.resolve({
|
||||
deviceIdentifier: 'iPhone 17' as never,
|
||||
udid: 'udid' as never,
|
||||
displayName: '',
|
||||
});
|
||||
|
||||
await stepFailure;
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/** A download that runs until its abort signal fires, like a stalled one. */
|
||||
function mockStalledDownload(): { aborted: () => boolean; settled: () => boolean } {
|
||||
let aborted = false;
|
||||
let settled = false;
|
||||
jest.mocked(downloadBuildAsync).mockImplementation(
|
||||
({ signal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
signal!.addEventListener('abort', () => {
|
||||
aborted = true;
|
||||
// Settles a bit later, like a request that winds down.
|
||||
setImmediate(() => {
|
||||
settled = true;
|
||||
reject(new Error('The user aborted a request.'));
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
return { aborted: () => aborted, settled: () => settled };
|
||||
}
|
||||
|
||||
/** Runs the session like the real one: fails with the daemon, then waits for every part. */
|
||||
function mockSessionWithFailingDaemon(beforeFailure?: () => Promise<void>): void {
|
||||
jest
|
||||
.mocked(runAgentDeviceRemoteSessionAsync)
|
||||
.mockImplementation(async (_ctx, { tasks, device }) => {
|
||||
await beforeFailure?.();
|
||||
const daemon = tasks.run('agent-device daemon', async () => {
|
||||
throw new Error('daemon failed');
|
||||
});
|
||||
try {
|
||||
await Promise.all([daemon, device.ready]);
|
||||
} finally {
|
||||
await Promise.allSettled([daemon, device.ready]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('stops the download and waits for it when the boot fails', async () => {
|
||||
const download = mockStalledDownload();
|
||||
jest.mocked(bootIosSimulatorAsync).mockRejectedValue(new Error('boot failed'));
|
||||
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'boot failed'
|
||||
);
|
||||
|
||||
expect(download.aborted()).toBe(true);
|
||||
// The step returned only after the download stopped.
|
||||
expect(download.settled()).toBe(true);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops a stalled download and does not install when the daemon fails', async () => {
|
||||
const download = mockStalledDownload();
|
||||
mockSessionWithFailingDaemon();
|
||||
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'daemon failed'
|
||||
);
|
||||
|
||||
expect(download.aborted()).toBe(true);
|
||||
expect(download.settled()).toBe(true);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not launch the app when startup fails during the install', async () => {
|
||||
const install = deferred<Awaited<ReturnType<typeof installBuildAsync>>>();
|
||||
const installStarted = deferred();
|
||||
jest.mocked(installBuildAsync).mockImplementation(() => {
|
||||
installStarted.resolve();
|
||||
return install.promise;
|
||||
});
|
||||
mockSessionWithFailingDaemon(async () => {
|
||||
await installStarted.promise;
|
||||
// The install finishes only after the daemon failed.
|
||||
setImmediate(() => install.resolve({ applicationIdentifier: 'dev.example.app' }));
|
||||
});
|
||||
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'daemon failed'
|
||||
);
|
||||
|
||||
expect(installBuildAsync).toHaveBeenCalledTimes(1);
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects conflicting application inputs before it boots anything', async () => {
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, {
|
||||
build_id: 'build-id',
|
||||
application_archive_url: 'https://example.test/app.tar.gz',
|
||||
})
|
||||
).rejects.toThrow('Pass only one of build_id or application_archive_url.');
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { open_url: 'exp://example.test' })
|
||||
).rejects.toThrow('launch_args and open_url need an application');
|
||||
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('declares the step inputs', () => {
|
||||
const buildFunction = createStartAgentDeviceSessionBuildFunction(ctx);
|
||||
const globalCtx = createGlobalContextMock();
|
||||
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual([
|
||||
'device_identifier',
|
||||
'system_image_package',
|
||||
'lcd_width',
|
||||
'lcd_height',
|
||||
'lcd_density',
|
||||
'build_id',
|
||||
'application_archive_url',
|
||||
'launch_args',
|
||||
'open_url',
|
||||
'network_capture',
|
||||
'network_capture_fields',
|
||||
'package_version',
|
||||
'max_idle_time_minutes',
|
||||
'max_duration_seconds',
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes network capture to the session', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN, {
|
||||
network_capture: true,
|
||||
network_capture_fields: ['header', 'response-body'],
|
||||
});
|
||||
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
capture: { networkCapture: true, networkCaptureFields: ['header', 'response-body'] },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects network capture on Android before it boots anything', async () => {
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.LINUX, { network_capture: true, system_image_package: 'x' })
|
||||
).rejects.toThrow('records traffic through serve-sim on an iOS simulator');
|
||||
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../../utils/AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../../../utils/retry';
|
||||
import { resolveAndroidEmulatorLocalEgressAsync } from '../../utils/androidLocalEgress';
|
||||
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
@@ -19,6 +20,10 @@ jest.mock('../../../utils/retry', () => ({
|
||||
retryAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/androidLocalEgress', () => ({
|
||||
resolveAndroidEmulatorLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
AndroidEmulatorUtils: {
|
||||
defaultSystemImagePackage: 'system-images;android-30;default;x86_64',
|
||||
@@ -35,6 +40,7 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedRetryAsync = jest.mocked(retryAsync);
|
||||
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
|
||||
const mockedResolveLocalEgress = jest.mocked(resolveAndroidEmulatorLocalEgressAsync);
|
||||
const mockedMkdtemp = jest.spyOn(fs.promises, 'mkdtemp');
|
||||
function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.ProcessEnv) {
|
||||
const logger = createMockLogger();
|
||||
@@ -65,6 +71,7 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined);
|
||||
mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync.mockResolvedValue(undefined);
|
||||
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined);
|
||||
mockedResolveLocalEgress.mockResolvedValue(null);
|
||||
|
||||
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
|
||||
let lastErr: unknown;
|
||||
@@ -83,6 +90,82 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
mockedMkdtemp.mockRestore();
|
||||
});
|
||||
|
||||
describe('local egress', () => {
|
||||
function createLocalEgress() {
|
||||
return {
|
||||
launchGate: {
|
||||
emulatorArgs: ['-http-proxy', '127.0.0.1:8898', '-no-metrics'],
|
||||
wrapperCommand: 'bash',
|
||||
wrapperArgs: ['-c', 'gate', 'eas-egress-gate'],
|
||||
admitAsync: jest.fn(),
|
||||
},
|
||||
networkReadyTarget: { host: '192.0.2.1', port: 443 },
|
||||
configureBootedEmulatorAsync: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
it('starts the emulator exactly as before without a local egress session', async () => {
|
||||
await createStep().executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith({
|
||||
deviceName: 'EasAndroidDevice01',
|
||||
env: expect.any(Object),
|
||||
logcatDirectory: '/tmp/logcat-directory',
|
||||
});
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith({
|
||||
env: expect.any(Object),
|
||||
serialId: 'emulator-default',
|
||||
timeoutMs: 60_000,
|
||||
logger: expect.anything(),
|
||||
});
|
||||
expect(
|
||||
mockedSpawn.mock.calls.filter(
|
||||
([command, args]) =>
|
||||
['sudo', 'nft', 'systemd-run'].includes(command) ||
|
||||
(command === 'adb' && (args.includes('reverse') || args.includes('http_proxy')))
|
||||
)
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('fences, gates and configures the emulator', async () => {
|
||||
const localEgress = createLocalEgress();
|
||||
mockedResolveLocalEgress.mockResolvedValue(localEgress);
|
||||
mockedAndroidUtils.startAsync.mockResolvedValueOnce(createStartResult('emulator-base'));
|
||||
|
||||
await createStep().executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ launchGate: localEgress.launchGate })
|
||||
);
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ networkReadyTarget: { host: '192.0.2.1', port: 443 } })
|
||||
);
|
||||
expect(localEgress.configureBootedEmulatorAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ serialId: 'emulator-base' })
|
||||
);
|
||||
});
|
||||
|
||||
it('retries a boot whose local egress configuration fails', async () => {
|
||||
const localEgress = createLocalEgress();
|
||||
localEgress.configureBootedEmulatorAsync
|
||||
.mockRejectedValueOnce(new Error('netsimd outside the fence'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
mockedResolveLocalEgress.mockResolvedValue(localEgress);
|
||||
mockedAndroidUtils.startAsync
|
||||
.mockResolvedValueOnce(createStartResult('emulator-1111'))
|
||||
.mockResolvedValueOnce(createStartResult('emulator-2222'));
|
||||
|
||||
await createStep().executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ serialId: 'emulator-1111' })
|
||||
);
|
||||
expect(localEgress.configureBootedEmulatorAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ serialId: 'emulator-2222' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('passes profile and LCD inputs to emulator creation', async () => {
|
||||
const systemImagePackage = 'system-images;android-35;default;x86_64';
|
||||
await createStep({
|
||||
|
||||
+84
-1
@@ -1,10 +1,19 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
|
||||
|
||||
import { resolveAppium3VersionSpec, resolveAppiumDeviceAsync } from '../startAppiumRemoteSession';
|
||||
import {
|
||||
createStartAppiumRemoteSessionBuildFunction,
|
||||
installAppiumAsync,
|
||||
resolveAppium3VersionSpec,
|
||||
resolveAppiumDeviceAsync,
|
||||
} from '../startAppiumRemoteSession';
|
||||
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
AndroidEmulatorUtils: { getAttachedDevicesAsync: jest.fn() },
|
||||
@@ -13,8 +22,10 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
IosSimulatorUtils: { getAvailableDevicesAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as never;
|
||||
|
||||
@@ -84,3 +95,75 @@ describe(resolveAppiumDeviceAsync, () => {
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(installAppiumAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args.includes('--json')) {
|
||||
return { stdout: JSON.stringify({}) };
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as never);
|
||||
});
|
||||
|
||||
it('installs Appium with npm by default', async () => {
|
||||
const result = await installAppiumAsync({
|
||||
versionSpec: '^3',
|
||||
driverName: 'xcuitest',
|
||||
env: { EXISTING: 'value' },
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'npm',
|
||||
['install', '--no-audit', 'appium@^3'],
|
||||
expect.objectContaining({
|
||||
cwd: result.appiumHome,
|
||||
env: expect.objectContaining({ APPIUM_HOME: result.appiumHome, EXISTING: 'value' }),
|
||||
})
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
result.appiumBinPath,
|
||||
['driver', 'install', 'xcuitest'],
|
||||
expect.objectContaining({ env: result.appiumEnv, logger })
|
||||
);
|
||||
} finally {
|
||||
await fs.promises.rm(result.appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('installs Appium with bun add when EAS_OVERRIDE_PACKAGE_MANAGER is bun', async () => {
|
||||
const result = await installAppiumAsync({
|
||||
versionSpec: '3.5.0',
|
||||
driverName: 'uiautomator2',
|
||||
env: { EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' },
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'bun',
|
||||
['add', '[email protected]'],
|
||||
expect.objectContaining({ cwd: result.appiumHome })
|
||||
);
|
||||
} finally {
|
||||
await fs.promises.rm(result.appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('createStartAppiumRemoteSessionBuildFunction', () => {
|
||||
it('declares the launch inputs so serve-sim can launch the app', () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const buildFunction = createStartAppiumRemoteSessionBuildFunction(ctx);
|
||||
const globalCtx = createGlobalContextMock();
|
||||
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
|
||||
});
|
||||
});
|
||||
+208
-7
@@ -4,7 +4,9 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
|
||||
import { startArgentEventCollectionAsync } from '../../utils/argentEvents';
|
||||
@@ -15,7 +17,6 @@ import {
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
@@ -41,14 +42,17 @@ jest.mock('../../utils/argentEvents', () => ({
|
||||
...jest.requireActual('../../utils/argentEvents'),
|
||||
startArgentEventCollectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
finishRemoteSessionAsync: jest.requireActual('../../utils/remoteDeviceRunSession')
|
||||
.finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow: jest.fn(),
|
||||
getNgrokAuthtokenOrThrow: jest.fn(),
|
||||
getNgrokTunnelDomainOrThrow: jest.fn(),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
spawnDetached: jest.fn(),
|
||||
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
@@ -84,15 +88,21 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 4242,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: jest.fn(),
|
||||
});
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
|
||||
url: 'https://argent-abc.tunnel.example.com',
|
||||
subdomainId: 'argent-abc',
|
||||
stopAsync: mockTunnelStopAsync,
|
||||
});
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewUrl: 'https://web-preview.tunnel.example.com',
|
||||
stopAsync: mockPreviewStopAsync,
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
@@ -109,6 +119,75 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
await fs.promises.rm(TEST_HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports an early exit with output before opening tunnels or publishing readiness', async () => {
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 4242,
|
||||
getOutput: () => 'could not bind server port',
|
||||
getExitError: () => new Error('process exited with code 1'),
|
||||
stopAsync: jest.fn(),
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
).rejects.toThrow(
|
||||
'Argent exited before becoming ready: process exited with code 1\nArgent tool-server output:\ncould not bind server port'
|
||||
);
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['preview', 'config', 'wait'])(
|
||||
'finishes the host and tools after %s fails',
|
||||
async phase => {
|
||||
const error = new Error(`${phase} failed`);
|
||||
if (phase === 'preview') {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync: jest.fn().mockRejectedValue(error),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
});
|
||||
} else if (phase === 'config') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
|
||||
} else {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
|
||||
}
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
).rejects.toBe(error);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(jest.mocked(spawnDetached).mock.results[0].value.stopAsync).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('enables the event log flag, shares one path, and starts/stops the collector', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
@@ -155,6 +234,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
// (3) 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.ARGENT_EMULATOR_NO_WINDOW).toBe('1');
|
||||
expect(serverEnv.EXISTING).toBe('value');
|
||||
expect(jest.mocked(startArgentEventCollectionAsync).mock.calls[0][0]).toMatchObject({
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
@@ -172,17 +252,138 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
|
||||
);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
webPreviewUrl: 'https://web-preview.tunnel.example.com',
|
||||
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewApiUrl: 'https://web-preview.tunnel.example.com',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
max_duration_seconds: { value: undefined },
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
|
||||
open_url: { value: 'exp://127.0.0.1:8081' },
|
||||
},
|
||||
outputs: {},
|
||||
env: { EXISTING: 'value' },
|
||||
} as never
|
||||
);
|
||||
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
})
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'serve-sim will launch host.exp.Exponent with arguments ' +
|
||||
'["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails before starting anything when a launch is asked for on Android', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
max_duration_seconds: { value: undefined },
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
).rejects.toThrow('runs on linux');
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('declares the launch inputs', () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
const globalCtx = createGlobalContextMock();
|
||||
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
|
||||
});
|
||||
|
||||
it('stops automation without waiting for recording upload', async () => {
|
||||
let release!: () => void;
|
||||
let stopped!: () => void;
|
||||
const pendingFinish = new Promise<void>(resolve => {
|
||||
release = resolve;
|
||||
});
|
||||
const toolStopped = new Promise<void>(resolve => {
|
||||
stopped = resolve;
|
||||
});
|
||||
mockPreviewStopAsync.mockReturnValueOnce(pendingFinish);
|
||||
const stopServer = jest.fn(async () => {
|
||||
stopped();
|
||||
});
|
||||
jest.mocked(spawnDetached).mockReturnValueOnce({
|
||||
pid: 4242,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopServer,
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
const running = buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
try {
|
||||
await toolStopped;
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
release();
|
||||
await running;
|
||||
}
|
||||
});
|
||||
});
|
||||
+54
-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 the package manager resolve it.')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -94,6 +94,59 @@ describe(waitForArgentToolServerStateAsync, () => {
|
||||
return JSON.stringify({ port, pid, token });
|
||||
}
|
||||
|
||||
it('reports process exit instead of waiting for the startup timeout', async () => {
|
||||
const getExitError = jest.fn(() => new Error('server exited with code 1'));
|
||||
await expect(
|
||||
waitForArgentToolServerStateAsync({
|
||||
stateDir,
|
||||
ancestorPid: process.pid,
|
||||
timeoutMs: 100,
|
||||
pollIntervalMs: 1,
|
||||
getExitError,
|
||||
})
|
||||
).rejects.toThrow('server exited with code 1');
|
||||
expect(getExitError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('detects an exit after polling has begun', async () => {
|
||||
const getExitError = jest
|
||||
.fn<Error | undefined, []>()
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValue(new Error('server stopped'));
|
||||
await expect(
|
||||
waitForArgentToolServerStateAsync({
|
||||
stateDir,
|
||||
ancestorPid: process.pid,
|
||||
timeoutMs: 100,
|
||||
pollIntervalMs: 1,
|
||||
getExitError,
|
||||
})
|
||||
).rejects.toThrow('server stopped');
|
||||
expect(getExitError).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('rejects an exit observed while reading a matching state file', async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(stateDir, 'tool-server.json'),
|
||||
stateJson({ port: 4321, pid: process.pid })
|
||||
);
|
||||
const getExitError = jest
|
||||
.fn<Error | undefined, []>()
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValue(new Error('server exited during startup'));
|
||||
await expect(
|
||||
waitForArgentToolServerStateAsync({
|
||||
stateDir,
|
||||
ancestorPid: process.ppid,
|
||||
timeoutMs: 100,
|
||||
pollIntervalMs: 1,
|
||||
getExitError,
|
||||
})
|
||||
).rejects.toThrow('server exited during startup');
|
||||
expect(getExitError).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each(['tool-server.json', 'tool-server-012345abcdef.json'])(
|
||||
'reads a matching process from %s',
|
||||
async fileName => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type BuildStepContext } from '@expo/steps';
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { startAndroidLocalEgressAsync } from '../../utils/androidLocalEgress';
|
||||
import {
|
||||
LOCAL_EGRESS_HANDOFF_PATH,
|
||||
configureSystemProxyAsync,
|
||||
@@ -20,6 +21,9 @@ jest.mock('../../utils/localEgress', () => ({
|
||||
startChiselServerAsync: jest.fn(),
|
||||
writeLocalEgressHandoffAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/androidLocalEgress', () => ({
|
||||
startAndroidLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/localEgressGuard', () => ({
|
||||
stopLocalEgressGuardRelaysAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
@@ -31,8 +35,17 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
}));
|
||||
|
||||
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() };
|
||||
const server = {
|
||||
pid: 12345,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: jest.fn(),
|
||||
};
|
||||
const tunnel = {
|
||||
url: 'https://egress.example.com',
|
||||
subdomainId: 'egress-id',
|
||||
stopAsync: jest.fn(),
|
||||
};
|
||||
|
||||
async function start(signal?: AbortSignal): Promise<void> {
|
||||
await createStartLocalEgressBuildFunction().fn!({ logger } as BuildStepContext, {
|
||||
@@ -43,6 +56,82 @@ async function start(signal?: AbortSignal): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function startOn(runtimePlatform: BuildRuntimePlatform): Promise<void> {
|
||||
await createStartLocalEgressBuildFunction().fn!(
|
||||
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
|
||||
{
|
||||
env: {},
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
} as any
|
||||
);
|
||||
}
|
||||
|
||||
describe('local egress platforms', () => {
|
||||
const androidStop = jest.fn();
|
||||
|
||||
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);
|
||||
androidStop.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);
|
||||
jest.mocked(startAndroidLocalEgressAsync).mockResolvedValue({ stopAsync: androidStop });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('sets the macOS system proxy and writes the same handoff as before', async () => {
|
||||
await startOn(BuildRuntimePlatform.DARWIN);
|
||||
expect(configureSystemProxyAsync).toHaveBeenCalled();
|
||||
expect(startAndroidLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(writeLocalEgressHandoffAsync).toHaveBeenCalledWith({
|
||||
url: tunnel.url,
|
||||
token: expect.any(String),
|
||||
fingerprint: 'key=',
|
||||
port: 8899,
|
||||
});
|
||||
});
|
||||
|
||||
it('starts the Android relay and fence on Linux instead of the system proxy', async () => {
|
||||
await startOn(BuildRuntimePlatform.LINUX);
|
||||
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
|
||||
expect(startAndroidLocalEgressAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chiselPid: server.pid,
|
||||
proxyPort: 8899,
|
||||
controlPort: 52001,
|
||||
workDir: '/tmp/egress-acquisition-test',
|
||||
})
|
||||
);
|
||||
expect(writeLocalEgressHandoffAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ platform: 'android' })
|
||||
);
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
expect(androidStop).toHaveBeenCalledTimes(1);
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('releases the tunnel when the Android fence cannot start', async () => {
|
||||
jest.mocked(startAndroidLocalEgressAsync).mockRejectedValue(new Error('nft failed'));
|
||||
await expect(startOn(BuildRuntimePlatform.LINUX)).rejects.toThrow('nft failed');
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(tunnel.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(writeLocalEgressHandoffAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('local egress acquisition', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
+133
-14
@@ -1,25 +1,37 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, type BuildStepContext, type BuildStepEnv } from '@expo/steps';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
|
||||
|
||||
jest.mock('../../utils/remoteDeviceRunSession');
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
getDeviceRunSessionIdOrThrow: jest.fn(),
|
||||
getNgrokTunnelDomainOrThrow: jest.fn(),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
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> {
|
||||
async function runAsync(
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
launchInputs: Record<string, { value: unknown }> = {}
|
||||
): Promise<void> {
|
||||
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx);
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
@@ -30,6 +42,7 @@ async function runAsync(runtimePlatform: BuildRuntimePlatform): Promise<void> {
|
||||
inputs: {
|
||||
package_version: { value: '1.2.3' },
|
||||
max_duration_seconds: { value: 120 },
|
||||
...launchInputs,
|
||||
},
|
||||
outputs: {},
|
||||
env,
|
||||
@@ -43,9 +56,13 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
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(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
@@ -53,10 +70,14 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
});
|
||||
|
||||
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,
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
previewToken: 'tok-1',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
|
||||
await runAsync(BuildRuntimePlatform.DARWIN);
|
||||
@@ -64,13 +85,32 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: {
|
||||
previewUrl: 'https://web-preview.example.test',
|
||||
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewApiUrl: 'https://web-preview.example.test',
|
||||
webPreviewToken: 'tok-1',
|
||||
previewToken: 'tok-1',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['preview', 'config', 'wait'])('finishes the session after %s fails', async phase => {
|
||||
const error = new Error(`${phase} failed`);
|
||||
if (phase === 'preview') {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync: jest.fn().mockRejectedValue(error),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
} else if (phase === 'config') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
|
||||
} else {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
|
||||
}
|
||||
await expect(runAsync(BuildRuntimePlatform.LINUX)).rejects.toBe(error);
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[BuildRuntimePlatform.DARWIN, true],
|
||||
[BuildRuntimePlatform.LINUX, false],
|
||||
@@ -78,18 +118,26 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
await runAsync(runtimePlatform);
|
||||
|
||||
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(selectsXcode ? 1 : 0);
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(ctx, {
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: 'tunnel.example.com',
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 60_000,
|
||||
packageVersion: '1.2.3',
|
||||
launchAppIdentifier: undefined,
|
||||
launchArgs: [],
|
||||
openUrl: undefined,
|
||||
networkCapture: false,
|
||||
networkCaptureFields: [],
|
||||
});
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
remoteConfig: { previewUrl: 'https://web-preview.example.test' },
|
||||
remoteConfig: {
|
||||
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewApiUrl: 'https://web-preview.example.test',
|
||||
},
|
||||
logger,
|
||||
});
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith({
|
||||
@@ -101,4 +149,75 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
});
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('declares the launch inputs', () => {
|
||||
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx);
|
||||
const globalCtx = createGlobalContextMock();
|
||||
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual([
|
||||
'launch_app_identifier',
|
||||
'launch_args',
|
||||
'open_url',
|
||||
'network_capture',
|
||||
'network_capture_fields',
|
||||
'package_version',
|
||||
'max_duration_seconds',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hands the launch inputs to the session host and announces them', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, {
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
|
||||
open_url: { value: 'exp://127.0.0.1:8081' },
|
||||
});
|
||||
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
})
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'serve-sim will launch host.exp.Exponent with arguments ' +
|
||||
'["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.'
|
||||
);
|
||||
});
|
||||
|
||||
it('hands network capture to the session host', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, {
|
||||
network_capture: { value: true },
|
||||
network_capture_fields: { value: ['header', 'response-body'] },
|
||||
});
|
||||
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
networkCapture: true,
|
||||
networkCaptureFields: ['header', 'response-body'],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('fails before starting anything when network capture is asked for on Android', async () => {
|
||||
await expect(
|
||||
runAsync(BuildRuntimePlatform.LINUX, {
|
||||
network_capture: { value: true },
|
||||
})
|
||||
).rejects.toThrow('this session runs on linux');
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails before starting anything when a launch is asked for on Android', async () => {
|
||||
await expect(
|
||||
runAsync(BuildRuntimePlatform.LINUX, {
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
})
|
||||
).rejects.toThrow('runs on linux');
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,621 @@
|
||||
import nock from 'nock';
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { AscApiClient } from '../../utils/ios/AscApiClient';
|
||||
import {
|
||||
createUpdateTestFlightMetadataBuildFunction,
|
||||
updateTestFlightMetadataAsync,
|
||||
} from '../updateTestFlightMetadata';
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
jest.mock('../../../sentry', () => ({ Sentry: { capture: jest.fn() } }));
|
||||
|
||||
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
||||
const key = { keyId: 'TESTKEY', privateKey };
|
||||
const changelog = 'Test "quotes"\n$(not-a-command)';
|
||||
const options = {
|
||||
client: new AscApiClient({ key }),
|
||||
buildUploadId: 'upload',
|
||||
changelog,
|
||||
groups: [] as string[],
|
||||
logger: createMockLogger(),
|
||||
};
|
||||
const api = () => nock('https://api.appstoreconnect.apple.com');
|
||||
|
||||
function mockBuild(
|
||||
state = 'COMPLETE',
|
||||
buildId: string | null = 'build',
|
||||
primaryLocale: string | null = 'en-US'
|
||||
): void {
|
||||
api()
|
||||
.get('/v1/buildUploads/upload')
|
||||
.query(true)
|
||||
.reply(200, {
|
||||
data: {
|
||||
type: 'buildUploads',
|
||||
id: 'upload',
|
||||
attributes: { state: { state } },
|
||||
relationships: { build: { data: buildId ? { type: 'builds', id: buildId } : null } },
|
||||
},
|
||||
});
|
||||
if (state === 'COMPLETE' && buildId) {
|
||||
api()
|
||||
.get(`/v1/builds/${buildId}/app`)
|
||||
.query(true)
|
||||
.reply(200, {
|
||||
data: { id: 'app', attributes: primaryLocale ? { primaryLocale } : {} },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function mockAssignedGroups(ids: string[] = []): void {
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[builds]': 'build', limit: '200' })
|
||||
.reply(200, { data: ids.map(id => ({ id })) });
|
||||
}
|
||||
|
||||
function mockInternalBuildState(state: string | null = 'READY_FOR_BETA_TESTING'): void {
|
||||
api()
|
||||
.get('/v1/builds/build/buildBetaDetail')
|
||||
.query(true)
|
||||
.reply(
|
||||
state ? 200 : 503,
|
||||
state
|
||||
? { data: { id: 'detail', attributes: { internalBuildState: state } } }
|
||||
: { errors: [{ code: 'UNAVAILABLE' }] }
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(() => nock.disableNetConnect());
|
||||
beforeEach(() => {
|
||||
jest.mocked(Sentry.capture).mockClear();
|
||||
options.logger = createMockLogger();
|
||||
options.client = new AscApiClient({ key });
|
||||
});
|
||||
afterAll(() => nock.enableNetConnect());
|
||||
afterEach(() => {
|
||||
try {
|
||||
expect(nock.pendingMocks()).toEqual([]);
|
||||
} finally {
|
||||
nock.cleanAll();
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts group arrays and preserves changelog input without shell parsing', async () => {
|
||||
const fn = createUpdateTestFlightMetadataBuildFunction();
|
||||
const spy = jest.spyOn(fn, 'fn').mockImplementation(async (_ctx, { inputs }) => {
|
||||
expect(inputs.groups.value).toEqual(['A, "B"']);
|
||||
expect(inputs.changelog.value).toBe(changelog);
|
||||
});
|
||||
const step = fn.createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: {
|
||||
asc_api_key_path: '/tmp/key.json',
|
||||
build_upload_id: 'upload',
|
||||
groups: ['A, "B"'],
|
||||
changelog,
|
||||
},
|
||||
});
|
||||
await step.executeAsync();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses empty metadata defaults', async () => {
|
||||
const fn = createUpdateTestFlightMetadataBuildFunction();
|
||||
const spy = jest.spyOn(fn, 'fn').mockImplementation(async (_ctx, { inputs }) => {
|
||||
expect(inputs.groups.value).toEqual([]);
|
||||
expect(inputs.changelog.value).toBe('');
|
||||
});
|
||||
const step = fn.createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { asc_api_key_path: '/tmp/key.json', build_upload_id: 'upload' },
|
||||
});
|
||||
await step.executeAsync();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips automatic internal groups and groups that already contain the build', async () => {
|
||||
mockBuild();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, {
|
||||
data: [
|
||||
{
|
||||
id: 'automatic',
|
||||
attributes: { name: 'Internal', isInternalGroup: true, hasAccessToAllBuilds: true },
|
||||
},
|
||||
{ id: 'assigned', attributes: { name: 'QA' } },
|
||||
{
|
||||
id: 'manual',
|
||||
attributes: { name: 'External', isInternalGroup: false, hasAccessToAllBuilds: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[builds]': 'build', limit: '200' })
|
||||
.reply(200, { data: [{ id: 'assigned', attributes: { name: 'QA' } }] });
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id: 'manual' }],
|
||||
})
|
||||
.reply(204);
|
||||
await updateTestFlightMetadataAsync({
|
||||
...options,
|
||||
changelog: '',
|
||||
groups: ['Internal', 'QA', 'External'],
|
||||
});
|
||||
expect(options.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"Internal" (automatic): automatic access')
|
||||
);
|
||||
expect(options.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"QA" (assigned): build already assigned')
|
||||
);
|
||||
expect(options.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"External" (manual): assignment completed')
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['en-US', null])(
|
||||
'creates a localization when none exist (primary locale: %s)',
|
||||
async primaryLocale => {
|
||||
mockBuild('COMPLETE', 'build', primaryLocale);
|
||||
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
|
||||
api()
|
||||
.post('/v1/betaBuildLocalizations', {
|
||||
data: {
|
||||
type: 'betaBuildLocalizations',
|
||||
attributes: { locale: 'en-US', whatsNew: changelog },
|
||||
relationships: { build: { data: { type: 'builds', id: 'build' } } },
|
||||
},
|
||||
})
|
||||
.reply(201, { data: { id: 'locale' } });
|
||||
await updateTestFlightMetadataAsync(options);
|
||||
expect(options.logger.info).toHaveBeenCalledWith(
|
||||
`Updating changelog: ${JSON.stringify(changelog)}`
|
||||
);
|
||||
expect(options.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Locale "en-US": localization created')
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('reads all localization pages and updates existing localizations without replacing them', async () => {
|
||||
mockBuild();
|
||||
api()
|
||||
.get('/v1/builds/build/betaBuildLocalizations')
|
||||
.query(true)
|
||||
.reply(200, {
|
||||
data: [{ id: 'en-US', attributes: { locale: 'en-US' } }],
|
||||
links: {
|
||||
next: 'https://api.appstoreconnect.apple.com/v1/builds/build/betaBuildLocalizations?limit=200&cursor=next',
|
||||
},
|
||||
});
|
||||
api()
|
||||
.get('/v1/builds/build/betaBuildLocalizations')
|
||||
.query({ limit: '200', cursor: 'next' })
|
||||
.reply(200, { data: [{ id: 'pl', attributes: { locale: 'pl' } }] });
|
||||
for (const id of ['en-US', 'pl']) {
|
||||
api()
|
||||
.patch(`/v1/betaBuildLocalizations/${id}`, {
|
||||
data: { type: 'betaBuildLocalizations', id, attributes: { whatsNew: changelog } },
|
||||
})
|
||||
.reply(200, { data: { id } });
|
||||
}
|
||||
await updateTestFlightMetadataAsync(options);
|
||||
for (const id of ['en-US', 'pl']) {
|
||||
expect(options.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`Locale "${id}" (${id})`)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('updates by ID when Apple omits a localization locale and creates the primary locale', async () => {
|
||||
mockBuild();
|
||||
api()
|
||||
.get('/v1/builds/build/betaBuildLocalizations')
|
||||
.query(true)
|
||||
.reply(200, { data: [{ id: 'localization' }] });
|
||||
|
||||
api()
|
||||
.patch('/v1/betaBuildLocalizations/localization', {
|
||||
data: {
|
||||
type: 'betaBuildLocalizations',
|
||||
id: 'localization',
|
||||
attributes: { whatsNew: changelog },
|
||||
},
|
||||
})
|
||||
.reply(200, { data: { id: 'localization' } });
|
||||
api()
|
||||
.post('/v1/betaBuildLocalizations', {
|
||||
data: {
|
||||
type: 'betaBuildLocalizations',
|
||||
attributes: { locale: 'en-US', whatsNew: changelog },
|
||||
relationships: { build: { data: { type: 'builds', id: 'build' } } },
|
||||
},
|
||||
})
|
||||
.reply(201, { data: { id: 'primary' } });
|
||||
await updateTestFlightMetadataAsync(options);
|
||||
});
|
||||
|
||||
it('assigns requested groups when hasAccessToAllBuilds is null', async () => {
|
||||
mockBuild();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, {
|
||||
data: [
|
||||
{
|
||||
id: 'internal',
|
||||
attributes: { name: 'Internal', isInternalGroup: true, hasAccessToAllBuilds: false },
|
||||
},
|
||||
{
|
||||
id: 'external',
|
||||
attributes: {
|
||||
name: 'test external',
|
||||
isInternalGroup: false,
|
||||
hasAccessToAllBuilds: null,
|
||||
publicLinkEnabled: false,
|
||||
publicLink: 'https://testflight.apple.com/join/abc',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'requested-external',
|
||||
attributes: { name: 'Beta', isInternalGroup: false, hasAccessToAllBuilds: null },
|
||||
},
|
||||
],
|
||||
});
|
||||
for (const id of ['internal', 'requested-external']) {
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id }],
|
||||
})
|
||||
.reply(204);
|
||||
}
|
||||
await expect(
|
||||
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['Internal', 'Beta'] })
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('adds groups without changing changelog or submitting beta review', async () => {
|
||||
mockBuild();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, { data: [{ id: 'group', attributes: { name: 'A, "B"' } }] });
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id: 'group' }],
|
||||
})
|
||||
.reply(204);
|
||||
await updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A, "B"', 'A, "B"'] });
|
||||
});
|
||||
|
||||
it('reports missing groups independently of the changelog task', async () => {
|
||||
mockBuild();
|
||||
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
|
||||
api()
|
||||
.post('/v1/betaBuildLocalizations')
|
||||
.reply(201, { data: { id: 'locale' } });
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, { data: [] });
|
||||
await expect(updateTestFlightMetadataAsync({ ...options, groups: ['missing'] })).rejects.toThrow(
|
||||
'The following TestFlight group was not found in App Store Connect: "missing".'
|
||||
);
|
||||
});
|
||||
|
||||
it('reads all pages and adds every group with a requested name', async () => {
|
||||
mockBuild();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, {
|
||||
data: [{ id: 'first', attributes: { name: 'A' } }],
|
||||
links: {
|
||||
next: 'https://api.appstoreconnect.apple.com/v1/betaGroups?filter%5Bapp%5D=app&limit=200&cursor=page-2',
|
||||
},
|
||||
});
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200', cursor: 'page-2' })
|
||||
.reply(200, {
|
||||
data: [
|
||||
{ id: 'second', attributes: { name: 'A' } },
|
||||
{ id: 'other', attributes: { name: 'B' } },
|
||||
],
|
||||
});
|
||||
for (const id of ['first', 'second']) {
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id }],
|
||||
})
|
||||
.reply(204);
|
||||
}
|
||||
await updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A'] });
|
||||
});
|
||||
|
||||
it('assigns found groups before reporting missing requested names', async () => {
|
||||
mockBuild();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id: 'group' }],
|
||||
})
|
||||
.reply(204);
|
||||
await expect(
|
||||
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A', 'B', 'C'] })
|
||||
).rejects.toThrow(
|
||||
'The following TestFlight groups were not found in App Store Connect: "B", "C".'
|
||||
);
|
||||
});
|
||||
|
||||
it('attempts all localization changes and reports every failed update', async () => {
|
||||
mockBuild();
|
||||
api()
|
||||
.get('/v1/builds/build/betaBuildLocalizations')
|
||||
.query(true)
|
||||
.reply(200, {
|
||||
data: ['pl', 'fr', 'de'].map(locale => ({ id: locale, attributes: { locale } })),
|
||||
});
|
||||
for (const id of ['pl', 'fr', 'de']) {
|
||||
api()
|
||||
.patch(`/v1/betaBuildLocalizations/${id}`)
|
||||
.reply(
|
||||
id === 'de' ? 200 : 403,
|
||||
id === 'de' ? { data: { id } } : { errors: [{ code: `${id}_FAILED` }] }
|
||||
);
|
||||
}
|
||||
api()
|
||||
.post('/v1/betaBuildLocalizations', {
|
||||
data: {
|
||||
type: 'betaBuildLocalizations',
|
||||
attributes: { locale: 'en-US', whatsNew: changelog },
|
||||
relationships: { build: { data: { type: 'builds', id: 'build' } } },
|
||||
},
|
||||
})
|
||||
.reply(201, { data: { id: 'locale' } });
|
||||
await expect(updateTestFlightMetadataAsync(options)).rejects.toThrow(/pl_FAILED.*fr_FAILED/);
|
||||
expect(options.logger.error).toHaveBeenCalledWith(expect.stringContaining('"pl" (pl)'));
|
||||
expect(options.logger.error).toHaveBeenCalledWith(expect.stringContaining('"fr" (fr)'));
|
||||
});
|
||||
|
||||
it.each([1, 2])('explains automatic internal group rejection (%s errors)', async errorCount => {
|
||||
mockBuild();
|
||||
mockInternalBuildState();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups')
|
||||
.reply(422, {
|
||||
errors: Array.from({ length: errorCount }, () => ({
|
||||
code: 'ENTITY_UNPROCESSABLE',
|
||||
title: 'Builds cannot be assigned to this internal group.',
|
||||
detail: 'Cannot add internal group to a build.',
|
||||
})),
|
||||
});
|
||||
await expect(
|
||||
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A'] })
|
||||
).rejects.toThrow(
|
||||
"App Store Connect can't add this build to a requested internal TestFlight group."
|
||||
);
|
||||
});
|
||||
|
||||
it('stops after 20 group pages', async () => {
|
||||
mockBuild();
|
||||
const next =
|
||||
'https://api.appstoreconnect.apple.com/v1/betaGroups?filter%5Bapp%5D=app&limit=200&cursor=next';
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, { data: [], links: { next } });
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200', cursor: 'next' })
|
||||
.times(19)
|
||||
.reply(200, { data: [], links: { next } });
|
||||
await expect(
|
||||
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A'] })
|
||||
).rejects.toThrow('We only support TestFlight group lists with up to 20 pages');
|
||||
});
|
||||
|
||||
it('tries group assignment if the changelog update fails', async () => {
|
||||
mockBuild();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
|
||||
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
|
||||
api()
|
||||
.post('/v1/betaBuildLocalizations')
|
||||
.reply(403, { errors: [{ code: 'FORBIDDEN', detail: 'Not allowed' }] });
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id: 'group' }],
|
||||
})
|
||||
.reply(204);
|
||||
await expect(updateTestFlightMetadataAsync({ ...options, groups: ['A'] })).rejects.toThrow('403');
|
||||
});
|
||||
|
||||
it('reports both write failures', async () => {
|
||||
mockBuild();
|
||||
mockInternalBuildState();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
|
||||
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
|
||||
api()
|
||||
.post('/v1/betaBuildLocalizations')
|
||||
.reply(403, { errors: [{ code: 'CHANGELOG_FAILED' }] });
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups')
|
||||
.reply(403, { errors: [{ code: 'GROUPS_FAILED' }] });
|
||||
await expect(updateTestFlightMetadataAsync({ ...options, groups: ['A'] })).rejects.toThrow(
|
||||
/CHANGELOG_FAILED.*GROUPS_FAILED/
|
||||
);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'attempts all groups and reports assignment errors (multiple: %s)',
|
||||
async multiple => {
|
||||
mockBuild();
|
||||
mockInternalBuildState();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, { data: ['A', 'B', 'C'].map(id => ({ id, attributes: { name: id } })) });
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id: 'A' }],
|
||||
})
|
||||
.reply(
|
||||
multiple ? 403 : 204,
|
||||
multiple ? { errors: [{ code: 'FIRST_GROUP_FAILED' }] } : undefined
|
||||
);
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id: 'B' }],
|
||||
})
|
||||
.reply(422, { errors: [{ code: 'ENTITY_UNPROCESSABLE', title: 'Partial assignment' }] });
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id: 'C' }],
|
||||
})
|
||||
.reply(204);
|
||||
await expect(
|
||||
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A', 'B', 'C'] })
|
||||
).rejects.toThrow(multiple ? 'FIRST_GROUP_FAILED' : 'Partial assignment');
|
||||
expect(options.logger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Group "B" (B): assignment failed.')
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('reads all membership pages before assigning groups', async () => {
|
||||
mockBuild();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[builds]': 'build', limit: '200' })
|
||||
.reply(200, {
|
||||
data: [],
|
||||
links: {
|
||||
next: 'https://api.appstoreconnect.apple.com/v1/betaGroups?filter%5Bbuilds%5D=build&limit=200&cursor=next',
|
||||
},
|
||||
});
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[builds]': 'build', limit: '200', cursor: 'next' })
|
||||
.reply(200, { data: [{ id: 'group' }] });
|
||||
await updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A'] });
|
||||
});
|
||||
|
||||
it.each(['PROCESSING', 'FAILED'])('rejects a %s upload', async state => {
|
||||
mockBuild(state);
|
||||
await expect(updateTestFlightMetadataAsync(options)).rejects.toThrow('not ready');
|
||||
});
|
||||
|
||||
it('rejects a completed upload without its build relationship', async () => {
|
||||
mockBuild('COMPLETE', null);
|
||||
await expect(updateTestFlightMetadataAsync(options)).rejects.toThrow('not ready');
|
||||
});
|
||||
|
||||
it('propagates Apple write failures', async () => {
|
||||
mockBuild();
|
||||
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
|
||||
api()
|
||||
.post('/v1/betaBuildLocalizations')
|
||||
.reply(403, { errors: [{ code: 'FORBIDDEN', detail: 'Not allowed' }] });
|
||||
await expect(updateTestFlightMetadataAsync(options)).rejects.toThrow('403');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['MISSING_EXPORT_COMPLIANCE', 'Complete the export compliance questions'],
|
||||
['IN_EXPORT_COMPLIANCE_REVIEW', 'Wait for Apple to approve'],
|
||||
['EXPIRED', 'ASSIGNMENT_FAILED'],
|
||||
['PROCESSING', 'ASSIGNMENT_FAILED'],
|
||||
['READY_FOR_BETA_TESTING', 'ASSIGNMENT_FAILED'],
|
||||
['IN_BETA_TESTING', 'ASSIGNMENT_FAILED'],
|
||||
[null, 'ASSIGNMENT_FAILED'],
|
||||
] as const)(
|
||||
'shares one diagnostic lookup across failed assignments (state: %s)',
|
||||
async (state, guidance) => {
|
||||
mockBuild();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, {
|
||||
data: ['A', 'B', 'C'].map(id => ({ id, attributes: { name: id } })),
|
||||
});
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id: 'A' }],
|
||||
})
|
||||
.reply(403, { errors: [{ code: 'ASSIGNMENT_FAILED' }] });
|
||||
mockInternalBuildState(state);
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id: 'B' }],
|
||||
})
|
||||
.reply(422, { errors: [{ code: 'ASSIGNMENT_FAILED' }] });
|
||||
// A failed assignment must not prevent the next group from being assigned.
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id: 'C' }],
|
||||
})
|
||||
.reply(204);
|
||||
const failure = await updateTestFlightMetadataAsync({
|
||||
...options,
|
||||
changelog: '',
|
||||
groups: ['A', 'B', 'C'],
|
||||
}).catch(error => error);
|
||||
expect(failure).toBeInstanceOf(AggregateError);
|
||||
expect(failure.errors).toHaveLength(2);
|
||||
if (state === 'MISSING_EXPORT_COMPLIANCE' || state === 'IN_EXPORT_COMPLIANCE_REVIEW') {
|
||||
expect(failure.errors[0]).toBe(failure.errors[1]);
|
||||
} else {
|
||||
expect(failure.errors[0]).toMatchObject({ status: 403 });
|
||||
expect(failure.errors[1]).toMatchObject({ status: 422 });
|
||||
}
|
||||
expect(failure.errors[0].message).toContain(guidance);
|
||||
expect(
|
||||
jest
|
||||
.mocked(options.logger.info)
|
||||
.mock.calls.filter(([message]) => String(message).includes('internal TestFlight state ='))
|
||||
).toEqual([[`Apple build build: internal TestFlight state = ${state ?? 'UNKNOWN'}.`]]);
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
`TestFlight group assignment failed (state: ${state ?? 'UNKNOWN'})`,
|
||||
{
|
||||
level: 'error',
|
||||
tags: { step: 'eas/update_testflight_metadata', internal_build_state: state ?? 'UNKNOWN' },
|
||||
extras: {
|
||||
buildId: 'build',
|
||||
assignmentError: expect.stringContaining('ASSIGNMENT_FAILED'),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
+24
-10
@@ -20,7 +20,8 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockReset()
|
||||
.mockImplementation(async (_ctx, { stream }) => {
|
||||
.mockImplementation(async (_ctx, { openStream }) => {
|
||||
const stream = openStream();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stream.once('end', resolve);
|
||||
stream.once('error', reject);
|
||||
@@ -29,7 +30,20 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads simulator device metadata', async () => {
|
||||
it.each([
|
||||
{
|
||||
platform: BuildRuntimePlatform.DARWIN,
|
||||
udid: SIMULATOR_UDID,
|
||||
name: 'iPhone 16',
|
||||
runtime: 'iOS 18.6',
|
||||
},
|
||||
{
|
||||
platform: BuildRuntimePlatform.LINUX,
|
||||
udid: 'emulator-5554',
|
||||
name: 'Pixel',
|
||||
runtime: 'Android 16',
|
||||
},
|
||||
])('uploads device metadata on $platform', async ({ platform, udid, name, runtime }) => {
|
||||
const recordingDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'ios-recording-test-'));
|
||||
const recordingPath = path.join(recordingDirectory, 'recording.mp4');
|
||||
await fs.writeFile(recordingPath, 'recording');
|
||||
@@ -45,7 +59,7 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
|
||||
try {
|
||||
const globalContext = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
runtimePlatform: platform,
|
||||
});
|
||||
const buildStep = createUploadDeviceRunSessionScreenRecordingsBuildFunction(
|
||||
{} as CustomBuildContext
|
||||
@@ -54,9 +68,9 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
callInputs: {
|
||||
recordings_json: [
|
||||
{
|
||||
udid: SIMULATOR_UDID,
|
||||
deviceName: 'iPhone 16',
|
||||
runtimeDisplayName: 'iOS 18.6',
|
||||
udid,
|
||||
deviceName: name,
|
||||
runtimeDisplayName: runtime,
|
||||
directory: recordingDirectory,
|
||||
},
|
||||
],
|
||||
@@ -68,13 +82,13 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
name: 'iPhone 16 screen recording (01234567-…, started at Jul 10, 2026, 10:00:00.000 UTC)',
|
||||
name: `${name} screen recording (${udid.slice(0, 8)}-…, started at Jul 10, 2026, 10:00:00.000 UTC)`,
|
||||
metadata: {
|
||||
__eas_type: 'screen-recording',
|
||||
__eas_screen_recording: '1',
|
||||
udid: SIMULATOR_UDID,
|
||||
deviceName: 'iPhone 16',
|
||||
runtimeDisplayName: 'iOS 18.6',
|
||||
udid,
|
||||
deviceName: name,
|
||||
runtimeDisplayName: runtime,
|
||||
firstFrameAt: '2026-07-10T10:00:00.000Z',
|
||||
width: 1179,
|
||||
height: 2556,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { ProjectConfig } from '@expo/config';
|
||||
import { BuildJob } from '@expo/eas-build-job';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createTestAndroidJob } from '../../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../../context';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { readAppConfig } from '../../../utils/appConfig';
|
||||
import { uploadEmbeddedBundleAsync } from '../../../utils/expoUpdatesEmbedded';
|
||||
import { createUploadEmbeddedBundleBuildFunction } from '../uploadEmbeddedBundle';
|
||||
|
||||
jest.mock('../../../utils/appConfig');
|
||||
jest.mock('../../../utils/expoUpdatesEmbedded');
|
||||
|
||||
function mockAppConfig(updatesUrl: string | undefined): void {
|
||||
jest.mocked(readAppConfig).mockResolvedValue({
|
||||
exp: { name: 'my-app', slug: 'my-app', updates: updatesUrl ? { url: updatesUrl } : undefined },
|
||||
} as ProjectConfig);
|
||||
}
|
||||
|
||||
function createCustomContext(job: BuildJob): CustomBuildContext<BuildJob> {
|
||||
return new CustomBuildContext(
|
||||
new BuildContext(job, {
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test' },
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
uploadArtifact: jest.fn(),
|
||||
workingdir: '',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
describe(createUploadEmbeddedBundleBuildFunction, () => {
|
||||
const job = createTestAndroidJob({});
|
||||
const customContext = createCustomContext(job);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('uploads the embedded bundle from the working directory', async () => {
|
||||
mockAppConfig('https://u.expo.dev/project-id');
|
||||
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValue({ status: 'uploaded' });
|
||||
const globalContext = createGlobalContextMock({});
|
||||
globalContext.updateEnv({ EAS_BUILD_ID: 'build-123' });
|
||||
const buildStep =
|
||||
createUploadEmbeddedBundleBuildFunction(customContext).createBuildStepFromFunctionCall(
|
||||
globalContext
|
||||
);
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
job,
|
||||
env: expect.objectContaining({ EAS_BUILD_ID: 'build-123' }),
|
||||
projectDir: globalContext.defaultWorkingDirectory,
|
||||
appConfig: expect.objectContaining({ updates: { url: 'https://u.expo.dev/project-id' } }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('logs when the embedded bundle upload is skipped', async () => {
|
||||
mockAppConfig('https://u.expo.dev/project-id');
|
||||
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValue({ status: 'skipped' });
|
||||
const buildStep = createUploadEmbeddedBundleBuildFunction(
|
||||
customContext
|
||||
).createBuildStepFromFunctionCall(createGlobalContextMock({}));
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.ctx.logger.info).toHaveBeenCalledWith('Skipping embedded bundle upload.');
|
||||
});
|
||||
|
||||
it('fails the step when the upload fails', async () => {
|
||||
mockAppConfig('https://u.expo.dev/project-id');
|
||||
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValue({ status: 'failed' });
|
||||
const buildStep = createUploadEmbeddedBundleBuildFunction(
|
||||
customContext
|
||||
).createBuildStepFromFunctionCall(createGlobalContextMock({}));
|
||||
|
||||
await expect(buildStep.executeAsync()).rejects.toThrow('Failed to upload embedded bundle.');
|
||||
});
|
||||
|
||||
it('does not fail the step when the upload fails and ignore_error is set', async () => {
|
||||
mockAppConfig('https://u.expo.dev/project-id');
|
||||
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValue({ status: 'failed' });
|
||||
const buildStep = createUploadEmbeddedBundleBuildFunction(
|
||||
customContext
|
||||
).createBuildStepFromFunctionCall(createGlobalContextMock({}), {
|
||||
callInputs: { ignore_error: true },
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.ctx.logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Failed to upload embedded bundle. Ignoring error.'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails the step when the app config cannot be read', async () => {
|
||||
jest.mocked(readAppConfig).mockRejectedValue(new Error('Invalid app config'));
|
||||
const buildStep = createUploadEmbeddedBundleBuildFunction(
|
||||
customContext
|
||||
).createBuildStepFromFunctionCall(createGlobalContextMock({}));
|
||||
|
||||
await expect(buildStep.executeAsync()).rejects.toThrow('Invalid app config');
|
||||
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fail the step when the app config cannot be read and ignore_error is set', async () => {
|
||||
jest.mocked(readAppConfig).mockRejectedValue(new Error('Invalid app config'));
|
||||
const buildStep = createUploadEmbeddedBundleBuildFunction(
|
||||
customContext
|
||||
).createBuildStepFromFunctionCall(createGlobalContextMock({}), {
|
||||
callInputs: { ignore_error: true },
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.ctx.logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Failed to upload embedded bundle. Ignoring error.'
|
||||
);
|
||||
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { getDeviceRunSessionIdOrThrow } from '../utils/remoteDeviceRunSession';
|
||||
import { uploadServeSimLogsFileAsync } from '../utils/serveSimLogsArtifacts';
|
||||
import { ServeSimLogsRecorder } from '../utils/serveSimLogsRecorder';
|
||||
|
||||
const UPLOAD_TIMEOUT_MS = 30_000;
|
||||
|
||||
export function createCollectServeSimLogsBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'collect_serve_sim_logs',
|
||||
name: 'Collect serve-sim simulator logs',
|
||||
__metricsId: 'eas/collect_serve_sim_logs',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
fn: async ({ logger }, { env }) => {
|
||||
try {
|
||||
const collected = await ServeSimLogsRecorder.finishAsync({ logger });
|
||||
if (collected.length === 0) {
|
||||
logger.info('No simulator logs collected; skipping upload.');
|
||||
return;
|
||||
}
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
for (const { udid, filePath } of collected) {
|
||||
// Each device gets its own budget so one stuck upload does not cancel the rest.
|
||||
await uploadServeSimLogsFileAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
udid,
|
||||
filePath,
|
||||
logger,
|
||||
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not finalize serve-sim simulator logs', error);
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Could not finalize simulator logs; the session result is unchanged.'
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import plist from 'plist';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
|
||||
import { pluralize } from '../../utils/strings';
|
||||
|
||||
@@ -78,7 +79,7 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
required: true,
|
||||
}),
|
||||
],
|
||||
fn: async (stepsCtx, { inputs, outputs }) => {
|
||||
fn: async (stepsCtx, { inputs, outputs, signal }) => {
|
||||
const { logger } = stepsCtx;
|
||||
|
||||
const extensions = z.array(z.string()).parse(inputs.extensions.value);
|
||||
@@ -119,6 +120,7 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
robotAccessToken: stepsCtx.global.staticContext.job.secrets?.robotAccessToken ?? null,
|
||||
extensions,
|
||||
signal,
|
||||
});
|
||||
|
||||
outputs.artifact_path.set(artifactPath);
|
||||
@@ -129,11 +131,17 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
async function fetchApplicationArchiveUrlAsync({
|
||||
buildId,
|
||||
graphqlClient,
|
||||
signal,
|
||||
}: {
|
||||
buildId: string;
|
||||
graphqlClient: Client;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<string> {
|
||||
const result = await graphqlClient.query(BUILD_BY_ID_QUERY, { buildId }).toPromise();
|
||||
signal?.throwIfAborted();
|
||||
const result = await graphqlClient
|
||||
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
signal?.throwIfAborted();
|
||||
|
||||
if (result.error) {
|
||||
const { error } = result;
|
||||
@@ -161,9 +169,11 @@ export async function downloadBuildAsync(
|
||||
graphqlClient: Client;
|
||||
robotAccessToken: string | null;
|
||||
extensions: string[];
|
||||
/** Stops the build lookup, the request and the file write when aborted, and skips the rest. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<{ artifactPath: string }> {
|
||||
const { logger, graphqlClient, robotAccessToken, extensions } = params;
|
||||
const { logger, graphqlClient, robotAccessToken, extensions, signal } = params;
|
||||
|
||||
let downloadUrl: string;
|
||||
let headers: { Authorization: string } | undefined;
|
||||
@@ -178,7 +188,7 @@ export async function downloadBuildAsync(
|
||||
headers = undefined;
|
||||
} else if (params.buildId) {
|
||||
const buildId = z.string().uuid().parse(params.buildId);
|
||||
downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient });
|
||||
downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient, signal });
|
||||
headers = robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined;
|
||||
} else {
|
||||
throw new UserError(
|
||||
@@ -191,7 +201,8 @@ export async function downloadBuildAsync(
|
||||
path.join(os.tmpdir(), 'download_build-downloaded-')
|
||||
);
|
||||
|
||||
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers });
|
||||
signal?.throwIfAborted();
|
||||
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers, signal });
|
||||
|
||||
if (!response.ok) {
|
||||
const textResult = await asyncResult(response.text());
|
||||
@@ -201,7 +212,9 @@ export async function downloadBuildAsync(
|
||||
const archiveFilename = resolveArchiveFilename({ response, extensions });
|
||||
const archivePath = path.join(downloadDestinationDirectory, archiveFilename);
|
||||
|
||||
// On abort, node-fetch emits an error on the body stream, which fails the pipeline.
|
||||
await streamPipeline(response.body, fs.createWriteStream(archivePath));
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const { size } = await fs.promises.stat(archivePath);
|
||||
|
||||
@@ -217,10 +230,15 @@ export async function downloadBuildAsync(
|
||||
const extractionDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'download_build-extracted-')
|
||||
);
|
||||
// Not interrupted on abort: tar cannot stop its in-flight file writes, so an abort during
|
||||
// the extraction lets it finish (local, bounded work) and is reported right after it.
|
||||
// This way no file is written after this function settles.
|
||||
signal?.throwIfAborted();
|
||||
await decompressTarAsync({
|
||||
archivePath,
|
||||
destinationDirectory: extractionDirectory,
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const matchingFiles = await glob(`**/*.(${extensions.join('|')})`, {
|
||||
absolute: true,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import { graphql } from 'gql.tada';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { downloadBuildAsync } from './downloadBuild';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import { promiseRetryWithCondition } from '../../utils/promiseRetryWithCondition';
|
||||
|
||||
const CREATE_ARCHIVE_DOWNLOAD_URL = graphql(`
|
||||
mutation GenerateSubmissionArchiveDownloadUrl($submissionId: ID!) {
|
||||
submission {
|
||||
generateSubmissionArchiveDownloadUrl(submissionId: $submissionId)
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export function createDownloadSubmissionArchiveFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'download_submission_archive',
|
||||
name: 'Download submission archive',
|
||||
__metricsId: 'eas/download_submission_archive',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'submission_id',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'extensions',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
defaultValue: ['apk', 'aab', 'ipa'],
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'artifact_path',
|
||||
required: true,
|
||||
}),
|
||||
],
|
||||
fn: async (stepsCtx, { inputs, outputs, signal }) => {
|
||||
const { submissionId, extensions } = z
|
||||
.object({
|
||||
submissionId: z.string().uuid(),
|
||||
extensions: z.array(z.string()),
|
||||
})
|
||||
.parse({
|
||||
submissionId: inputs.submission_id.value,
|
||||
extensions: inputs.extensions.value,
|
||||
});
|
||||
|
||||
stepsCtx.logger.info(`Downloading archive for submission ${submissionId}...`);
|
||||
|
||||
const applicationArchiveUrl = await promiseRetryWithCondition(
|
||||
async () => {
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(CREATE_ARCHIVE_DOWNLOAD_URL, { submissionId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
|
||||
signal?.throwIfAborted();
|
||||
|
||||
if (result.error) {
|
||||
throw result.error.networkError || result.error.response?.status >= 500
|
||||
? new SystemError('Could not request the submission archive. Try again later.', {
|
||||
cause: result.error,
|
||||
})
|
||||
: new UserError(
|
||||
'EAS_SUBMISSION_ARCHIVE_FETCH_FAILED',
|
||||
'Could not request the submission archive. Check your project access and the submission archive.',
|
||||
{ cause: result.error }
|
||||
);
|
||||
}
|
||||
|
||||
const url = result.data?.submission.generateSubmissionArchiveDownloadUrl;
|
||||
|
||||
if (!url) {
|
||||
throw new SystemError(
|
||||
'The server did not return a submission archive URL. Try again later.'
|
||||
);
|
||||
}
|
||||
return url;
|
||||
},
|
||||
error => !signal?.aborted && error instanceof SystemError,
|
||||
{ retries: 3, minTimeout: 1000, maxTimeout: 1000 },
|
||||
({ attemptNumber, maxAttemptsCount }) => {
|
||||
stepsCtx.logger.info(
|
||||
`Retrying submission archive URL request (${attemptNumber}/${maxAttemptsCount})...`
|
||||
);
|
||||
}
|
||||
)();
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: stepsCtx.logger,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
applicationArchiveUrl,
|
||||
robotAccessToken: null,
|
||||
extensions,
|
||||
signal,
|
||||
});
|
||||
|
||||
outputs.artifact_path.set(artifactPath);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -199,10 +199,15 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
}
|
||||
|
||||
if (backend === 'maestro-runner' && global.runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await installMaestroRunnerWdaCache({
|
||||
logger,
|
||||
env,
|
||||
});
|
||||
// Runner 1.1.28 switched the default iOS driver to the bundled DeviceLab agent.
|
||||
// Use the detected version after installation, including when `latest` was requested.
|
||||
const installedRunnerVersion = semver.coerce(maestroVersionResult.value);
|
||||
if (!installedRunnerVersion || semver.lt(installedRunnerVersion, '1.1.28')) {
|
||||
await installMaestroRunnerWdaCache({
|
||||
logger,
|
||||
env,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`${backend} ${maestroVersionResult.value} is ready.`);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { BuildFunction, BuildRuntimePlatform, BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { Sentry } from '../../sentry';
|
||||
|
||||
export function createInstallMitmproxyBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'install_mitmproxy',
|
||||
name: 'Install mitmproxy',
|
||||
__metricsId: 'eas/install_mitmproxy',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
fn: async ({ logger }, { env }) => {
|
||||
// Some images ship the cask, and its quarantined first launch can hang.
|
||||
const canLaunch =
|
||||
env.EAS_BUILD_RUNNER !== 'eas-build' || (await removeMitmproxyQuarantineAsync(env));
|
||||
if (canLaunch && (await isMitmproxyAvailableAsync(env))) {
|
||||
logger.info('mitmproxy is already installed.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (env.EAS_BUILD_RUNNER !== 'eas-build') {
|
||||
logger.warn(
|
||||
'mitmproxy is not installed and network capture needs it. Install it with `brew install --cask mitmproxy` and rerun the job.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('Installing mitmproxy with Homebrew.');
|
||||
await installMitmproxyWithHomebrewAsync({ env, logger });
|
||||
if (!(await removeMitmproxyQuarantineAsync(env))) {
|
||||
throw new Error('Could not launch mitmproxy because it is still quarantined.');
|
||||
}
|
||||
if (!(await isMitmproxyAvailableAsync(env))) {
|
||||
throw new Error('`brew install --cask mitmproxy` succeeded but mitmdump did not run.');
|
||||
}
|
||||
logger.info('Installed mitmproxy.');
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not install mitmproxy for the device session', error, {
|
||||
level: 'warning',
|
||||
});
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Could not install mitmproxy, so network capture will record nothing in this session. Re-run the session to retry the install, or run it without network capture.'
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function isMitmproxyAvailableAsync(env: BuildStepEnv): Promise<boolean> {
|
||||
// Kills a first launch that Gatekeeper still holds, if the quarantine check missed it. Without
|
||||
// pipes, a child process that outlives mitmdump cannot hold the call open past the timeout.
|
||||
return (
|
||||
await asyncResult(
|
||||
spawn('mitmdump', ['--version'], {
|
||||
env,
|
||||
stdio: 'ignore',
|
||||
timeout: 60_000,
|
||||
killSignal: 'SIGKILL',
|
||||
})
|
||||
)
|
||||
).ok;
|
||||
}
|
||||
|
||||
async function installMitmproxyWithHomebrewAsync({
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
await spawn('brew', ['install', '--cask', 'mitmproxy'], {
|
||||
env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1' },
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gatekeeper has rejected the mitmproxy 12.2.3 cask as unnotarized since 2026-10-06, and a
|
||||
* quarantined first launch then never returns. Takes the app behind the `mitmdump` on PATH out of
|
||||
* quarantine, and returns false when it stays quarantined, so it is not launched.
|
||||
*/
|
||||
async function removeMitmproxyQuarantineAsync(env: BuildStepEnv): Promise<boolean> {
|
||||
const which = await asyncResult(spawn('which', ['mitmdump'], { env, stdio: 'pipe' }));
|
||||
const mitmdump = which.ok
|
||||
? await fs.promises.realpath(which.value.stdout.trim()).catch(() => null)
|
||||
: null;
|
||||
if (!mitmdump) {
|
||||
return true;
|
||||
}
|
||||
const appEnd = mitmdump.lastIndexOf('.app/');
|
||||
const app = appEnd === -1 ? mitmdump : mitmdump.slice(0, appEnd + '.app'.length);
|
||||
await asyncResult(spawn('xattr', ['-dr', 'com.apple.quarantine', app], { env, stdio: 'pipe' }));
|
||||
const quarantined = await asyncResult(
|
||||
spawn('xattr', ['-p', 'com.apple.quarantine', mitmdump], { env, stdio: 'pipe' })
|
||||
);
|
||||
return !quarantined.ok;
|
||||
}
|
||||
@@ -184,7 +184,7 @@ function logApplicationLaunch(
|
||||
logger.info(`Launching ${applicationIdentifier}${argumentsDescription}.`);
|
||||
}
|
||||
|
||||
function parseNonEmptyStringInput(value: unknown, inputName: string): string {
|
||||
export function parseNonEmptyStringInput(value: unknown, inputName: string): string {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
@@ -194,7 +194,7 @@ function parseNonEmptyStringInput(value: unknown, inputName: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseLaunchArgsInput(value: unknown): string[] {
|
||||
export function parseLaunchArgsInput(value: unknown): string[] {
|
||||
if (value === undefined) {
|
||||
return [];
|
||||
}
|
||||
@@ -207,7 +207,7 @@ function parseLaunchArgsInput(value: unknown): string[] {
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseOpenUrlInput(value: unknown): string {
|
||||
export function parseOpenUrlInput(value: unknown): string {
|
||||
const openUrl = parseNonEmptyStringInput(value, 'open_url');
|
||||
if (!URL.canParse(openUrl)) {
|
||||
throw new UserError(
|
||||
|
||||
@@ -107,7 +107,12 @@ function buildMaestroArgs({
|
||||
return { executable: 'maestro', args };
|
||||
}
|
||||
case 'maestro-runner': {
|
||||
// The runner writes every report format to one directory. output_format only
|
||||
// selects which extra report artifact EAS uploads after the test.
|
||||
const args = [`--platform=${platform}`, 'test', `--output=${output}`, '--flatten'];
|
||||
if (shards !== undefined && shards > 1) {
|
||||
args.push(`--parallel=${shards}`);
|
||||
}
|
||||
if (includeTags) {
|
||||
args.push(`--include-tags=${includeTags}`);
|
||||
}
|
||||
@@ -210,23 +215,20 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
}
|
||||
const testsDirectory = path.join(home, '.maestro', 'tests');
|
||||
const junitReportDirectory = path.join(testsDirectory, 'junit-reports');
|
||||
const finalReportPath =
|
||||
outputFormat === 'junit'
|
||||
? path.join(testsDirectory, `${platform}-maestro-junit.xml`)
|
||||
: undefined;
|
||||
const junitFinalReportPath = path.join(testsDirectory, `${platform}-maestro-junit.xml`);
|
||||
|
||||
// Public docs (EAS workflows pre-packaged-jobs) document
|
||||
// `${MAESTRO_TESTS_DIR}` for users to save screenshots/recordings into
|
||||
// the uploaded dir.
|
||||
const spawnEnv: NodeJS.ProcessEnv = { ...env, MAESTRO_TESTS_DIR: testsDirectory };
|
||||
|
||||
// Outputs are published BEFORE any throw below so downstream
|
||||
// `if: always()` upload steps still see populated values when this
|
||||
// step fails early.
|
||||
// Publish known paths before backend validation so `if: always()` upload
|
||||
// steps can use them after an early failure. final_report_path names the
|
||||
// report selected by output_format, not every report the backend produces.
|
||||
outputs.tests_directory.set(testsDirectory);
|
||||
outputs.junit_report_directory.set(junitReportDirectory);
|
||||
if (finalReportPath !== undefined) {
|
||||
outputs.final_report_path.set(finalReportPath);
|
||||
if (outputFormat === 'junit') {
|
||||
outputs.final_report_path.set(junitFinalReportPath);
|
||||
}
|
||||
|
||||
// Resolved after the output assignments above: an invalid backend input or
|
||||
@@ -236,6 +238,16 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
input: inputs.backend.value,
|
||||
env,
|
||||
});
|
||||
// The runner can write JUnit regardless of output_format; Maestro CLI only
|
||||
// writes it when JUnit is selected. Either command may fail before it writes a file.
|
||||
const mayHaveJUnitReports = backend === 'maestro-runner' || outputFormat === 'junit';
|
||||
const maestroCliOutputPath =
|
||||
backend === 'maestro' && outputFormat && outputFormat !== 'junit'
|
||||
? path.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
|
||||
: null;
|
||||
if (backend === 'maestro' && outputFormat === 'html' && maestroCliOutputPath) {
|
||||
outputs.final_report_path.set(maestroCliOutputPath);
|
||||
}
|
||||
|
||||
const flowPaths = parseInput(
|
||||
FlowPathSchema,
|
||||
@@ -259,16 +271,14 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
undefined,
|
||||
'android_connection_mode and EAS_MAESTRO_ANDROID_CONNECTION_MODE must be either "adb" or "dadb".'
|
||||
);
|
||||
if (backend === 'maestro-runner' && shards !== undefined && shards > 1) {
|
||||
if (
|
||||
backend === 'maestro-runner' &&
|
||||
outputFormat !== undefined &&
|
||||
!['junit', 'html', 'allure'].includes(outputFormat)
|
||||
) {
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_INVALID_INPUT',
|
||||
'maestro-runner does not support EAS Maestro test sharding. Remove shards or set it to 1.'
|
||||
);
|
||||
}
|
||||
if (backend === 'maestro-runner' && outputFormat !== undefined && outputFormat !== 'junit') {
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_INVALID_INPUT',
|
||||
`maestro-runner only supports the "junit" output_format, but received "${outputFormat}".`
|
||||
`maestro-runner supports "junit", "html", and "allure" output_format values, but received "${outputFormat}".`
|
||||
);
|
||||
}
|
||||
const retryFailedOnly = inputs.retry_failed_only.value as boolean;
|
||||
@@ -340,17 +350,14 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
// maestro-runner writes its JUnit report and screenshot metadata to this directory.
|
||||
// maestro-runner writes all report formats and screenshot metadata here.
|
||||
const runnerOutputDirectory = path.join(
|
||||
testsDirectory,
|
||||
`${platform}-maestro-runner-attempt-${attempt}`
|
||||
);
|
||||
const outputPath =
|
||||
outputFormat === 'junit'
|
||||
? path.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
|
||||
: backend === 'maestro' && outputFormat
|
||||
? path.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
|
||||
: null;
|
||||
const outputPath = mayHaveJUnitReports
|
||||
? path.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
|
||||
: maestroCliOutputPath;
|
||||
const { executable, args: maestroArgs } = buildMaestroArgs({
|
||||
backend,
|
||||
platform,
|
||||
@@ -374,6 +381,17 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
logger.warn({ err }, `Failed to clear ${runnerOutputDirectory} before the attempt.`);
|
||||
}
|
||||
}
|
||||
// Maestro CLI uses the same HTML path for every retry. Remove the previous report
|
||||
// before spawning so an attempt that fails before report generation cannot upload it.
|
||||
if (backend === 'maestro' && outputFormat === 'html' && maestroCliOutputPath) {
|
||||
try {
|
||||
await fs.rm(maestroCliOutputPath, { force: true });
|
||||
} catch (err) {
|
||||
throw new SystemError('Failed to clear the previous Maestro HTML report', {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const attemptStartedAtMs = Date.now();
|
||||
|
||||
@@ -415,9 +433,8 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
reportDirectories.push(runnerOutputDirectory);
|
||||
}
|
||||
|
||||
// Harvest this attempt's failure screenshots before any retry subsetting. Gated on
|
||||
// junit: test-case-result rows (and therefore the summary icons) only exist for junit
|
||||
// runs, so harvesting other formats would just create orphan artifacts the website hides.
|
||||
// The pre-packaged job reports test cases only for JUnit (the default format).
|
||||
// Individual screenshots need those test cases to be visible on the website.
|
||||
if (outputFormat === 'junit') {
|
||||
let screenshots: HarvestedScreenshot[];
|
||||
switch (backend) {
|
||||
@@ -507,14 +524,15 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
await sleepAsync(2000);
|
||||
}
|
||||
|
||||
// Smart merge first; on data errors (bad XML, missing input) fall back
|
||||
// to copy-latest so the caller still gets a single JUnit file.
|
||||
// Only JUnit is merged into final_report_path. Runner HTML/Allure keeps its
|
||||
// per-attempt JUnit files, but final_report_path names the selected report.
|
||||
// On data errors (bad XML, missing input), fall back to copy-latest.
|
||||
// Filesystem errors short-circuit straight to SystemError.
|
||||
if (finalReportPath !== undefined) {
|
||||
if (outputFormat === 'junit') {
|
||||
try {
|
||||
await mergeJUnitReports({
|
||||
sourceDir: junitReportDirectory,
|
||||
outputPath: finalReportPath,
|
||||
outputPath: junitFinalReportPath,
|
||||
});
|
||||
} catch (mergeErr: any) {
|
||||
if (isFilesystemError(mergeErr)) {
|
||||
@@ -524,7 +542,7 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
try {
|
||||
await copyLatestAttemptXml({
|
||||
sourceDir: junitReportDirectory,
|
||||
outputPath: finalReportPath,
|
||||
outputPath: junitFinalReportPath,
|
||||
});
|
||||
} catch (copyErr: any) {
|
||||
// Swallow: a copy failure here usually means maestro itself failed
|
||||
@@ -532,14 +550,13 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
// the real reason and cancel billing for a user-side failure — let
|
||||
// the lastAttemptExitCode check below surface ERR_MAESTRO_TESTS_FAILED.
|
||||
logger.warn(
|
||||
`Failed to produce final_report_path at ${finalReportPath}: ${copyErr?.message ?? copyErr}`
|
||||
`Failed to produce final_report_path at ${junitFinalReportPath}: ${copyErr?.message ?? copyErr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Upload before the ERR_MAESTRO_TESTS_FAILED throw below so fully-failed runs (which need
|
||||
// screenshots most) still upload. Harvest only ran for junit, so guard the same way.
|
||||
// Upload before the failure verdict so fully-failed runs still get screenshots.
|
||||
if (outputFormat === 'junit') {
|
||||
await uploadFailureScreenshotsAsync({
|
||||
harvested,
|
||||
@@ -550,6 +567,81 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
});
|
||||
}
|
||||
|
||||
// Upload the selected non-JUnit report before the failure verdict. The pre-packaged
|
||||
// job also uploads testsDirectory, but a named artifact makes the report easy to find.
|
||||
let selectedReport:
|
||||
| { name: string; artifactPath: string; finalReportPath: string }
|
||||
| undefined;
|
||||
switch (outputFormat) {
|
||||
case 'html': {
|
||||
switch (backend) {
|
||||
case 'maestro': {
|
||||
if (!maestroCliOutputPath) {
|
||||
break;
|
||||
}
|
||||
selectedReport = {
|
||||
name: 'Maestro HTML Report',
|
||||
artifactPath: maestroCliOutputPath,
|
||||
finalReportPath: maestroCliOutputPath,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'maestro-runner': {
|
||||
const reportDirectory = reportDirectories.at(-1);
|
||||
if (!reportDirectory) {
|
||||
break;
|
||||
}
|
||||
// HTML references nearby screenshots, so keep its whole attempt directory.
|
||||
selectedReport = {
|
||||
name: 'Maestro Runner HTML Report',
|
||||
artifactPath: reportDirectory,
|
||||
finalReportPath: path.join(reportDirectory, 'report.html'),
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'allure': {
|
||||
switch (backend) {
|
||||
case 'maestro':
|
||||
logger.warn(
|
||||
'Maestro CLI does not support Allure reports; no Allure artifact was uploaded.'
|
||||
);
|
||||
break;
|
||||
case 'maestro-runner': {
|
||||
const reportDirectory = reportDirectories.at(-1);
|
||||
if (!reportDirectory) {
|
||||
break;
|
||||
}
|
||||
const allureResultsDirectory = path.join(reportDirectory, 'allure-results');
|
||||
selectedReport = {
|
||||
name: 'Maestro Runner Allure Results',
|
||||
artifactPath: allureResultsDirectory,
|
||||
finalReportPath: allureResultsDirectory,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (selectedReport) {
|
||||
outputs.final_report_path.set(selectedReport.finalReportPath);
|
||||
try {
|
||||
await ctx.runtimeApi.uploadArtifact({
|
||||
artifact: {
|
||||
type: GenericArtifactType.OTHER,
|
||||
name: selectedReport.name,
|
||||
paths: [selectedReport.artifactPath],
|
||||
},
|
||||
logger,
|
||||
});
|
||||
} catch (err: any) {
|
||||
logger.warn({ err }, `Failed to upload ${selectedReport.name}.`);
|
||||
}
|
||||
}
|
||||
|
||||
// The retry loop exits via success (0), numeric status (retryable),
|
||||
// or throw (infra). A non-null non-zero status means the user's tests
|
||||
// failed every attempt.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { readAndroidArtifactInfoAsync } from '../utils/android/appArtifact';
|
||||
|
||||
export function createReadAndroidAppInfoBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'read_android_app_info',
|
||||
name: 'Read Android app info',
|
||||
__metricsId: 'eas/read_android_app_info',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'artifact_path',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({ id: 'artifact_type', required: true }),
|
||||
BuildStepOutput.createProvider({ id: 'package_name', required: true }),
|
||||
],
|
||||
fn: async (ctx, { inputs, outputs, signal }) => {
|
||||
const artifactPath = path.resolve(
|
||||
ctx.workingDirectory,
|
||||
z.string().min(1).parse(inputs.artifact_path.value)
|
||||
);
|
||||
ctx.logger.info(`Reading Android artifact: ${artifactPath}.`);
|
||||
const artifact = await readAndroidArtifactInfoAsync(artifactPath, signal);
|
||||
ctx.logger.info(`Android artifact type: ${artifact.artifactType}.`);
|
||||
ctx.logger.info(`Android package name: ${artifact.packageName}.`);
|
||||
outputs.artifact_type.set(artifact.artifactType);
|
||||
outputs.package_name.set(artifact.packageName);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { AGENT_KINDS, runAgentAsync } from '../utils/agentRun';
|
||||
|
||||
export function createRunAgentBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'run_agent',
|
||||
name: 'Run agent',
|
||||
__metricsId: 'eas/run_agent',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.LINUX],
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'agent_kind',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'agent_run_id',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_duration_seconds',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'prompt',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, env, signal }) => {
|
||||
const inputErrors = {
|
||||
agentKind: `The "agent_kind" input must be one of ${AGENT_KINDS.join(', ')}, but received "${String(inputs.agent_kind.value)}".`,
|
||||
agentRunId: `The "agent_run_id" input must be the UUID of an agent run, but received "${String(inputs.agent_run_id.value)}".`,
|
||||
maxDurationSeconds: `The "max_duration_seconds" input must be a positive whole number of seconds, but received "${String(inputs.max_duration_seconds.value)}".`,
|
||||
prompt: 'The "prompt" input must be a non-empty string.',
|
||||
};
|
||||
const parsedInputs = z
|
||||
.object({
|
||||
agent_kind: z.enum(AGENT_KINDS, { error: inputErrors.agentKind }),
|
||||
agent_run_id: z.uuid({ error: inputErrors.agentRunId }),
|
||||
max_duration_seconds: z
|
||||
.number({ error: inputErrors.maxDurationSeconds })
|
||||
.int({ error: inputErrors.maxDurationSeconds })
|
||||
.positive({ error: inputErrors.maxDurationSeconds }),
|
||||
prompt: z.string({ error: inputErrors.prompt }).min(1, { error: inputErrors.prompt }),
|
||||
})
|
||||
.safeParse({
|
||||
agent_kind: inputs.agent_kind.value,
|
||||
agent_run_id: inputs.agent_run_id.value,
|
||||
max_duration_seconds: inputs.max_duration_seconds.value,
|
||||
prompt: inputs.prompt.value,
|
||||
});
|
||||
if (!parsedInputs.success) {
|
||||
throw createValidationError(parsedInputs.error);
|
||||
}
|
||||
const {
|
||||
agent_kind: agentKind,
|
||||
agent_run_id: agentRunId,
|
||||
max_duration_seconds: maxDurationSeconds,
|
||||
prompt,
|
||||
} = parsedInputs.data;
|
||||
const parsedEnv = z
|
||||
.object({
|
||||
EXPO_TOKEN: z
|
||||
.string({ error: 'EXPO_TOKEN is required to run an agent.' })
|
||||
.min(1, { error: 'EXPO_TOKEN is required to run an agent.' }),
|
||||
PATH: z
|
||||
.string({ error: 'PATH is required to run an agent.' })
|
||||
.min(1, { error: 'PATH is required to run an agent.' }),
|
||||
EAS_BUILD_NPM_CACHE_URL: z.string().optional(),
|
||||
})
|
||||
.safeParse(env);
|
||||
if (!parsedEnv.success) {
|
||||
throw createValidationError(parsedEnv.error);
|
||||
}
|
||||
const {
|
||||
EXPO_TOKEN: expoToken,
|
||||
PATH: pathEnv,
|
||||
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
|
||||
} = parsedEnv.data;
|
||||
if (!ctx.expoApiV2BaseUrl) {
|
||||
throw new SystemError('Expo API URL is required to run an agent.');
|
||||
}
|
||||
if (!ctx.mcpServerUrl) {
|
||||
throw new SystemError('MCP server URL is required to run an agent.');
|
||||
}
|
||||
// The worker config holds the MCP server's WebSocket URL; its HTTP endpoint is on the same host.
|
||||
const mcpUrl = new URL('/mcp', ctx.mcpServerUrl);
|
||||
mcpUrl.protocol = mcpUrl.protocol.replace(/^ws/, 'http');
|
||||
|
||||
const timeoutError = new UserError(
|
||||
'EAS_RUN_AGENT_TIMEOUT',
|
||||
`The agent did not finish within its limit of ${maxDurationSeconds} seconds, so it was stopped. Raise the agent's maximum duration or give it a smaller task, then start a new agent run.`
|
||||
);
|
||||
const timeoutController = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => timeoutController.abort(timeoutError),
|
||||
maxDurationSeconds * 1000
|
||||
);
|
||||
try {
|
||||
await runAgentAsync({
|
||||
agentKind,
|
||||
agentRunId,
|
||||
prompt,
|
||||
expoToken,
|
||||
expoApiV2BaseUrl: ctx.expoApiV2BaseUrl,
|
||||
mcpUrl: mcpUrl.toString(),
|
||||
pathEnv,
|
||||
npmCacheUrl,
|
||||
logger: stepCtx.logger,
|
||||
signal: signal
|
||||
? AbortSignal.any([signal, timeoutController.signal])
|
||||
: timeoutController.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createValidationError(error: z.ZodError): SystemError {
|
||||
return new SystemError(error.issues.map(issue => issue.message).join(' '));
|
||||
}
|
||||
@@ -1,33 +1,32 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
type BuildStepEnv,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import { type BuildRuntimePlatform, type BuildStepEnv } 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 { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { uploadRemoteSessionConfigWithLocalEgressAsync } from '../utils/localEgressSession';
|
||||
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageAdd,
|
||||
resolvePackageInstall,
|
||||
} from '../../utils/packageManager';
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
|
||||
import { type parseNetworkCaptureInputs } from '../utils/networkCaptureFields';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
|
||||
import { type StartupTasks } from '../utils/startupTasks';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
@@ -42,145 +41,301 @@ const STARTUP_TIMEOUT_MS = 60_000;
|
||||
const AGENT_DEVICE_DAEMON_ENV = {
|
||||
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
|
||||
AGENT_DEVICE_RETAIN_ARTIFACTS: '1',
|
||||
// The session lifetime is owned by max_idle_time_minutes / max_duration_seconds
|
||||
// and the device run session stop, so disable agent-device's own idle timers.
|
||||
// Without this the daemon exits after 5 minutes with no open session, and the
|
||||
// tunnel is left without a daemon behind it.
|
||||
AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0',
|
||||
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS: '0',
|
||||
AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS: '0',
|
||||
};
|
||||
|
||||
export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_agent_device_remote_session',
|
||||
name: 'Start agent device remote session',
|
||||
__metricsId: 'eas/start_agent_device_remote_session',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_idle_time_minutes',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_duration_seconds',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: withLocalEgressSession(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
|
||||
// for our ngrok tunnels), and NGROK_AUTHTOKEN (to authenticate them).
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
export type AgentDeviceRemoteSessionEnv = {
|
||||
deviceRunSessionId: string;
|
||||
ngrokTunnelDomain: string;
|
||||
ngrokAuthtoken: string;
|
||||
};
|
||||
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
logger.info(
|
||||
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
/**
|
||||
* Reads the env vars that the API server injects into a device run session job:
|
||||
* DEVICE_RUN_SESSION_ID (to report the remote config back to the API server),
|
||||
* EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain for our ngrok tunnels), and
|
||||
* NGROK_AUTHTOKEN (to authenticate them).
|
||||
*/
|
||||
export function getAgentDeviceRemoteSessionEnvOrThrow(
|
||||
env: BuildStepEnv
|
||||
): AgentDeviceRemoteSessionEnv {
|
||||
return {
|
||||
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
|
||||
ngrokTunnelDomain: getNgrokTunnelDomainOrThrow(env),
|
||||
ngrokAuthtoken: getNgrokAuthtokenOrThrow(env),
|
||||
};
|
||||
}
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
/**
|
||||
* Starts the agent-device daemon, its tunnel, the session host and the web preview,
|
||||
* reports the session as ready, and keeps it alive until it stops.
|
||||
*
|
||||
* The daemon does not need the device, so it starts at once. The session host starts
|
||||
* when `device.booted` resolves. The session is reported as ready only when both are
|
||||
* up and `device.ready` (the app is installed and launched) resolved too.
|
||||
*
|
||||
* The first failure aborts `tasks.signal`: each part stops before its next stage, and
|
||||
* the teardown then stops whatever was started. `device.ready` must settle soon after
|
||||
* an abort, because the teardown waits for it.
|
||||
*/
|
||||
export async function runAgentDeviceRemoteSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
runtimePlatform,
|
||||
sessionEnv: { deviceRunSessionId, ngrokTunnelDomain, ngrokAuthtoken },
|
||||
packageVersion,
|
||||
maxIdleTimeMinutes,
|
||||
maxDurationSeconds,
|
||||
capture,
|
||||
tasks,
|
||||
device,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
sessionEnv: AgentDeviceRemoteSessionEnv;
|
||||
packageVersion: string | undefined;
|
||||
maxIdleTimeMinutes: number | undefined;
|
||||
maxDurationSeconds: number | undefined;
|
||||
capture: ReturnType<typeof parseNetworkCaptureInputs>;
|
||||
tasks: StartupTasks;
|
||||
device: { booted: Promise<unknown>; ready: Promise<unknown> };
|
||||
}
|
||||
): Promise<void> {
|
||||
logger.info(
|
||||
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
|
||||
logger.info('Launching agent-device daemon.');
|
||||
const daemonProcess = await startAgentDeviceDaemonAsync({ packageVersion, env, logger });
|
||||
let daemonProcess: DetachedProcessHandle | undefined;
|
||||
let agentDeviceTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let eventCollection: Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>> | undefined;
|
||||
let sessionFailed = false;
|
||||
|
||||
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
|
||||
const { port: daemonPort, token: daemonToken } = await waitForDaemonInfoAsync({
|
||||
daemonProcess,
|
||||
});
|
||||
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
|
||||
// Each task stores what it started, so the teardown below can stop it even when
|
||||
// another task failed first.
|
||||
const agentDeviceStartup = tasks.run('agent-device daemon', async taskLogger => {
|
||||
taskLogger.info('Launching agent-device daemon.');
|
||||
daemonProcess = await startAgentDeviceDaemonAsync({
|
||||
packageVersion,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
signal: tasks.signal,
|
||||
});
|
||||
|
||||
const agentDeviceTunnel = await startNgrokTunnelAsync({
|
||||
port: daemonPort,
|
||||
subdomainPrefix: 'agent-device',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
logger,
|
||||
});
|
||||
const agentDeviceRemoteSessionUrl = agentDeviceTunnel.url;
|
||||
logger.info(`Tunnel is ready at ${agentDeviceRemoteSessionUrl}.`);
|
||||
taskLogger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
|
||||
// The daemon is stored above, so the teardown stops it. The wait itself is bounded
|
||||
// (STARTUP_TIMEOUT_MS) and only reads a file, so it may finish in the background.
|
||||
const daemonInfo = await tasks.untilAborted(waitForDaemonInfoAsync({ daemonProcess }));
|
||||
taskLogger.info(`Daemon is listening on port ${daemonInfo.port}; loaded auth token.`);
|
||||
|
||||
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | 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}`);
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
agentDeviceRemoteSessionUrl,
|
||||
agentDeviceRemoteSessionToken: daemonToken,
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
daemonUrl: `http://127.0.0.1:${daemonPort}`,
|
||||
daemonToken,
|
||||
logger,
|
||||
});
|
||||
|
||||
eventCollection = await startAgentDeviceEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
stateDir: AGENT_DEVICE_STATE_DIR,
|
||||
logger,
|
||||
});
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
maxDurationSeconds,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
if (webPreview) {
|
||||
await webPreview.stopAsync();
|
||||
}
|
||||
await agentDeviceTunnel.stopAsync();
|
||||
if (eventCollection) {
|
||||
await stopAgentDeviceEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
await daemonProcess.stopAsync();
|
||||
}
|
||||
}),
|
||||
tasks.signal.throwIfAborted();
|
||||
agentDeviceTunnel = await startNgrokTunnelAsync({
|
||||
port: daemonInfo.port,
|
||||
subdomainPrefix: 'agent-device',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
logger: taskLogger,
|
||||
});
|
||||
taskLogger.info(`Tunnel is ready at ${agentDeviceTunnel.url}.`);
|
||||
return { ...daemonInfo, remoteSessionUrl: agentDeviceTunnel.url };
|
||||
});
|
||||
const sessionHostStartup = tasks.run('session host', async taskLogger => {
|
||||
// A boot cannot be cancelled, so stop waiting for it when startup is aborted.
|
||||
await tasks.untilAborted(device.booted);
|
||||
tasks.signal.throwIfAborted();
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
networkCapture: capture.networkCapture,
|
||||
networkCaptureFields: capture.networkCaptureFields,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
|
||||
taskLogger.info(
|
||||
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
|
||||
);
|
||||
return webPreview;
|
||||
});
|
||||
|
||||
try {
|
||||
const [
|
||||
{ port: daemonPort, token: daemonToken, remoteSessionUrl: agentDeviceRemoteSessionUrl },
|
||||
webPreview,
|
||||
] = await Promise.all([agentDeviceStartup, sessionHostStartup, device.ready]).catch(
|
||||
(err: unknown) => {
|
||||
// Also aborts for a `device.ready` that does not come from `tasks.run`.
|
||||
tasks.abort(err);
|
||||
throw err;
|
||||
}
|
||||
);
|
||||
logger.info(tasks.summary());
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
agentDeviceRemoteSessionUrl,
|
||||
agentDeviceRemoteSessionToken: daemonToken,
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
daemonUrl: `http://127.0.0.1:${daemonPort}`,
|
||||
daemonToken,
|
||||
logger,
|
||||
});
|
||||
|
||||
eventCollection = await startAgentDeviceEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
stateDir: AGENT_DEVICE_STATE_DIR,
|
||||
logger,
|
||||
});
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
maxDurationSeconds,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
sessionFailed = true;
|
||||
throw error;
|
||||
} finally {
|
||||
// Promise.all rejects on the first failure while other tasks can still be starting.
|
||||
// They stop at their next abort check. Wait for all of them, so the teardown sees
|
||||
// everything that was started.
|
||||
await Promise.allSettled([agentDeviceStartup, sessionHostStartup, device.ready]);
|
||||
const startedDaemon = daemonProcess;
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
['agent-device tunnel', agentDeviceTunnel?.stopAsync()],
|
||||
[
|
||||
'agent-device daemon',
|
||||
startedDaemon &&
|
||||
(async () => {
|
||||
try {
|
||||
if (eventCollection) {
|
||||
await stopAgentDeviceEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await startedDaemon.stopAsync();
|
||||
}
|
||||
})(),
|
||||
],
|
||||
['session host', sessionHost?.finishAsync()],
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function startAgentDeviceDaemonAsync({
|
||||
packageVersion,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
/** Kills the install and stops before the daemon starts, when aborted. No git fallback then. */
|
||||
signal?: AbortSignal;
|
||||
}): Promise<DetachedProcessHandle> {
|
||||
const packageSpec = createAgentDevicePackageSpec(packageVersion);
|
||||
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
|
||||
const installDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-agent-device-'));
|
||||
await fs.promises.writeFile(
|
||||
path.join(installDir, 'package.json'),
|
||||
`${JSON.stringify({ name: 'eas-agent-device', private: true })}\n`
|
||||
);
|
||||
|
||||
try {
|
||||
const add = resolvePackageAdd(packageManager, packageSpec);
|
||||
logger.info(`Installing ${packageSpec} with ${add.command}.`);
|
||||
await spawn(add.command, add.args, { cwd: installDir, env, logger, signal });
|
||||
|
||||
const daemonPath = getInstalledAgentDeviceDaemonPath(installDir);
|
||||
if (!fs.existsSync(daemonPath)) {
|
||||
throw new SystemError(`Expected agent-device daemon entry at ${daemonPath}.`);
|
||||
}
|
||||
|
||||
signal?.throwIfAborted();
|
||||
logger.info(`Launching daemon from ${daemonPath} after ${add.command} install.`);
|
||||
const daemonProcess = spawnDetached({
|
||||
command: 'node',
|
||||
args: [daemonPath],
|
||||
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
|
||||
});
|
||||
return {
|
||||
...daemonProcess,
|
||||
stopAsync: async () => {
|
||||
await daemonProcess.stopAsync();
|
||||
await fs.promises.rm(installDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
await fs.promises.rm(installDir, { recursive: true, force: true });
|
||||
// An abort is not an install problem: stop instead of falling back to git.
|
||||
if (signal?.aborted) {
|
||||
throw err;
|
||||
}
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
const bunVersion = await getBunVersionForDiagnosticsAsync(env);
|
||||
Sentry.capture(
|
||||
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
|
||||
error,
|
||||
{
|
||||
level: 'warning',
|
||||
tags: {
|
||||
phase: 'agent-device-daemon-start',
|
||||
fallback: 'git-clone',
|
||||
},
|
||||
extras: {
|
||||
packageSpec,
|
||||
packageVersion: packageVersion ?? 'latest',
|
||||
packageManager,
|
||||
bunVersion,
|
||||
},
|
||||
}
|
||||
);
|
||||
logger.warn(
|
||||
`Failed to start daemon from ${packageSpec} via ${packageManager}; falling back to git clone: ${error.message}`
|
||||
);
|
||||
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger, signal });
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopAgentDeviceEventCollectionSafelyAsync({
|
||||
@@ -205,85 +360,37 @@ export async function stopAgentDeviceEventCollectionSafelyAsync({
|
||||
}
|
||||
}
|
||||
|
||||
async function startAgentDeviceDaemonAsync({
|
||||
packageVersion,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<DetachedProcessHandle> {
|
||||
const packageSpec = createAgentDevicePackageSpec(packageVersion);
|
||||
try {
|
||||
logger.info(`Installing ${packageSpec} globally with Bun.`);
|
||||
await spawn('bun', ['add', '--global', packageSpec], {
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
const daemonPath = getGlobalAgentDeviceDaemonPath(env);
|
||||
if (!fs.existsSync(daemonPath)) {
|
||||
throw new SystemError(`Expected agent-device daemon entry at ${daemonPath}.`);
|
||||
}
|
||||
|
||||
logger.info(`Launching daemon from ${daemonPath}.`);
|
||||
return spawnDetached({
|
||||
command: 'node',
|
||||
args: [daemonPath],
|
||||
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
const bunVersion = await getBunVersionForDiagnosticsAsync(env);
|
||||
Sentry.capture(
|
||||
'Failed to start agent-device daemon from global Bun package; falling back to git clone',
|
||||
error,
|
||||
{
|
||||
level: 'warning',
|
||||
tags: {
|
||||
phase: 'agent-device-daemon-start',
|
||||
fallback: 'git-clone',
|
||||
},
|
||||
extras: {
|
||||
packageSpec,
|
||||
packageVersion: packageVersion ?? 'latest',
|
||||
bunVersion,
|
||||
bunInstallConfigured: Boolean(env.BUN_INSTALL?.trim()),
|
||||
},
|
||||
}
|
||||
);
|
||||
logger.warn(
|
||||
`Failed to start daemon from global ${packageSpec}; falling back to git clone: ${error.message}`
|
||||
);
|
||||
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger });
|
||||
}
|
||||
}
|
||||
|
||||
async function startAgentDeviceDaemonFromGitAsync({
|
||||
packageVersion,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<DetachedProcessHandle> {
|
||||
logger.info(
|
||||
packageVersion
|
||||
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
|
||||
: `Cloning agent-device (latest) into ${SRC_DIR}.`
|
||||
);
|
||||
await cloneAgentDeviceAsync({ packageVersion, env, logger });
|
||||
await cloneAgentDeviceAsync({ packageVersion, env, logger, signal });
|
||||
|
||||
logger.info('Installing agent-device dependencies.');
|
||||
await spawn('bun', ['install', '--production'], {
|
||||
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
|
||||
const install = resolvePackageInstall(packageManager, { production: true });
|
||||
logger.info(`Installing agent-device dependencies with ${install.command}.`);
|
||||
await spawn(install.command, install.args, {
|
||||
cwd: SRC_DIR,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
|
||||
signal?.throwIfAborted();
|
||||
logger.info('Launching daemon from cloned agent-device source.');
|
||||
// Git fallback is TypeScript source. The published path runs node on dist JS.
|
||||
return spawnDetached({
|
||||
command: 'bun',
|
||||
args: ['run', 'src/daemon.ts'],
|
||||
@@ -296,15 +403,18 @@ async function cloneAgentDeviceAsync({
|
||||
packageVersion,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const branchArgs = packageVersion ? ['--branch', `v${packageVersion}`] : [];
|
||||
await spawn('git', ['clone', '--depth', '1', ...branchArgs, AGENT_DEVICE_REPO_URL, SRC_DIR], {
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -346,11 +456,9 @@ function createAgentDevicePackageSpec(packageVersion: string | undefined): strin
|
||||
return `${AGENT_DEVICE_PACKAGE_NAME}@${versionSpec}`;
|
||||
}
|
||||
|
||||
function getGlobalAgentDeviceDaemonPath(env: BuildStepEnv): string {
|
||||
function getInstalledAgentDeviceDaemonPath(installDir: string): string {
|
||||
return path.join(
|
||||
getBunInstallDirectory(env),
|
||||
'install',
|
||||
'global',
|
||||
installDir,
|
||||
'node_modules',
|
||||
AGENT_DEVICE_PACKAGE_NAME,
|
||||
'dist',
|
||||
@@ -360,11 +468,6 @@ function getGlobalAgentDeviceDaemonPath(env: BuildStepEnv): string {
|
||||
);
|
||||
}
|
||||
|
||||
function getBunInstallDirectory(env: BuildStepEnv): string {
|
||||
const bunInstall = env.BUN_INSTALL?.trim();
|
||||
return bunInstall ? bunInstall : path.join(os.homedir(), '.bun');
|
||||
}
|
||||
|
||||
function parseDaemonInfo(raw: string): { port: number; token: string } {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
AndroidDeviceName,
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorName, IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { withLocalEgressSession } from '../utils/localEgressSession';
|
||||
import { selectXcodeDeveloperDirectoryAsync } from '../utils/remoteDeviceRunSession';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
} from '../utils/networkCaptureFields';
|
||||
import { createStartupTasks } from '../utils/startupTasks';
|
||||
|
||||
import { downloadBuildAsync } from './downloadBuild';
|
||||
import { installBuildAsync } from './installBuild';
|
||||
import {
|
||||
launchApplicationAsync,
|
||||
parseLaunchArgsInput,
|
||||
parseOpenUrlInput,
|
||||
} from './launchApplication';
|
||||
import {
|
||||
getAgentDeviceRemoteSessionEnvOrThrow,
|
||||
runAgentDeviceRemoteSessionAsync,
|
||||
} from './startAgentDeviceRemoteSession';
|
||||
import { startAndroidEmulatorAsync } from './startAndroidEmulator';
|
||||
import { bootIosSimulatorAsync } from './startIosSimulator';
|
||||
|
||||
const ANDROID_DEVICE_NAME = 'EasAndroidDevice01' as AndroidVirtualDeviceName;
|
||||
|
||||
/**
|
||||
* One step for a whole agent-device session: boot the device, download, install and
|
||||
* launch the app, and start the agent-device daemon and the web preview.
|
||||
*
|
||||
* It does the work of eas/start_ios_simulator or eas/start_android_emulator,
|
||||
* eas/download_build, eas/install_build and eas/launch_application, plus the agent-device
|
||||
* session, so the parts that do not depend on each other can run at the same time:
|
||||
*
|
||||
* boot ─────────────┬─► install ─► launch ─┐
|
||||
* download ─────────┘ │
|
||||
* boot ─► session host ─► web preview ─────┼─► ready
|
||||
* agent-device daemon ─► tunnel ───────────┘
|
||||
*
|
||||
* The first failure aborts the rest: the download stops, and nothing installs, launches
|
||||
* or starts after it. A boot cannot be cancelled, so it can still run when a failed
|
||||
* step returns.
|
||||
*/
|
||||
export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_agent_device_session',
|
||||
name: 'Start agent-device session',
|
||||
__metricsId: 'eas/start_agent_device_session',
|
||||
inputProviders: [
|
||||
// Device. On iOS a Simulator name or UDID, on Android an AVD hardware profile.
|
||||
BuildStepInput.createProvider({
|
||||
id: 'device_identifier',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'system_image_package',
|
||||
required: false,
|
||||
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,
|
||||
}),
|
||||
// Application. Pass at most one of build_id and application_archive_url.
|
||||
BuildStepInput.createProvider({
|
||||
id: 'build_id',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'application_archive_url',
|
||||
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,
|
||||
}),
|
||||
// Session.
|
||||
...createNetworkCaptureInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_idle_time_minutes',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_duration_seconds',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: withLocalEgressSession(async (stepsCtx, { inputs, env, signal }) => {
|
||||
const { logger, global } = stepsCtx;
|
||||
const { runtimePlatform } = global;
|
||||
const isIos = runtimePlatform === BuildRuntimePlatform.DARWIN;
|
||||
|
||||
// Validate everything before any expensive work starts.
|
||||
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(env);
|
||||
const buildId = inputs.build_id.value as string | undefined;
|
||||
const applicationArchiveUrl = inputs.application_archive_url.value as string | undefined;
|
||||
if (buildId && applicationArchiveUrl) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
|
||||
'Pass only one of build_id or application_archive_url.'
|
||||
);
|
||||
}
|
||||
const hasApplication = Boolean(buildId || applicationArchiveUrl);
|
||||
const launchArgs = parseLaunchArgsInput(inputs.launch_args.value);
|
||||
const openUrl =
|
||||
inputs.open_url.value === undefined ? undefined : parseOpenUrlInput(inputs.open_url.value);
|
||||
if (!hasApplication && (launchArgs.length > 0 || openUrl)) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
'launch_args and open_url need an application: pass build_id or application_archive_url.'
|
||||
);
|
||||
}
|
||||
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
|
||||
const capture = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture.value,
|
||||
networkCaptureFields: inputs.network_capture_fields.value,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
|
||||
if (isIos) {
|
||||
// Before the boot, so every Xcode tool below uses the same developer directory.
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
|
||||
const tasks = createStartupTasks(logger);
|
||||
|
||||
const booted = tasks.run(
|
||||
isIos ? 'iOS Simulator boot' : 'Android Emulator boot',
|
||||
async taskLogger => {
|
||||
if (isIos) {
|
||||
await bootIosSimulatorAsync({
|
||||
deviceIdentifier: deviceIdentifier as IosSimulatorUuid | IosSimulatorName | undefined,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await startAndroidEmulatorAsync({
|
||||
deviceName: ANDROID_DEVICE_NAME,
|
||||
systemImagePackage: `${inputs.system_image_package.value}`,
|
||||
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
|
||||
lcdWidth: inputs.lcd_width.value as number | undefined,
|
||||
lcdHeight: inputs.lcd_height.value as number | undefined,
|
||||
lcdDensity: inputs.lcd_density.value as number | undefined,
|
||||
logcatDirectory: await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
|
||||
),
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const downloaded = hasApplication
|
||||
? tasks.run('build download', async taskLogger => {
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: taskLogger,
|
||||
...(buildId ? { buildId } : { applicationArchiveUrl: applicationArchiveUrl! }),
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
|
||||
extensions: isIos ? ['app'] : ['apk'],
|
||||
signal: tasks.signal,
|
||||
});
|
||||
return artifactPath;
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// `ready` settles only after everything it started has settled, so the session
|
||||
// teardown, which waits for it, never leaves a download running. It must also settle
|
||||
// soon after an abort: it stops waiting for the boot then, and the download stops.
|
||||
const ready = downloaded
|
||||
? tasks.run('app install and launch', async taskLogger => {
|
||||
const [download, boot] = await Promise.allSettled([
|
||||
downloaded,
|
||||
tasks.untilAborted(booted),
|
||||
]);
|
||||
// After a failure elsewhere, report that failure, not the abort it caused here.
|
||||
tasks.signal.throwIfAborted();
|
||||
if (download.status === 'rejected') {
|
||||
throw download.reason;
|
||||
}
|
||||
if (boot.status === 'rejected') {
|
||||
throw boot.reason;
|
||||
}
|
||||
const { applicationIdentifier, activityName } = await installBuildAsync({
|
||||
artifactPath: download.value,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier,
|
||||
activityName,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
})
|
||||
: tasks.untilAborted(booted);
|
||||
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
runtimePlatform,
|
||||
sessionEnv,
|
||||
packageVersion: inputs.package_version.value as string | undefined,
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
|
||||
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
|
||||
capture,
|
||||
tasks,
|
||||
device: { booted, ready },
|
||||
});
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
type BuildStepEnv,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
@@ -14,10 +16,12 @@ import path from 'node:path';
|
||||
|
||||
import {
|
||||
AndroidDeviceName,
|
||||
AndroidDeviceSerialId,
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../../utils/retry';
|
||||
import { resolveAndroidEmulatorLocalEgressAsync } from '../utils/androidLocalEgress';
|
||||
|
||||
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
|
||||
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
|
||||
@@ -80,140 +84,20 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
);
|
||||
outputs.logcat_directory.set(logcatDirectory);
|
||||
|
||||
if (env.EAS_NO_EMULATOR_HOST_SUPPORT_CHECK !== '1') {
|
||||
await assertAndroidEmulatorHostSupportAsync({ env });
|
||||
} else {
|
||||
logger.info(
|
||||
'Skipping Android emulator host support check because $EAS_NO_EMULATOR_HOST_SUPPORT_CHECK is enabled.'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const availableDevices = await AndroidEmulatorUtils.getAvailableDevicesAsync({ env });
|
||||
logger.info(`Available Android devices:\n- ${availableDevices.join(`\n- `)}`);
|
||||
} catch (error) {
|
||||
logger.info('Failed to list available Android devices.', error);
|
||||
} finally {
|
||||
logger.info('');
|
||||
}
|
||||
|
||||
const deviceName = `${inputs.device_name.value}` as AndroidVirtualDeviceName;
|
||||
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';
|
||||
|
||||
if (!shouldAdjustAnimationScale) {
|
||||
logger.info(
|
||||
'Skipping Android emulator animation scale adjustments because $ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE is disabled.'
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('Making sure system image is installed');
|
||||
await retryAsync(
|
||||
async () => {
|
||||
await spawn('sdkmanager', [systemImagePackage], {
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
},
|
||||
{
|
||||
const { serialId, emulatorPromise, shouldAdjustAnimationScale } =
|
||||
await startAndroidEmulatorAsync({
|
||||
deviceName,
|
||||
systemImagePackage: `${inputs.system_image_package.value}`,
|
||||
// We can cast because allowedValueTypeName validated this is a string.
|
||||
deviceIdentifier: inputs.device_identifier.value as AndroidDeviceName | undefined,
|
||||
lcdWidth: inputs.lcd_width.value as number | undefined,
|
||||
lcdHeight: inputs.lcd_height.value as number | undefined,
|
||||
lcdDensity: inputs.lcd_density.value as number | undefined,
|
||||
logcatDirectory,
|
||||
env,
|
||||
logger,
|
||||
retryOptions: {
|
||||
retries: 3, // Retry 3 times
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
let emulatorPromise = null;
|
||||
let serialId = null;
|
||||
await retryAsync(
|
||||
async attemptCount => {
|
||||
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
|
||||
const attempt = attemptCount + 1;
|
||||
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
|
||||
const attemptSuffix = attempt > 1 ? ` (attempt ${attempt}/${maxAttempts})` : '';
|
||||
let attemptSerialId = null;
|
||||
|
||||
try {
|
||||
logger.info(`Creating emulator device${attemptSuffix}.`);
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
deviceName,
|
||||
systemImagePackage,
|
||||
deviceIdentifier: deviceIdentifier ?? null,
|
||||
lcdWidth: lcdWidth ?? null,
|
||||
lcdHeight: lcdHeight ?? null,
|
||||
lcdDensity: lcdDensity ?? null,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info(`Starting emulator device${attemptSuffix}.`);
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env,
|
||||
logcatDirectory,
|
||||
});
|
||||
attemptSerialId = startResult.serialId;
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
env,
|
||||
serialId: attemptSerialId,
|
||||
timeoutMs,
|
||||
logger,
|
||||
});
|
||||
if (shouldAdjustAnimationScale) {
|
||||
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
|
||||
env,
|
||||
logger,
|
||||
serialId: attemptSerialId,
|
||||
});
|
||||
}
|
||||
logger.info(`${deviceName} is ready.`);
|
||||
|
||||
serialId = attemptSerialId;
|
||||
emulatorPromise = startResult.emulatorPromise;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`${deviceName} failed to start on attempt ${attempt}/${maxAttempts}.`
|
||||
);
|
||||
try {
|
||||
if (attemptSerialId) {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId: attemptSerialId,
|
||||
deviceName,
|
||||
env,
|
||||
});
|
||||
} else {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
deviceName,
|
||||
env,
|
||||
});
|
||||
}
|
||||
} catch (cleanupErr) {
|
||||
logger.warn({ err: cleanupErr }, `Failed to clean up ${deviceName}.`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
{
|
||||
logger,
|
||||
retryOptions: {
|
||||
retries: ANDROID_STARTUP_RETRIES_COUNT,
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!serialId || !emulatorPromise) {
|
||||
throw new Error(`Failed to start emulator ${deviceName}.`);
|
||||
}
|
||||
});
|
||||
|
||||
const count = Number(inputs.count.value ?? 1);
|
||||
if (count > 1) {
|
||||
@@ -306,6 +190,167 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the host, installs the system image, then creates and starts one emulator and
|
||||
* waits until it is ready, with retries.
|
||||
*/
|
||||
export async function startAndroidEmulatorAsync({
|
||||
deviceName,
|
||||
systemImagePackage,
|
||||
deviceIdentifier,
|
||||
lcdWidth,
|
||||
lcdHeight,
|
||||
lcdDensity,
|
||||
logcatDirectory,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
deviceName: AndroidVirtualDeviceName;
|
||||
systemImagePackage: string;
|
||||
deviceIdentifier?: AndroidDeviceName;
|
||||
lcdWidth?: number;
|
||||
lcdHeight?: number;
|
||||
lcdDensity?: number;
|
||||
logcatDirectory: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<{
|
||||
serialId: AndroidDeviceSerialId;
|
||||
emulatorPromise: Promise<unknown>;
|
||||
shouldAdjustAnimationScale: boolean;
|
||||
}> {
|
||||
if (env.EAS_NO_EMULATOR_HOST_SUPPORT_CHECK !== '1') {
|
||||
await assertAndroidEmulatorHostSupportAsync({ env });
|
||||
} else {
|
||||
logger.info(
|
||||
'Skipping Android emulator host support check because $EAS_NO_EMULATOR_HOST_SUPPORT_CHECK is enabled.'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const availableDevices = await AndroidEmulatorUtils.getAvailableDevicesAsync({ env });
|
||||
logger.info(`Available Android devices:\n- ${availableDevices.join(`\n- `)}`);
|
||||
} catch (error) {
|
||||
logger.info('Failed to list available Android devices.', error);
|
||||
} finally {
|
||||
logger.info('');
|
||||
}
|
||||
|
||||
const shouldAdjustAnimationScale =
|
||||
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== 'false' &&
|
||||
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== '0';
|
||||
|
||||
if (!shouldAdjustAnimationScale) {
|
||||
logger.info(
|
||||
'Skipping Android emulator animation scale adjustments because $ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE is disabled.'
|
||||
);
|
||||
}
|
||||
|
||||
const localEgress = await resolveAndroidEmulatorLocalEgressAsync();
|
||||
|
||||
logger.info('Making sure system image is installed');
|
||||
await retryAsync(
|
||||
async () => {
|
||||
await spawn('sdkmanager', [systemImagePackage], {
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
},
|
||||
{
|
||||
logger,
|
||||
retryOptions: {
|
||||
retries: 3, // Retry 3 times
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
let emulatorPromise = null;
|
||||
let serialId = null;
|
||||
await retryAsync(
|
||||
async attemptCount => {
|
||||
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
|
||||
const attempt = attemptCount + 1;
|
||||
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
|
||||
const attemptSuffix = attempt > 1 ? ` (attempt ${attempt}/${maxAttempts})` : '';
|
||||
let attemptSerialId = null;
|
||||
|
||||
try {
|
||||
logger.info(`Creating emulator device${attemptSuffix}.`);
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
deviceName,
|
||||
systemImagePackage,
|
||||
deviceIdentifier: deviceIdentifier ?? null,
|
||||
lcdWidth: lcdWidth ?? null,
|
||||
lcdHeight: lcdHeight ?? null,
|
||||
lcdDensity: lcdDensity ?? null,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info(`Starting emulator device${attemptSuffix}.`);
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env,
|
||||
logcatDirectory,
|
||||
...(localEgress ? { launchGate: localEgress.launchGate } : {}),
|
||||
});
|
||||
attemptSerialId = startResult.serialId;
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
env,
|
||||
serialId: attemptSerialId,
|
||||
timeoutMs,
|
||||
logger,
|
||||
...(localEgress ? { networkReadyTarget: localEgress.networkReadyTarget } : {}),
|
||||
});
|
||||
if (shouldAdjustAnimationScale) {
|
||||
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
|
||||
env,
|
||||
logger,
|
||||
serialId: attemptSerialId,
|
||||
});
|
||||
}
|
||||
await localEgress?.configureBootedEmulatorAsync({ serialId: attemptSerialId, logger });
|
||||
logger.info(`${deviceName} is ready.`);
|
||||
|
||||
serialId = attemptSerialId;
|
||||
emulatorPromise = startResult.emulatorPromise;
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `${deviceName} failed to start on attempt ${attempt}/${maxAttempts}.`);
|
||||
try {
|
||||
if (attemptSerialId) {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId: attemptSerialId,
|
||||
deviceName,
|
||||
env,
|
||||
});
|
||||
} else {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
deviceName,
|
||||
env,
|
||||
});
|
||||
}
|
||||
} catch (cleanupErr) {
|
||||
logger.warn({ err: cleanupErr }, `Failed to clean up ${deviceName}.`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
{
|
||||
logger,
|
||||
retryOptions: {
|
||||
retries: ANDROID_STARTUP_RETRIES_COUNT,
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!serialId || !emulatorPromise) {
|
||||
throw new Error(`Failed to start emulator ${deviceName}.`);
|
||||
}
|
||||
return { serialId, emulatorPromise, shouldAdjustAnimationScale };
|
||||
}
|
||||
|
||||
async function assertAndroidEmulatorHostSupportAsync({
|
||||
env,
|
||||
}: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
@@ -19,18 +20,32 @@ import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
} from '../utils/networkCaptureFields';
|
||||
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageAdd,
|
||||
} from '../../utils/packageManager';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
|
||||
import {
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
@@ -54,6 +69,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
name: 'Start Appium remote session',
|
||||
__metricsId: 'eas/start_appium_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -72,18 +89,38 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
const launch = parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
networkCaptureFields: inputs.network_capture_fields?.value,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
const versionSpec = resolveAppium3VersionSpec(packageVersion);
|
||||
|
||||
logger.info(
|
||||
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
const device = await resolveAppiumDeviceAsync({ runtimePlatform, env, logger });
|
||||
const { appiumHome, appiumBinPath, appiumEnv } = await installAppiumAsync({
|
||||
versionSpec,
|
||||
driverName: device.driverName,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
// Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the
|
||||
// simulator stream with it, and UiAutomator2 merges long recordings with it. The
|
||||
// macOS session image does not ship ffmpeg, so install it concurrently with Appium.
|
||||
// Both installs settle before the step continues or fails, so no install outlives it.
|
||||
const [appiumInstall] = await Promise.all([
|
||||
asyncResult(
|
||||
installAppiumAsync({ versionSpec, driverName: device.driverName, env, logger })
|
||||
),
|
||||
ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger }),
|
||||
]);
|
||||
const { appiumHome, appiumBinPath, appiumEnv } = appiumInstall.enforceValue();
|
||||
|
||||
const appiumProcess = spawnDetached({
|
||||
command: appiumBinPath,
|
||||
@@ -121,7 +158,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
logger,
|
||||
});
|
||||
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let sessionFailed = false;
|
||||
try {
|
||||
appiumTunnel = await startNgrokTunnelAsync({
|
||||
port: APPIUM_PORT,
|
||||
@@ -133,13 +171,22 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
|
||||
// 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, {
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
});
|
||||
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
@@ -153,7 +200,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
'appium:automationName': device.automationName,
|
||||
'appium:udid': device.udid,
|
||||
},
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
@@ -172,16 +220,29 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
sessionFailed = true;
|
||||
throw error;
|
||||
} finally {
|
||||
if (webPreview) {
|
||||
await webPreview.stopAsync();
|
||||
}
|
||||
if (appiumTunnel) {
|
||||
await appiumTunnel.stopAsync();
|
||||
}
|
||||
await eventCollection.stopAsync();
|
||||
await appiumProcess.stopAsync();
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
['Appium tunnel', appiumTunnel?.stopAsync()],
|
||||
[
|
||||
'Appium server',
|
||||
(async () => {
|
||||
try {
|
||||
await eventCollection.stopAsync();
|
||||
} finally {
|
||||
await appiumProcess.stopAsync();
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
})(),
|
||||
],
|
||||
['session host', sessionHost?.finishAsync()],
|
||||
],
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -198,7 +259,7 @@ export function resolveAppium3VersionSpec(packageVersion: string | undefined): s
|
||||
return versionSpec;
|
||||
}
|
||||
|
||||
type AppiumDevice = {
|
||||
export type AppiumDevice = {
|
||||
platformName: 'iOS' | 'Android';
|
||||
automationName: 'XCUITest' | 'UiAutomator2';
|
||||
driverName: 'xcuitest' | 'uiautomator2';
|
||||
@@ -247,7 +308,7 @@ export async function resolveAppiumDeviceAsync({
|
||||
}
|
||||
}
|
||||
|
||||
async function installAppiumAsync({
|
||||
export async function installAppiumAsync({
|
||||
versionSpec,
|
||||
driverName,
|
||||
env,
|
||||
@@ -265,9 +326,14 @@ async function installAppiumAsync({
|
||||
);
|
||||
const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome };
|
||||
const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium');
|
||||
const add = resolvePackageAdd(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
`appium@${versionSpec}`
|
||||
);
|
||||
|
||||
logger.info(`Installing appium@${versionSpec}.`);
|
||||
await spawn('npm', ['install', '--prefix', appiumHome, `appium@${versionSpec}`], {
|
||||
logger.info(`Installing appium@${versionSpec} with ${add.command}.`);
|
||||
await spawn(add.command, add.args, {
|
||||
cwd: appiumHome,
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -18,19 +18,32 @@ import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
} from '../utils/networkCaptureFields';
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageExec,
|
||||
} from '../../utils/packageManager';
|
||||
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 {
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
@@ -66,6 +79,8 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
name: 'Start argent remote session',
|
||||
__metricsId: 'eas/start_argent_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -98,6 +113,21 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
|
||||
const versionSpec = packageVersion ?? 'latest';
|
||||
const { runtimePlatform } = global;
|
||||
const launch = parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value as string | undefined,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
networkCaptureFields: inputs.network_capture_fields?.value,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
logger.info(
|
||||
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
@@ -113,42 +143,45 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
// Never rejects, so `void` is safe.
|
||||
void ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
|
||||
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
|
||||
const argentExec = (args: string[]): { command: string; args: string[] } =>
|
||||
resolvePackageExec(packageManager, args);
|
||||
|
||||
logger.info('Enabling the Argent artifacts list endpoint flag.');
|
||||
await spawn(
|
||||
'bun',
|
||||
[
|
||||
'x',
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
|
||||
],
|
||||
{ env, logger }
|
||||
);
|
||||
const enableArtifacts = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
|
||||
]);
|
||||
await spawn(enableArtifacts.command, enableArtifacts.args, { 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],
|
||||
{ env, logger }
|
||||
);
|
||||
const enableEventLog = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_EVENT_LOG_FLAG,
|
||||
]);
|
||||
await spawn(enableEventLog.command, enableEventLog.args, { 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.
|
||||
const startServer = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
'start',
|
||||
'--port',
|
||||
'0',
|
||||
'--idle-timeout',
|
||||
'0',
|
||||
'--force',
|
||||
]);
|
||||
logger.info(
|
||||
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
|
||||
);
|
||||
// Keep Argent itself in foreground mode under the detached process. This preserves
|
||||
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
const argentServer = spawnDetached({
|
||||
command: 'bun',
|
||||
args: [
|
||||
'x',
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
'start',
|
||||
'--port',
|
||||
'0',
|
||||
'--idle-timeout',
|
||||
'0',
|
||||
'--force',
|
||||
],
|
||||
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH },
|
||||
command: startServer.command,
|
||||
args: startServer.args,
|
||||
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, ARGENT_EMULATOR_NO_WINDOW: '1' },
|
||||
});
|
||||
if (argentServer.pid === undefined) {
|
||||
throw new SystemError(
|
||||
@@ -164,6 +197,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
stateDir: ARGENT_STATE_DIR,
|
||||
ancestorPid: argentServer.pid,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
getExitError: argentServer.getExitError,
|
||||
});
|
||||
toolServerPort = toolServerState.port;
|
||||
toolServerToken = toolServerState.token;
|
||||
@@ -196,7 +230,8 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
});
|
||||
|
||||
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let sessionFailed = false;
|
||||
try {
|
||||
toolsTunnel = await startNgrokTunnelAsync({
|
||||
port: toolServerPort,
|
||||
@@ -209,14 +244,25 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
const publicToolsUrl = toolsTunnel.url;
|
||||
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
|
||||
|
||||
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
});
|
||||
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
|
||||
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
|
||||
logger.info(
|
||||
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
|
||||
);
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
@@ -226,7 +272,8 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
remoteConfig: {
|
||||
toolsUrl: publicToolsUrl,
|
||||
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
@@ -246,23 +293,46 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
sessionFailed = true;
|
||||
throw error;
|
||||
} finally {
|
||||
if (webPreview) {
|
||||
await webPreview.stopAsync();
|
||||
}
|
||||
if (toolsTunnel) {
|
||||
await toolsTunnel.stopAsync();
|
||||
}
|
||||
await stopArgentEventCollectionSafelyAsync({ eventCollection, deviceRunSessionId, logger });
|
||||
artifactPollAbortController.abort();
|
||||
try {
|
||||
await artifactPollingPromise;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not finish Argent remote session artifact polling', error);
|
||||
logger.warn({ err: error }, 'Could not finish Argent remote session artifact polling.');
|
||||
}
|
||||
await argentServer.stopAsync();
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
['Argent tunnel', toolsTunnel?.stopAsync()],
|
||||
[
|
||||
'Argent tool-server',
|
||||
(async () => {
|
||||
try {
|
||||
await stopArgentEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
});
|
||||
artifactPollAbortController.abort();
|
||||
try {
|
||||
await artifactPollingPromise;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture(
|
||||
'Could not finish Argent remote session artifact polling',
|
||||
error
|
||||
);
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Could not finish Argent remote session artifact polling.'
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await argentServer.stopAsync();
|
||||
}
|
||||
})(),
|
||||
],
|
||||
['session host', sessionHost?.finishAsync()],
|
||||
],
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -306,7 +376,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 the package manager resolve it.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -341,16 +411,26 @@ export async function waitForArgentToolServerStateAsync({
|
||||
ancestorPid,
|
||||
timeoutMs,
|
||||
pollIntervalMs = 1_000,
|
||||
getExitError,
|
||||
}: {
|
||||
stateDir: string;
|
||||
ancestorPid: number;
|
||||
timeoutMs: number;
|
||||
pollIntervalMs?: number;
|
||||
getExitError?: () => Error | undefined;
|
||||
}): Promise<ArgentToolServerState> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
const throwIfExited = (): void => {
|
||||
const error = getExitError?.();
|
||||
if (error) {
|
||||
throw new SystemError(`Argent exited before becoming ready: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
throwIfExited();
|
||||
let matchingState: ArgentToolServerState | undefined;
|
||||
try {
|
||||
const stateFileNames = (await fs.promises.readdir(stateDir)).filter(
|
||||
name => name.startsWith('tool-server') && name.endsWith('.json')
|
||||
@@ -361,7 +441,8 @@ export async function waitForArgentToolServerStateAsync({
|
||||
await fs.promises.readFile(path.join(stateDir, stateFileName), 'utf8')
|
||||
);
|
||||
if (await isProcessDescendantOfAsync(state.pid, ancestorPid)) {
|
||||
return state;
|
||||
matchingState = state;
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT' && !(err instanceof SystemError)) {
|
||||
@@ -372,6 +453,11 @@ export async function waitForArgentToolServerStateAsync({
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
// Keep process failures outside the retryable state-file read errors.
|
||||
throwIfExited();
|
||||
if (matchingState) {
|
||||
return matchingState;
|
||||
}
|
||||
await sleepAsync(pollIntervalMs);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,59 +47,18 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { inputs, env }) => {
|
||||
try {
|
||||
const availableDevices = await IosSimulatorUtils.getAvailableDevicesAsync({
|
||||
env,
|
||||
filter: 'available',
|
||||
});
|
||||
logger.info(
|
||||
`Available Simulator devices:\n- ${availableDevices
|
||||
.map(device => device.displayName)
|
||||
.join(`\n- `)}`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.info('Failed to list available Simulator devices.', error);
|
||||
} finally {
|
||||
logger.info('');
|
||||
}
|
||||
|
||||
const deviceIdentifierInput = inputs.device_identifier.value?.toString() as
|
||||
| IosSimulatorUuid
|
||||
| IosSimulatorName
|
||||
| undefined;
|
||||
const originalDeviceIdentifier =
|
||||
deviceIdentifierInput ?? (await findMostGenericIphoneUuidAsync({ env }));
|
||||
const enableAccessibilitySettings = Boolean(inputs.enable_accessibility_settings.value);
|
||||
|
||||
if (!originalDeviceIdentifier) {
|
||||
throw new Error('Could not find an iPhone among available simulator devices.');
|
||||
}
|
||||
|
||||
if (enableAccessibilitySettings) {
|
||||
await IosSimulatorUtils.enableAccessibilitySettingsAsync({
|
||||
deviceIdentifier: originalDeviceIdentifier,
|
||||
const { deviceIdentifier: originalDeviceIdentifier, displayName: formattedDevice } =
|
||||
await bootIosSimulatorAsync({
|
||||
deviceIdentifier: deviceIdentifierInput,
|
||||
enableAccessibilitySettings,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
const udid = await bootWithLocalEgressAsync({
|
||||
deviceIdentifier: originalDeviceIdentifier,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
await IosSimulatorUtils.disableApsdAsync({ udid, env });
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Failed to disable apsd in the Simulator.');
|
||||
}
|
||||
|
||||
await IosSimulatorUtils.waitForReadyAsync({ udid, env });
|
||||
|
||||
logger.info('');
|
||||
|
||||
const device = await IosSimulatorUtils.getDeviceAsync({ udid, env });
|
||||
const formattedDevice = device?.displayName ?? originalDeviceIdentifier;
|
||||
logger.info(`${formattedDevice} is ready.`);
|
||||
|
||||
const count = Number(inputs.count.value ?? 1);
|
||||
if (count > 1) {
|
||||
@@ -150,6 +109,67 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Boots one iOS Simulator and waits until it is ready: the requested device, or the
|
||||
* most generic iPhone when none is given.
|
||||
*/
|
||||
export async function bootIosSimulatorAsync({
|
||||
deviceIdentifier: deviceIdentifierInput,
|
||||
enableAccessibilitySettings = false,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
deviceIdentifier?: IosSimulatorUuid | IosSimulatorName;
|
||||
enableAccessibilitySettings?: boolean;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<{
|
||||
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
|
||||
udid: IosSimulatorUuid;
|
||||
displayName: string;
|
||||
}> {
|
||||
try {
|
||||
const availableDevices = await IosSimulatorUtils.getAvailableDevicesAsync({
|
||||
env,
|
||||
filter: 'available',
|
||||
});
|
||||
logger.info(
|
||||
`Available Simulator devices:\n- ${availableDevices
|
||||
.map(device => device.displayName)
|
||||
.join(`\n- `)}`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.info('Failed to list available Simulator devices.', error);
|
||||
} finally {
|
||||
logger.info('');
|
||||
}
|
||||
|
||||
const deviceIdentifier = deviceIdentifierInput ?? (await findMostGenericIphoneUuidAsync({ env }));
|
||||
if (!deviceIdentifier) {
|
||||
throw new Error('Could not find an iPhone among available simulator devices.');
|
||||
}
|
||||
|
||||
if (enableAccessibilitySettings) {
|
||||
await IosSimulatorUtils.enableAccessibilitySettingsAsync({ deviceIdentifier, env });
|
||||
}
|
||||
const udid = await bootWithLocalEgressAsync({ deviceIdentifier, env, logger });
|
||||
|
||||
try {
|
||||
await IosSimulatorUtils.disableApsdAsync({ udid, env });
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Failed to disable apsd in the Simulator.');
|
||||
}
|
||||
|
||||
await IosSimulatorUtils.waitForReadyAsync({ udid, env });
|
||||
|
||||
logger.info('');
|
||||
|
||||
const device = await IosSimulatorUtils.getDeviceAsync({ udid, env });
|
||||
const displayName = device?.displayName ?? deviceIdentifier;
|
||||
logger.info(`${displayName} is ready.`);
|
||||
return { deviceIdentifier, udid, displayName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot a device with the local egress environment in place before anything
|
||||
* inside it starts. `simctl boot` returns once the simulator's launchd is up
|
||||
|
||||
@@ -4,6 +4,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { startAndroidLocalEgressAsync } from '../utils/androidLocalEgress';
|
||||
import {
|
||||
CHISEL_VERSION,
|
||||
LOCAL_EGRESS_HANDOFF_PATH,
|
||||
@@ -79,8 +80,9 @@ function awaitLocalEgressAcquisitionAsync<T>(
|
||||
* at boot. Once a simulator boots, `eas/start_ios_simulator` also sets proxy
|
||||
* environment variables inside it for clients that read them (gRPC, libcurl)
|
||||
* and installs the local egress guard, which refuses connections that ignore
|
||||
* both. The shared session cleanup releases the resources started here when
|
||||
* the session ends, with a job finalizer as a fallback.
|
||||
* both. On Linux it starts the Android emulator relay and fence instead; see
|
||||
* androidLocalEgress.ts. 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({
|
||||
@@ -88,13 +90,15 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
|
||||
id: 'start_local_egress',
|
||||
name: 'Start local egress',
|
||||
__metricsId: 'eas/start_local_egress',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
fn: async ({ logger }, { env, signal }) => {
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX],
|
||||
fn: async ({ logger, global }, { env, signal }) => {
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
const isAndroid = global?.runtimePlatform === BuildRuntimePlatform.LINUX;
|
||||
let workDir: string | undefined;
|
||||
let server: DetachedProcessHandle | undefined;
|
||||
let tunnel: NgrokTunnelHandle | undefined;
|
||||
let android: { stopAsync: () => Promise<void> } | undefined;
|
||||
let finishSetup!: () => void;
|
||||
const setupFinished = new Promise<void>(resolve => {
|
||||
finishSetup = resolve;
|
||||
@@ -105,6 +109,7 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
|
||||
await setupFinished;
|
||||
const results = await Promise.allSettled([
|
||||
Promise.resolve().then(() => stopLocalEgressGuardRelaysAsync(logger)),
|
||||
Promise.resolve().then(() => android?.stopAsync()),
|
||||
Promise.resolve().then(() => tunnel?.stopAsync()),
|
||||
Promise.resolve().then(() => server?.stopAsync()),
|
||||
]);
|
||||
@@ -169,6 +174,35 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
|
||||
);
|
||||
startupSignal.throwIfAborted();
|
||||
|
||||
if (isAndroid) {
|
||||
if (server.pid === undefined) {
|
||||
throw new Error('The reverse tunnel server has no process id.');
|
||||
}
|
||||
android = await startAndroidLocalEgressAsync({
|
||||
env,
|
||||
logger,
|
||||
workDir,
|
||||
chiselPid: server.pid,
|
||||
proxyPort: LOCAL_EGRESS_PROXY_PORT,
|
||||
controlPort,
|
||||
});
|
||||
startupSignal.throwIfAborted();
|
||||
await writeLocalEgressHandoffAsync({
|
||||
url: tunnel.url,
|
||||
token: credentials.password,
|
||||
fingerprint: started.fingerprint,
|
||||
port: LOCAL_EGRESS_PROXY_PORT,
|
||||
platform: 'android',
|
||||
});
|
||||
startupSignal.throwIfAborted();
|
||||
logger.info(
|
||||
"Local egress is configured for the Android emulator. Its TCP traffic goes through the emulator's " +
|
||||
'proxy and fails until the EAS CLI egress client connects, then exits from that machine. ' +
|
||||
'Traffic that cannot use the tunnel, such as UDP, is refused and reported here.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { service } = await configureSystemProxyAsync({
|
||||
env,
|
||||
logger,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@exp
|
||||
import { graphql } from 'gql.tada';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import { withLogPhaseAsync } from '../../utils/logPhase';
|
||||
import { startSandboxDaemonAsync } from '../utils/sandboxDaemon';
|
||||
|
||||
@@ -51,6 +52,8 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
signal,
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
env: commandEnv,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
sandboxId,
|
||||
});
|
||||
try {
|
||||
await daemon.ready;
|
||||
@@ -76,19 +79,7 @@ export async function markSandboxReadyAsync(
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(
|
||||
MARK_SANDBOX_READY_MUTATION,
|
||||
{ sandboxId },
|
||||
signal
|
||||
? {
|
||||
fetch: (input, init) =>
|
||||
fetch(input, {
|
||||
...init,
|
||||
signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal,
|
||||
}),
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
.mutation(MARK_SANDBOX_READY_MUTATION, { sandboxId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
signal?.throwIfAborted();
|
||||
if (result.error) {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import { Sentry } from '../../sentry';
|
||||
import { ServeSimLogsRecorder } from '../utils/serveSimLogsRecorder';
|
||||
|
||||
export function createStartServeSimLogsBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_serve_sim_logs',
|
||||
name: 'Start serve-sim simulator logs',
|
||||
__metricsId: 'eas/start_serve_sim_logs',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
fn: async ({ logger }) => {
|
||||
try {
|
||||
await ServeSimLogsRecorder.startAsync({ logger });
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not start serve-sim simulator logs', error);
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Could not start simulator log collection; the session will continue.'
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -6,15 +6,22 @@ import {
|
||||
} from '@expo/steps';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
} from '../utils/networkCaptureFields';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import {
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
@@ -29,6 +36,8 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
name: 'Start web preview remote session',
|
||||
__metricsId: 'eas/start_serve_sim_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -46,32 +55,63 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
const packageVersion = inputs.package_version?.value as string | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
const launch = parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value as string | undefined,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
networkCaptureFields: inputs.network_capture_fields?.value,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
|
||||
logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`);
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
|
||||
const webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
const sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
packageVersion,
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
});
|
||||
logger.info(`Preview URL: ${webPreview.previewUrl}`);
|
||||
|
||||
try {
|
||||
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
|
||||
logger.info(`Preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`);
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
// WEB_PREVIEW_ONLY sessions read webPreviewUrl and webPreviewToken. Legacy SERVE_SIM
|
||||
// sessions read previewUrl and previewToken. The server drops the keys that the
|
||||
// session type does not use.
|
||||
remoteConfig: {
|
||||
previewUrl: webPreview.previewUrl,
|
||||
...(webPreview.previewToken ? { previewToken: webPreview.previewToken } : {}),
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken
|
||||
? { webPreviewToken: webPreview.previewToken, previewToken: webPreview.previewToken }
|
||||
: {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
@@ -84,7 +124,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
signal,
|
||||
});
|
||||
} finally {
|
||||
await webPreview.stopAsync();
|
||||
await sessionHost.finishAsync();
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import path from 'node:path';
|
||||
import limitFactory from 'promise-limit';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Sentry } from '../../sentry';
|
||||
import { AscApiClient, AscApiRequestError } from '../utils/ios/AscApiClient';
|
||||
import { AscApiUtils } from '../utils/ios/AscApiUtils';
|
||||
|
||||
export function createUpdateTestFlightMetadataBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'update_testflight_metadata',
|
||||
name: 'Update TestFlight metadata',
|
||||
__metricsId: 'eas/update_testflight_metadata',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'asc_api_key_path',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'build_upload_id',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'changelog',
|
||||
required: false,
|
||||
defaultValue: '',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'groups',
|
||||
required: false,
|
||||
defaultValue: [],
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
],
|
||||
fn: async (ctx, { inputs }) => {
|
||||
const parsedInputs = z
|
||||
.object({
|
||||
asc_api_key_path: z.string(),
|
||||
build_upload_id: z.string(),
|
||||
changelog: z.string(),
|
||||
groups: z.array(z.string()),
|
||||
})
|
||||
.parse({
|
||||
asc_api_key_path: inputs.asc_api_key_path.value,
|
||||
build_upload_id: inputs.build_upload_id.value,
|
||||
changelog: inputs.changelog.value,
|
||||
groups: inputs.groups.value,
|
||||
});
|
||||
const keyPath = path.resolve(ctx.workingDirectory, parsedInputs.asc_api_key_path);
|
||||
const key = await AscApiUtils.loadApiKeyAsync({ keyPath });
|
||||
const client = new AscApiClient({ key, logger: ctx.logger });
|
||||
await updateTestFlightMetadataAsync({
|
||||
client,
|
||||
buildUploadId: parsedInputs.build_upload_id,
|
||||
changelog: parsedInputs.changelog,
|
||||
groups: parsedInputs.groups,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
ctx.logger.info('TestFlight metadata updated.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateTestFlightMetadataAsync({
|
||||
client,
|
||||
buildUploadId,
|
||||
changelog,
|
||||
groups,
|
||||
logger,
|
||||
}: {
|
||||
client: AscApiClient;
|
||||
buildUploadId: string;
|
||||
changelog: string;
|
||||
groups: string[];
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const { data: upload } = await client.getAsync(
|
||||
'/v1/buildUploads/:id',
|
||||
{
|
||||
'fields[buildUploads]': ['state', 'build'],
|
||||
include: ['build'],
|
||||
},
|
||||
{ id: buildUploadId }
|
||||
);
|
||||
const buildId = upload.relationships?.build?.data?.id;
|
||||
if (upload.attributes?.state?.state !== 'COMPLETE' || !buildId) {
|
||||
throw new UserError(
|
||||
'EAS_TESTFLIGHT_BUILD_NOT_READY',
|
||||
'The uploaded build is not ready for TestFlight metadata. Run eas/upload_to_asc with wait_for_processing: true first.'
|
||||
);
|
||||
}
|
||||
logger.info(`Updating TestFlight metadata for Apple build ${buildId}...`);
|
||||
const { data: app } = await client.getAsync('/v1/builds/:id/app', {}, { id: buildId });
|
||||
const limit = limitFactory<void>(1);
|
||||
const results = await Promise.allSettled([
|
||||
limit(async () => {
|
||||
if (!changelog) {
|
||||
return;
|
||||
}
|
||||
logger.info(`Updating changelog: ${JSON.stringify(changelog)}`);
|
||||
const primaryLocale = app.attributes?.primaryLocale || 'en-US';
|
||||
if (!app.attributes?.primaryLocale) {
|
||||
logger.warn('App Store Connect did not return the primary locale; using "en-US".');
|
||||
}
|
||||
logger.info(`Primary locale: ${JSON.stringify(primaryLocale)}.`);
|
||||
const localizations = await AscApiUtils.getAllBetaBuildLocalizationsAsync({
|
||||
client,
|
||||
buildId,
|
||||
});
|
||||
const localizationLimit = limitFactory<void>(1);
|
||||
const updates = localizations.map(localization =>
|
||||
localizationLimit(async () => {
|
||||
const label = `${JSON.stringify(localization.attributes?.locale ?? 'unknown')} (${localization.id})`;
|
||||
try {
|
||||
await client.patchAsync(
|
||||
'/v1/betaBuildLocalizations/:id',
|
||||
{
|
||||
data: {
|
||||
type: 'betaBuildLocalizations',
|
||||
id: localization.id,
|
||||
attributes: { whatsNew: changelog },
|
||||
},
|
||||
},
|
||||
{ id: localization.id }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`❌ Locale ${label}: changelog update failed. ${String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
logger.info(`✅ Locale ${label}: changelog updated.`);
|
||||
})
|
||||
);
|
||||
if (!localizations.some(localization => localization.attributes?.locale === primaryLocale)) {
|
||||
updates.push(
|
||||
localizationLimit(async () => {
|
||||
try {
|
||||
await client.postAsync('/v1/betaBuildLocalizations', {
|
||||
data: {
|
||||
type: 'betaBuildLocalizations',
|
||||
attributes: { locale: primaryLocale, whatsNew: changelog },
|
||||
relationships: { build: { data: { type: 'builds', id: buildId } } },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`❌ Locale ${JSON.stringify(primaryLocale)}: localization creation failed. ${String(error)}`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
logger.info(`✅ Locale ${JSON.stringify(primaryLocale)}: localization created.`);
|
||||
})
|
||||
);
|
||||
}
|
||||
const localizationResults = await Promise.allSettled(updates);
|
||||
const localizationFailures = localizationResults.filter(
|
||||
result => result.status === 'rejected'
|
||||
);
|
||||
if (localizationFailures.length === 1) {
|
||||
throw localizationFailures[0].reason;
|
||||
}
|
||||
if (localizationFailures.length > 1) {
|
||||
throw new AggregateError(
|
||||
localizationFailures.map(failure => failure.reason),
|
||||
`Failed to update TestFlight localizations: ${localizationFailures.map(failure => String(failure.reason)).join('; ')}`
|
||||
);
|
||||
}
|
||||
}),
|
||||
limit(async () => {
|
||||
if (!groups.length) {
|
||||
return;
|
||||
}
|
||||
logger.info(`Apple app: ${app.id}. Requested groups: ${JSON.stringify(groups)}.`);
|
||||
const requestedNames = new Set(groups);
|
||||
const allGroups = await AscApiUtils.getAllBetaGroupsAsync({ client, appId: app.id });
|
||||
const requestedGroups = allGroups.filter(group =>
|
||||
requestedNames.has(group.attributes?.name ?? '')
|
||||
);
|
||||
const foundNames = new Set(requestedGroups.map(group => group.attributes?.name));
|
||||
const missingNames = [...requestedNames].filter(name => !foundNames.has(name));
|
||||
logger.info(`Found ${requestedGroups.length} TestFlight group(s).`);
|
||||
const assignedGroups = requestedGroups.length
|
||||
? await AscApiUtils.getAllBetaGroupsAsync({
|
||||
client,
|
||||
buildId,
|
||||
})
|
||||
: [];
|
||||
const assignedIds = new Set(assignedGroups.map(group => group.id));
|
||||
let internalBuildStateVerification: Promise<void> | undefined;
|
||||
const groupLimit = limitFactory<void>(1);
|
||||
const groupResults = await Promise.allSettled(
|
||||
requestedGroups.map(group =>
|
||||
groupLimit(async () => {
|
||||
const label = `${JSON.stringify(group.attributes?.name)} (${group.id})`;
|
||||
if (assignedIds.has(group.id)) {
|
||||
logger.info(`✅ Group ${label}: build already assigned; no assignment needed.`);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
group.attributes?.isInternalGroup === true &&
|
||||
group.attributes.hasAccessToAllBuilds === true
|
||||
) {
|
||||
logger.info(
|
||||
`✅ Group ${label}: automatic access to all builds; no assignment needed.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await client.postAsync(
|
||||
'/v1/builds/:id/relationships/betaGroups',
|
||||
{
|
||||
data: [{ type: 'betaGroups', id: group.id }],
|
||||
},
|
||||
{ id: buildId }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`❌ Group ${label}: assignment failed. ${String(error)}`);
|
||||
|
||||
internalBuildStateVerification ??=
|
||||
verifyInternalBuildStateValidForGroupAssignmentAsync({
|
||||
client,
|
||||
buildId,
|
||||
appId: app.id,
|
||||
logger,
|
||||
error,
|
||||
});
|
||||
await internalBuildStateVerification;
|
||||
|
||||
if (isInternalGroupAssignmentError(error)) {
|
||||
throw new UserError(
|
||||
'EAS_TESTFLIGHT_INTERNAL_GROUP_ASSIGNMENT_FAILED',
|
||||
"App Store Connect can't add this build to a requested internal TestFlight group. " +
|
||||
"Internal groups that automatically receive new builds can't be assigned to manually. " +
|
||||
'Remove the group from the list, or turn off automatic distribution in App Store Connect. ' +
|
||||
`Manage groups at https://appstoreconnect.apple.com/apps/${app.id}/testflight`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
logger.info(`✅ Group ${label}: assignment completed.`);
|
||||
})
|
||||
)
|
||||
);
|
||||
const groupFailures = groupResults.filter(result => result.status === 'rejected');
|
||||
if (missingNames.length) {
|
||||
const error = new UserError(
|
||||
'EAS_TESTFLIGHT_GROUPS_NOT_FOUND',
|
||||
`The following TestFlight group${missingNames.length > 1 ? 's were' : ' was'} not found in App Store Connect: ${missingNames.map(name => `"${name}"`).join(', ')}. Check the group names and try again.`
|
||||
);
|
||||
logger.error(`❌ ${error.message}`);
|
||||
groupFailures.push({ status: 'rejected', reason: error });
|
||||
}
|
||||
if (groupFailures.length === 1) {
|
||||
throw groupFailures[0].reason;
|
||||
}
|
||||
if (groupFailures.length > 1) {
|
||||
throw new AggregateError(
|
||||
groupFailures.map(failure => failure.reason),
|
||||
`Failed to assign TestFlight groups: ${groupFailures.map(failure => String(failure.reason)).join('; ')}`
|
||||
);
|
||||
}
|
||||
}),
|
||||
]);
|
||||
const failures = results.filter(result => result.status === 'rejected');
|
||||
if (failures.length === 1) {
|
||||
throw failures[0].reason;
|
||||
}
|
||||
if (failures.length > 1) {
|
||||
throw new AggregateError(
|
||||
failures.map(failure => failure.reason),
|
||||
`Failed to update the TestFlight changelog and groups: ${failures
|
||||
.map(failure => String(failure.reason))
|
||||
.join('; ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyInternalBuildStateValidForGroupAssignmentAsync({
|
||||
client,
|
||||
appId,
|
||||
buildId,
|
||||
logger,
|
||||
error,
|
||||
}: {
|
||||
client: AscApiClient;
|
||||
appId: string;
|
||||
buildId: string;
|
||||
logger: bunyan;
|
||||
error: unknown;
|
||||
}): Promise<void> {
|
||||
let state = 'UNKNOWN';
|
||||
try {
|
||||
const { data } = await client.getAsync('/v1/builds/:id/buildBetaDetail', {}, { id: buildId });
|
||||
state = data.attributes.internalBuildState;
|
||||
} catch (diagnosticError) {
|
||||
logger.warn(`Could not read the TestFlight state: ${String(diagnosticError)}`);
|
||||
}
|
||||
|
||||
logger.info(`Apple build ${buildId}: internal TestFlight state = ${state}.`);
|
||||
|
||||
Sentry.capture(`TestFlight group assignment failed (state: ${state})`, {
|
||||
level: 'error',
|
||||
tags: { step: 'eas/update_testflight_metadata', internal_build_state: state },
|
||||
extras: { buildId, assignmentError: String(error) },
|
||||
});
|
||||
|
||||
if (state === 'MISSING_EXPORT_COMPLIANCE' || state === 'IN_EXPORT_COMPLIANCE_REVIEW') {
|
||||
throw new UserError(
|
||||
'EAS_TESTFLIGHT_GROUP_ASSIGNMENT_FAILED',
|
||||
`Apple could not add the uploaded build to a TestFlight group (state: ${state}). ` +
|
||||
(state === 'MISSING_EXPORT_COMPLIANCE'
|
||||
? 'Complete the export compliance questions for this build in App Store Connect. For future builds, set ITSAppUsesNonExemptEncryption in the iOS Info.plist to the value that matches your app. '
|
||||
: 'Wait for Apple to approve the export compliance information, then assign the group in App Store Connect. ') +
|
||||
`The binary is already uploaded; do not upload it again to fix group assignment. Manage this build at https://appstoreconnect.apple.com/apps/${appId}/testflight`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Apple returns a generic 422 code, so match the title or detail too.
|
||||
function isInternalGroupAssignmentError(error: unknown): boolean {
|
||||
if (error instanceof AggregateError) {
|
||||
return error.errors.some(isInternalGroupAssignmentError);
|
||||
}
|
||||
return (
|
||||
error instanceof AscApiRequestError &&
|
||||
error.status === 422 &&
|
||||
error.code === 'ENTITY_UNPROCESSABLE' &&
|
||||
(error.responseJson.title === 'Builds cannot be assigned to this internal group.' ||
|
||||
error.responseJson.detail === 'Cannot add internal group to a build.')
|
||||
);
|
||||
}
|
||||
+11
-100
@@ -1,53 +1,17 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import limitFactory from 'promise-limit';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../utils/deviceRunSessionArtifacts';
|
||||
import {
|
||||
parseDeviceScreenRecordings,
|
||||
uploadDeviceRunSessionScreenRecordingsAsync,
|
||||
} from '../utils/deviceRunSessionScreenRecordings';
|
||||
import { getDeviceRunSessionIdOrThrow } from '../utils/remoteDeviceRunSession';
|
||||
|
||||
const RecordingsSchema = z.array(
|
||||
z.object({
|
||||
udid: z.string(),
|
||||
deviceName: z.string(),
|
||||
runtimeDisplayName: z.string(),
|
||||
directory: z.string(),
|
||||
})
|
||||
);
|
||||
|
||||
const RecordingManifestSchema = z.object({
|
||||
firstFrameWallClock: z.object({
|
||||
iso8601: z.string(),
|
||||
}),
|
||||
width: z.number().int().positive(),
|
||||
height: z.number().int().positive(),
|
||||
recording: z.string(),
|
||||
});
|
||||
|
||||
const recordingStartTimeFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 3,
|
||||
hourCycle: 'h23',
|
||||
timeZone: 'UTC',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
|
||||
export function createUploadDeviceRunSessionScreenRecordingsBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
@@ -65,69 +29,16 @@ export function createUploadDeviceRunSessionScreenRecordingsBuildFunction(
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { env, inputs }) => {
|
||||
const result = RecordingsSchema.safeParse(inputs.recordings_json.value ?? []);
|
||||
if (!result.success) {
|
||||
throw new SystemError('Invalid iOS Simulator recordings input.', {
|
||||
cause: result.error,
|
||||
});
|
||||
}
|
||||
const recordings = result.data;
|
||||
const recordings = parseDeviceScreenRecordings(inputs.recordings_json.value ?? []);
|
||||
if (recordings.length === 0) {
|
||||
logger.info('No iOS Simulator recordings found; skipping uploads.');
|
||||
logger.info('No device screen recordings found; skipping uploads.');
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
|
||||
const limit = limitFactory(5);
|
||||
await Promise.all(
|
||||
recordings.map(recording =>
|
||||
limit(async () => {
|
||||
try {
|
||||
const metadata = RecordingManifestSchema.parse(
|
||||
JSON.parse(await readFile(path.join(recording.directory, 'session.json'), 'utf-8'))
|
||||
);
|
||||
const startedAt = recordingStartTimeFormatter.format(
|
||||
new Date(metadata.firstFrameWallClock.iso8601)
|
||||
);
|
||||
const shortUdid = `${recording.udid.slice(0, 8)}-…`;
|
||||
const displayName = `${recording.deviceName} screen recording (${shortUdid}, started at ${startedAt})`;
|
||||
const recordingPath = path.join(recording.directory, metadata.recording);
|
||||
const { size } = await stat(recordingPath);
|
||||
const recordingId = path.basename(recording.directory);
|
||||
logger.info(
|
||||
`Uploading screen recording for ${recording.deviceName} (${formatBytes(size)}).`
|
||||
);
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId: recordingId,
|
||||
name: displayName,
|
||||
filename: `${recordingId}.mp4`,
|
||||
kind: 'screen-recording',
|
||||
metadata: {
|
||||
__eas_type: 'screen-recording',
|
||||
__eas_screen_recording: '1',
|
||||
udid: recording.udid,
|
||||
deviceName: recording.deviceName,
|
||||
runtimeDisplayName: recording.runtimeDisplayName,
|
||||
firstFrameAt: metadata.firstFrameWallClock.iso8601,
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
},
|
||||
size,
|
||||
stream: createReadStream(recordingPath),
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not upload iOS Simulator screen recording', error);
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
`Could not upload screen recording for ${recording.deviceName}.`
|
||||
);
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
await uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
|
||||
recordings,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { BuildJob, UserError } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { readAppConfig } from '../../utils/appConfig';
|
||||
import { uploadEmbeddedBundleAsync } from '../../utils/expoUpdatesEmbedded';
|
||||
|
||||
export function createUploadEmbeddedBundleBuildFunction(
|
||||
ctx: CustomBuildContext<BuildJob>
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'upload_embedded_bundle',
|
||||
name: 'Upload embedded bundle',
|
||||
__metricsId: 'eas/upload_embedded_bundle',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'ignore_error',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { env, inputs }) => {
|
||||
try {
|
||||
const appConfig = (
|
||||
await readAppConfig({
|
||||
projectDir: stepCtx.workingDirectory,
|
||||
env,
|
||||
logger: stepCtx.logger,
|
||||
sdkVersion: stepCtx.global.staticContext.metadata?.sdkVersion,
|
||||
})
|
||||
).exp;
|
||||
const { status } = await uploadEmbeddedBundleAsync({
|
||||
job: ctx.job,
|
||||
env,
|
||||
logger: stepCtx.logger,
|
||||
projectDir: stepCtx.workingDirectory,
|
||||
appConfig,
|
||||
});
|
||||
if (status === 'skipped') {
|
||||
stepCtx.logger.info('Skipping embedded bundle upload.');
|
||||
} else if (status === 'failed') {
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_EMBEDDED_BUNDLE_FAILED',
|
||||
'Failed to upload embedded bundle. See the warning above for details.'
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (inputs.ignore_error.value) {
|
||||
stepCtx.logger.warn({ err }, 'Failed to upload embedded bundle. Ignoring error.');
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import fs from 'fs-extra';
|
||||
import * as jose from 'jose';
|
||||
import fetch from 'node-fetch';
|
||||
import path from 'node:path';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
@@ -87,29 +86,8 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
throw new Error(`ASC API Key file not found: ${ascApiKeyPath}`);
|
||||
}
|
||||
|
||||
const ascApiKeyJson = await fs.readJson(ascApiKeyPath);
|
||||
const ascApiKey = z
|
||||
.object({
|
||||
issuer_id: z.string().nullish(),
|
||||
key_id: z.string(),
|
||||
key: z.string(),
|
||||
})
|
||||
.parse(ascApiKeyJson);
|
||||
|
||||
const privateKey = await jose.importPKCS8(ascApiKey.key, 'ES256');
|
||||
const jwt = new jose.SignJWT({})
|
||||
.setProtectedHeader({ alg: 'ES256', kid: ascApiKey.key_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);
|
||||
|
||||
const client = new AscApiClient({ token, logger: stepsCtx.logger });
|
||||
const key = await AscApiUtils.loadApiKeyAsync({ keyPath: ascApiKeyPath });
|
||||
const client = new AscApiClient({ key, logger: stepsCtx.logger });
|
||||
|
||||
stepsCtx.logger.info(
|
||||
`Reading App information for Apple app identifier: ${appleAppIdentifier}...`
|
||||
@@ -177,21 +155,11 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
});
|
||||
|
||||
stepsCtx.logger.info('Committing upload...');
|
||||
await client.patchAsync(
|
||||
`/v1/buildUploadFiles/:id`,
|
||||
{
|
||||
data: {
|
||||
type: 'buildUploadFiles',
|
||||
id: buildFileResponse.data.id,
|
||||
attributes: {
|
||||
uploaded: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: buildFileResponse.data.id,
|
||||
}
|
||||
);
|
||||
await AscApiUtils.commitBuildUploadFileAsync({
|
||||
client,
|
||||
fileId: buildFileResponse.data.id,
|
||||
logger: stepsCtx.logger,
|
||||
});
|
||||
|
||||
stepsCtx.logger.info('Checking upload file status...');
|
||||
const waitingForFileStartedAt = Date.now();
|
||||
|
||||
@@ -10,12 +10,27 @@ import { setTimeout } from 'node:timers/promises';
|
||||
|
||||
import { Sentry } from '../../sentry';
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageExec,
|
||||
} from '../../utils/packageManager';
|
||||
import { isProcessGroupRunning, killProcessGroup } from '../../utils/processes';
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
|
||||
const IOS_SIMULATOR_RECORDING_POLL_INTERVAL_MS = 2_000;
|
||||
const RECORD_SIM_FINISH_TIMEOUT_MS = 70_000;
|
||||
const RECORD_SIM_FORCE_STOP_TIMEOUT_MS = 5_000;
|
||||
const RECORD_SIM_MAX_ATTEMPTS_PER_BOOT = 3;
|
||||
const RECORD_SIM_COMMAND = 'record-sim';
|
||||
// Once the poller stops, each recording stops in parallel within 60 + 130 + 5 + 30 = 225 s.
|
||||
const SERVE_SIM_START_TIMEOUT_MS = 60_000;
|
||||
/** serve-sim finalizes an active recording within this grace period after SIGTERM. */
|
||||
export const SERVE_SIM_STOP_GRACE_PERIOD_MS = 90_000;
|
||||
const SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS = 30_000;
|
||||
// serve-sim may be stopping at the same time: its grace period, the manifest wait, and 10 s.
|
||||
const SERVE_SIM_FINISH_TIMEOUT_MS =
|
||||
SERVE_SIM_STOP_GRACE_PERIOD_MS + SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS + 10_000;
|
||||
const SERVE_SIM_FORCE_STOP_TIMEOUT_MS = 5_000;
|
||||
const SERVE_SIM_RECORDING_STARTED = 'serve-sim:recording-started';
|
||||
// The serve-sim package the session host started; every recorder runs `record-video` from it.
|
||||
let serveSimPackageSpec: string | null = null;
|
||||
|
||||
type IosSimulatorRecording = {
|
||||
id: string;
|
||||
@@ -25,6 +40,7 @@ type IosSimulatorRecording = {
|
||||
outputDirectory: string;
|
||||
startedAt: Date;
|
||||
getOutput: () => string;
|
||||
hasStarted: () => boolean;
|
||||
};
|
||||
|
||||
type ActiveIosSimulatorRecording = IosSimulatorRecording & {
|
||||
@@ -35,11 +51,15 @@ type ActiveIosSimulatorRecording = IosSimulatorRecording & {
|
||||
type IosSimulatorRecordingSession = {
|
||||
env: Env;
|
||||
logger: bunyan;
|
||||
recordSimCommand: string;
|
||||
recordingsRootDirectory: string;
|
||||
activeRecordings: Map<IosSimulatorUuid, ActiveIosSimulatorRecording>;
|
||||
completedRecordings: IosSimulatorRecording[];
|
||||
recordingFailureCounts: Map<IosSimulatorUuid, number>;
|
||||
/** Failed attempts for the whole session; unlike recordingFailureCounts, never reset. */
|
||||
failedAttemptCounts: Map<IosSimulatorUuid, number>;
|
||||
recordingRetryAt: Map<IosSimulatorUuid, number>;
|
||||
serveBearerTokens: Map<IosSimulatorUuid, string>;
|
||||
completedServeBearerTokens: Map<IosSimulatorUuid, string>;
|
||||
pollingPromise: Promise<void>;
|
||||
abortController: AbortController;
|
||||
};
|
||||
@@ -47,31 +67,31 @@ type IosSimulatorRecordingSession = {
|
||||
let activeIosSimulatorRecordingSession: IosSimulatorRecordingSession | null = null;
|
||||
|
||||
export namespace IosSimulatorRecordingUtils {
|
||||
/** One session host serves every device of a job, so the first package wins. */
|
||||
export function useServeSimPackage(packageSpec: string): void {
|
||||
serveSimPackageSpec ??= packageSpec;
|
||||
}
|
||||
|
||||
export async function startAsync({ env, logger }: { env: Env; logger: bunyan }): Promise<void> {
|
||||
if (activeIosSimulatorRecordingSession) {
|
||||
logger.info('iOS Simulator screen recording polling is already running.');
|
||||
return;
|
||||
}
|
||||
|
||||
const recordSimCommand = await resolveRecordSimCommandAsync({ env });
|
||||
if (!recordSimCommand) {
|
||||
logger.warn(
|
||||
'record-sim binary is not available; iOS Simulator screen recordings are disabled.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const recordingsRootDirectory = await mkdtemp(
|
||||
path.join(os.tmpdir(), 'ios-simulator-recordings-')
|
||||
);
|
||||
const session: IosSimulatorRecordingSession = {
|
||||
env,
|
||||
logger,
|
||||
recordSimCommand,
|
||||
recordingsRootDirectory,
|
||||
activeRecordings: new Map(),
|
||||
completedRecordings: [],
|
||||
recordingFailureCounts: new Map(),
|
||||
failedAttemptCounts: new Map(),
|
||||
recordingRetryAt: new Map(),
|
||||
serveBearerTokens: new Map(),
|
||||
completedServeBearerTokens: new Map(),
|
||||
pollingPromise: Promise.resolve(),
|
||||
abortController: new AbortController(),
|
||||
};
|
||||
@@ -94,6 +114,7 @@ export namespace IosSimulatorRecordingUtils {
|
||||
}[]
|
||||
> {
|
||||
const session = activeIosSimulatorRecordingSession;
|
||||
serveSimPackageSpec = null;
|
||||
if (!session) {
|
||||
logger.info('No iOS Simulator screen recordings are running.');
|
||||
return [];
|
||||
@@ -105,41 +126,55 @@ export namespace IosSimulatorRecordingUtils {
|
||||
await Promise.all(
|
||||
[...session.activeRecordings.values()].map(async recording => {
|
||||
logger.info(`Stopping screen recording for ${recording.deviceName}.`);
|
||||
recording.recordingProcess.kill('SIGINT');
|
||||
const startState = await waitForRecorderStartAsync(recording, SERVE_SIM_START_TIMEOUT_MS);
|
||||
if (startState === 'exited') {
|
||||
return;
|
||||
}
|
||||
if (startState === 'timeout') {
|
||||
logger.warn(
|
||||
{ recorderOutput: recording.getOutput().trim() },
|
||||
`Screen recording for ${recording.deviceName} did not report its start within ${Math.round(
|
||||
SERVE_SIM_START_TIMEOUT_MS / 1_000
|
||||
)} seconds and will be stopped.`
|
||||
);
|
||||
}
|
||||
const finishTimeoutMs =
|
||||
startState === 'started' ? SERVE_SIM_FINISH_TIMEOUT_MS : SERVE_SIM_FORCE_STOP_TIMEOUT_MS;
|
||||
signalRecorder(recording.recordingProcess, 'SIGINT');
|
||||
const finished = await Promise.race([
|
||||
recording.completionPromise.then(() => true),
|
||||
setTimeout(RECORD_SIM_FINISH_TIMEOUT_MS, false, { ref: false }),
|
||||
setTimeout(finishTimeoutMs, false, { ref: false }),
|
||||
]);
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recordSimOutput = recording.getOutput().trim();
|
||||
const finishTimeoutSeconds = Math.round(RECORD_SIM_FINISH_TIMEOUT_MS / 1_000);
|
||||
const recorderOutput = recording.getOutput().trim();
|
||||
const finishTimeoutSeconds = Math.round(finishTimeoutMs / 1_000);
|
||||
const stopFailure =
|
||||
startState === 'started'
|
||||
? `did not finish within ${finishTimeoutSeconds} seconds`
|
||||
: `did not stop within ${finishTimeoutSeconds} seconds of SIGINT`;
|
||||
logger.warn(
|
||||
{ recordSimOutput },
|
||||
`Screen recording for ${recording.deviceName} did not finish within ${finishTimeoutSeconds} seconds and will be stopped.${
|
||||
recordSimOutput
|
||||
? `\nRecent recorder messages:\n${recordSimOutput}`
|
||||
{ recorderOutput },
|
||||
`Screen recording for ${recording.deviceName} ${stopFailure} and will be killed.${
|
||||
recorderOutput
|
||||
? `\nRecent recorder messages:\n${recorderOutput}`
|
||||
: '\nNo recorder messages were captured.'
|
||||
}`
|
||||
);
|
||||
recording.recordingProcess.kill('SIGKILL');
|
||||
signalRecorder(recording.recordingProcess, 'SIGKILL');
|
||||
const killed = await Promise.race([
|
||||
recording.completionPromise.then(() => true),
|
||||
setTimeout(RECORD_SIM_FORCE_STOP_TIMEOUT_MS, false, { ref: false }),
|
||||
setTimeout(SERVE_SIM_FORCE_STOP_TIMEOUT_MS, false, { ref: false }),
|
||||
]);
|
||||
if (!killed) {
|
||||
logger.warn(
|
||||
`iOS Simulator recording process for ${recording.deviceName} did not exit after SIGKILL.`
|
||||
);
|
||||
// Recorder output stays in the job log: it can contain the serve-sim bearer token.
|
||||
Sentry.capture(
|
||||
`iOS Simulator recording process for ${recording.deviceName} did not exit after SIGKILL.`,
|
||||
{
|
||||
extras: {
|
||||
output: recording.getOutput(),
|
||||
},
|
||||
}
|
||||
`iOS Simulator recording process for ${recording.deviceName} did not exit after SIGKILL.`
|
||||
);
|
||||
}
|
||||
})
|
||||
@@ -148,7 +183,31 @@ export namespace IosSimulatorRecordingUtils {
|
||||
const completedRecordings = [...session.completedRecordings].sort(
|
||||
(a, b) => a.startedAt.getTime() - b.startedAt.getTime()
|
||||
);
|
||||
return completedRecordings.map(recording => ({
|
||||
const recordingsWithManifests = await Promise.all(
|
||||
completedRecordings.map(async recording => {
|
||||
if (
|
||||
await waitForRecordingManifestAsync(
|
||||
recording.outputDirectory,
|
||||
SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS
|
||||
)
|
||||
) {
|
||||
return recording;
|
||||
}
|
||||
reportLostRecording(logger, recording.deviceName, recording.getOutput().trim());
|
||||
return null;
|
||||
})
|
||||
);
|
||||
const finalizedRecordings = recordingsWithManifests.filter(recording => recording !== null);
|
||||
const recordedUdids = new Set(finalizedRecordings.map(recording => recording.udid));
|
||||
for (const [failedUdid, attempts] of session.failedAttemptCounts) {
|
||||
if (!recordedUdids.has(failedUdid)) {
|
||||
Sentry.capture('iOS Simulator screen recording failed on every attempt', {
|
||||
level: 'warning',
|
||||
extras: { attempts },
|
||||
});
|
||||
}
|
||||
}
|
||||
return finalizedRecordings.map(recording => ({
|
||||
udid: recording.udid,
|
||||
deviceName: recording.deviceName,
|
||||
runtimeDisplayName: recording.runtimeDisplayName,
|
||||
@@ -174,18 +233,38 @@ async function pollIosSimulatorRecordingsAsync(
|
||||
break;
|
||||
}
|
||||
listDevicesErrorCount = 0;
|
||||
const readyServers = new Map(
|
||||
(await readServeSimServersAsync(SERVE_SIM_STATE_DIR)).flatMap(server =>
|
||||
server.token ? [[server.udid, server.token] as const] : []
|
||||
)
|
||||
);
|
||||
|
||||
const bootedUdids = new Set(bootedDevices.map(device => device.udid));
|
||||
for (const udid of session.recordingFailureCounts.keys()) {
|
||||
for (const udid of session.serveBearerTokens.keys()) {
|
||||
if (!bootedUdids.has(udid)) {
|
||||
session.recordingFailureCounts.delete(udid);
|
||||
session.recordingRetryAt.delete(udid);
|
||||
session.serveBearerTokens.delete(udid);
|
||||
session.completedServeBearerTokens.delete(udid);
|
||||
}
|
||||
}
|
||||
|
||||
for (const device of bootedDevices) {
|
||||
const token = readyServers.get(device.udid);
|
||||
const packageSpec = serveSimPackageSpec;
|
||||
if (!token || !packageSpec) {
|
||||
continue;
|
||||
}
|
||||
if (session.serveBearerTokens.get(device.udid) !== token) {
|
||||
session.serveBearerTokens.set(device.udid, token);
|
||||
session.recordingFailureCounts.delete(device.udid);
|
||||
session.recordingRetryAt.delete(device.udid);
|
||||
session.completedServeBearerTokens.delete(device.udid);
|
||||
}
|
||||
if (
|
||||
session.completedServeBearerTokens.get(device.udid) === token ||
|
||||
session.activeRecordings.has(device.udid) ||
|
||||
(session.recordingFailureCounts.get(device.udid) ?? 0) >= RECORD_SIM_MAX_ATTEMPTS_PER_BOOT
|
||||
Date.now() < (session.recordingRetryAt.get(device.udid) ?? 0)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -193,6 +272,7 @@ async function pollIosSimulatorRecordingsAsync(
|
||||
udid: device.udid,
|
||||
deviceName: device.name,
|
||||
runtimeDisplayName: device.runtimeDisplayName,
|
||||
packageSpec,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -227,50 +307,90 @@ async function startIosSimulatorRecordingAsync(
|
||||
udid,
|
||||
deviceName,
|
||||
runtimeDisplayName,
|
||||
packageSpec,
|
||||
}: {
|
||||
udid: IosSimulatorUuid;
|
||||
deviceName: string;
|
||||
runtimeDisplayName: string;
|
||||
packageSpec: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const startedAt = new Date();
|
||||
const serveBearerToken = session.serveBearerTokens.get(udid);
|
||||
const recordingId = randomUUID();
|
||||
const outputDirectory = path.join(session.recordingsRootDirectory, recordingId);
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
|
||||
session.logger.info(`Starting screen recording for ${deviceName}.`);
|
||||
const recorderExec = resolvePackageExec(
|
||||
resolveConfiguredPackageManager(session.env, PackageManager.NPM),
|
||||
[packageSpec, 'record-video']
|
||||
);
|
||||
const recordingSpawn = spawn(
|
||||
session.recordSimCommand,
|
||||
['--udid', udid, '--output', outputDirectory, '--segment-duration', '0'],
|
||||
recorderExec.command,
|
||||
[...recorderExec.args, '--udid', udid, '--output', outputDirectory],
|
||||
{
|
||||
env: session.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
}
|
||||
);
|
||||
const getOutput = captureProcessOutput(recordingSpawn.child);
|
||||
const completionPromise = recordingSpawn
|
||||
.then(() => undefined)
|
||||
.catch((err: unknown) => {
|
||||
session.recordingFailureCounts.set(udid, (session.recordingFailureCounts.get(udid) ?? 0) + 1);
|
||||
const { getOutput, hasStarted } = captureProcessOutput(recordingSpawn.child);
|
||||
const saveFinalizedRecordingAsync = async (): Promise<boolean> => {
|
||||
if (
|
||||
!(await waitForRecordingManifestAsync(outputDirectory, SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (serveBearerToken) {
|
||||
session.completedServeBearerTokens.set(udid, serveBearerToken);
|
||||
}
|
||||
session.completedRecordings.push({
|
||||
id: recordingId,
|
||||
udid,
|
||||
deviceName,
|
||||
runtimeDisplayName,
|
||||
outputDirectory,
|
||||
startedAt,
|
||||
getOutput,
|
||||
hasStarted,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
const completionPromise = (async () => {
|
||||
let spawnError: unknown;
|
||||
try {
|
||||
await recordingSpawn;
|
||||
} catch (err) {
|
||||
spawnError = err;
|
||||
}
|
||||
// A package-manager wrapper can exit while its record-video child still
|
||||
// runs in the detached group. Keep this device active until the group exits.
|
||||
await waitForRecordingProcessGroupExitAsync(recordingSpawn.child);
|
||||
// Host shutdown can end the recorder's lease after its video has been saved.
|
||||
if ((hasStarted() || !spawnError) && (await saveFinalizedRecordingAsync())) {
|
||||
return;
|
||||
}
|
||||
if (spawnError) {
|
||||
const err = spawnError;
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('iOS Simulator screen recording process failed', error);
|
||||
session.logger.warn(
|
||||
{ err: error, recordSimOutput: getOutput() },
|
||||
{ err: error, recorderOutput: getOutput() },
|
||||
`Screen recording process failed for ${deviceName}.`
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
session.activeRecordings.delete(udid);
|
||||
session.completedRecordings.push({
|
||||
id: recordingId,
|
||||
udid,
|
||||
deviceName,
|
||||
runtimeDisplayName,
|
||||
outputDirectory,
|
||||
startedAt,
|
||||
getOutput,
|
||||
});
|
||||
});
|
||||
}
|
||||
if (hasStarted()) {
|
||||
reportLostRecording(session.logger, deviceName, getOutput().trim());
|
||||
} else if (!spawnError) {
|
||||
session.logger.warn(
|
||||
{ recorderOutput: getOutput().trim() },
|
||||
`Screen recording for ${deviceName} exited before it started; it will be retried.`
|
||||
);
|
||||
}
|
||||
scheduleRecordingRetry(session, udid);
|
||||
})().finally(() => {
|
||||
session.activeRecordings.delete(udid);
|
||||
});
|
||||
|
||||
session.activeRecordings.set(udid, {
|
||||
id: recordingId,
|
||||
@@ -282,31 +402,103 @@ async function startIosSimulatorRecordingAsync(
|
||||
completionPromise,
|
||||
startedAt,
|
||||
getOutput,
|
||||
hasStarted,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveRecordSimCommandAsync({ env }: { env: Env }): Promise<string | null> {
|
||||
try {
|
||||
await spawn('which', [RECORD_SIM_COMMAND], { env });
|
||||
return RECORD_SIM_COMMAND;
|
||||
} catch {}
|
||||
async function waitForRecordingManifestAsync(
|
||||
outputDirectory: string,
|
||||
timeoutMs: number
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await access(path.join(outputDirectory, 'session.json'));
|
||||
return true;
|
||||
} catch {
|
||||
await setTimeout(1_000);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const packagedRecordSimPath = path.join(__dirname, '..', '..', '..', 'bin', RECORD_SIM_COMMAND);
|
||||
try {
|
||||
await access(packagedRecordSimPath);
|
||||
return packagedRecordSimPath;
|
||||
} catch {
|
||||
return null;
|
||||
async function waitForRecorderStartAsync(
|
||||
recording: ActiveIosSimulatorRecording,
|
||||
timeoutMs: number
|
||||
): Promise<'started' | 'exited' | 'timeout'> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (recording.hasStarted()) {
|
||||
return 'started';
|
||||
}
|
||||
const exited = await Promise.race([
|
||||
recording.completionPromise.then(() => true),
|
||||
setTimeout(100, false),
|
||||
]);
|
||||
if (exited) {
|
||||
return 'exited';
|
||||
}
|
||||
}
|
||||
return 'timeout';
|
||||
}
|
||||
|
||||
function reportLostRecording(logger: bunyan, deviceName: string, recorderOutput: string): void {
|
||||
logger.warn(
|
||||
{ recorderOutput },
|
||||
`Screen recording for ${deviceName} ended without a manifest; its footage cannot be uploaded.`
|
||||
);
|
||||
Sentry.capture('iOS Simulator screen recording ended without a manifest', {
|
||||
level: 'warning',
|
||||
extras: { deviceName },
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleRecordingRetry(
|
||||
session: IosSimulatorRecordingSession,
|
||||
udid: IosSimulatorUuid
|
||||
): void {
|
||||
session.failedAttemptCounts.set(udid, (session.failedAttemptCounts.get(udid) ?? 0) + 1);
|
||||
const failures = (session.recordingFailureCounts.get(udid) ?? 0) + 1;
|
||||
session.recordingFailureCounts.set(udid, failures);
|
||||
session.recordingRetryAt.set(
|
||||
udid,
|
||||
Date.now() + Math.min(300_000, 25_000 * 2 ** Math.min(failures - 1, 4))
|
||||
);
|
||||
}
|
||||
|
||||
function signalRecorder(child: ChildProcess, signal: NodeJS.Signals): void {
|
||||
// killProcessGroup skips a child without a pid.
|
||||
if (child.pid === undefined) {
|
||||
child.kill(signal);
|
||||
return;
|
||||
}
|
||||
killProcessGroup(child, signal);
|
||||
}
|
||||
|
||||
async function waitForRecordingProcessGroupExitAsync(child: ChildProcess): Promise<void> {
|
||||
if (child.pid === undefined) {
|
||||
return;
|
||||
}
|
||||
// Bounded by finishAsync's SIGKILL; unref'd so it cannot keep the worker alive.
|
||||
while (isProcessGroupRunning(child.pid)) {
|
||||
await setTimeout(100, undefined, { ref: false });
|
||||
}
|
||||
}
|
||||
|
||||
function captureProcessOutput(recordingProcess: ChildProcess): () => string {
|
||||
function captureProcessOutput(recordingProcess: ChildProcess): {
|
||||
getOutput: () => string;
|
||||
hasStarted: () => boolean;
|
||||
} {
|
||||
let output = '';
|
||||
let started = false;
|
||||
const appendChunk = (chunk: Buffer | string): void => {
|
||||
// Keep enough recorder stderr/stdout for diagnostics without retaining unbounded output.
|
||||
output = `${output}${chunk.toString()}`.slice(-16_384);
|
||||
output = `${output}${chunk.toString()}`;
|
||||
if (output.includes(SERVE_SIM_RECORDING_STARTED)) {
|
||||
started = true;
|
||||
}
|
||||
output = output.slice(-16_384);
|
||||
};
|
||||
recordingProcess.stdout?.on('data', appendChunk);
|
||||
recordingProcess.stderr?.on('data', appendChunk);
|
||||
return () => output;
|
||||
return { getOutput: () => output, hasStarted: () => started };
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
import { type Env } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { type ChildProcess } from 'node:child_process';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { rm, writeFile } from 'node:fs/promises';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils';
|
||||
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../serveSimMetricsRecorder');
|
||||
jest.mock('../../../sentry');
|
||||
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
} as unknown as bunyan;
|
||||
const env = {} as Env;
|
||||
const udid = '06B546AC-7B06-4BED-83DB-83603E8EB537';
|
||||
|
||||
beforeEach(() => {
|
||||
IosSimulatorRecordingUtils.useServeSimPackage('@expo/serve-sim@next');
|
||||
jest
|
||||
.spyOn(IosSimulatorUtils, 'getAvailableDevicesAsync')
|
||||
.mockResolvedValue([{ udid, name: 'iPhone 17 Pro', runtimeDisplayName: 'iOS 26.4' } as never]);
|
||||
jest.mocked(readServeSimServersAsync).mockReset();
|
||||
jest.mocked(spawn).mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function waitForRecorderSpawnAsync(): Promise<void> {
|
||||
for (let attempt = 0; attempt < 50; attempt++) {
|
||||
if (jest.mocked(spawn).mock.calls.length > 0) {
|
||||
return;
|
||||
}
|
||||
await setTimeout(10);
|
||||
}
|
||||
throw new Error('serve-sim recorder did not start');
|
||||
}
|
||||
|
||||
// A recorder the test controls: `exit` ends it cleanly, `fail` ends it with an error.
|
||||
function mockRecorder({ kill }: { kill?: (signal: NodeJS.Signals) => void } = {}): {
|
||||
child: ChildProcess;
|
||||
exit: () => void;
|
||||
fail: (error: Error) => void;
|
||||
} {
|
||||
let exit: () => void = () => {};
|
||||
let fail: (error: Error) => void = () => {};
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: jest.fn((signal: NodeJS.Signals) => {
|
||||
kill?.(signal);
|
||||
return true;
|
||||
}),
|
||||
}) as unknown as ChildProcess;
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
exit = resolve;
|
||||
fail = reject;
|
||||
});
|
||||
jest.mocked(spawn).mockReturnValue(Object.assign(done, { child }) as never);
|
||||
return { child, exit: () => exit(), fail: error => fail(error) };
|
||||
}
|
||||
|
||||
function mockFailingRecorder(): void {
|
||||
jest.mocked(spawn).mockImplementation((_command, args) => {
|
||||
void writeFile(`${args[6]}/session.json`, JSON.stringify({ recording: 'recording.mp4' }));
|
||||
return Object.assign(Promise.reject(new Error('recorder exited')), {
|
||||
child: {
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: jest.fn(),
|
||||
},
|
||||
}) as never;
|
||||
});
|
||||
}
|
||||
|
||||
test('waits for a token-gated serve-sim session before starting a recorder', async () => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563' }]);
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
|
||||
expect(jest.mocked(spawn)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('starts serve-sim record-video and returns only a finalized recording', async () => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
let resolveProcess: () => void = () => {};
|
||||
const stderr = new PassThrough();
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdout: new PassThrough(),
|
||||
stderr,
|
||||
kill: jest.fn(),
|
||||
}) as unknown as ChildProcess;
|
||||
const processPromise = Object.assign(
|
||||
new Promise<void>(resolve => {
|
||||
resolveProcess = resolve;
|
||||
}),
|
||||
{
|
||||
child,
|
||||
}
|
||||
);
|
||||
jest.mocked(spawn).mockReturnValue(processPromise as never);
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
const [command, args, options] = jest.mocked(spawn).mock.calls[0];
|
||||
expect(command).toBe('npx');
|
||||
expect(args.slice(0, 5)).toEqual([
|
||||
'--yes',
|
||||
'@expo/serve-sim@next',
|
||||
'record-video',
|
||||
'--udid',
|
||||
udid,
|
||||
]);
|
||||
expect(args[5]).toBe('--output');
|
||||
expect(args).not.toContain('--segment-duration');
|
||||
expect(options?.detached).toBe(true);
|
||||
|
||||
const outputDirectory = args[6];
|
||||
jest.mocked(child.kill).mockImplementation(() => {
|
||||
resolveProcess();
|
||||
void setTimeout(20).then(() =>
|
||||
writeFile(`${outputDirectory}/session.json`, JSON.stringify({ recording: 'recording.mp4' }))
|
||||
);
|
||||
return true;
|
||||
});
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await setTimeout(20);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
stderr.write('serve-sim:recording-started\n');
|
||||
stderr.write('x'.repeat(17_000));
|
||||
const recordings = await finishing;
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGINT');
|
||||
expect(recordings).toEqual([
|
||||
{
|
||||
udid,
|
||||
deviceName: 'iPhone 17 Pro',
|
||||
runtimeDisplayName: 'iOS 26.4',
|
||||
directory: outputDirectory,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('reports a started recording that ends without a manifest to Sentry', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
let exitRecorder: () => void = () => {};
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: jest.fn(),
|
||||
}) as unknown as ChildProcess;
|
||||
jest.mocked(spawn).mockReturnValue(
|
||||
Object.assign(
|
||||
new Promise<void>(resolve => {
|
||||
exitRecorder = resolve;
|
||||
}),
|
||||
{ child }
|
||||
) as never
|
||||
);
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
child.stderr?.emit('data', 'serve-sim:recording-started\n');
|
||||
child.stderr?.emit('data', 'GET http://127.0.0.1:43563/record?token=session-token failed\n');
|
||||
exitRecorder();
|
||||
await setTimeout(50);
|
||||
currentTime += 31_000;
|
||||
await setTimeout(1_100);
|
||||
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'iOS Simulator screen recording ended without a manifest',
|
||||
{ level: 'warning', extras: { deviceName: 'iPhone 17 Pro' } }
|
||||
);
|
||||
expect(JSON.stringify(jest.mocked(Sentry.capture).mock.calls)).not.toContain('session-token');
|
||||
});
|
||||
|
||||
test('says when a recorder never reported its start before it is stopped', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
let exitRecorder: () => void = () => {};
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: jest.fn(() => {
|
||||
exitRecorder();
|
||||
return true;
|
||||
}),
|
||||
}) as unknown as ChildProcess;
|
||||
jest.mocked(spawn).mockReturnValue(
|
||||
Object.assign(
|
||||
new Promise<void>(resolve => {
|
||||
exitRecorder = resolve;
|
||||
}),
|
||||
{ child }
|
||||
) as never
|
||||
);
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await setTimeout(50);
|
||||
currentTime += 61_000;
|
||||
await setTimeout(200);
|
||||
currentTime += 31_000;
|
||||
|
||||
expect(await finishing).toEqual([]);
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGINT');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'Screen recording for iPhone 17 Pro did not report its start within 60 seconds and will be stopped.'
|
||||
);
|
||||
});
|
||||
|
||||
test('retries after a failed recorder when the serve-sim session token changes', async () => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValueOnce([{ udid, url: 'http://127.0.0.1:43563', token: 'old-token' }])
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'new-token' }]);
|
||||
mockFailingRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
await setTimeout(2_100);
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(2);
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toEqual([]);
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'iOS Simulator screen recording failed on every attempt',
|
||||
expect.objectContaining({ level: 'warning', extras: { attempts: 2 } })
|
||||
);
|
||||
});
|
||||
|
||||
test('reports a device that failed and then shut down', async () => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
mockFailingRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockResolvedValue([]);
|
||||
await setTimeout(2_100);
|
||||
|
||||
expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toEqual([]);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'iOS Simulator screen recording failed on every attempt',
|
||||
expect.objectContaining({ extras: { attempts: 1 } })
|
||||
);
|
||||
});
|
||||
|
||||
test('retries after the server lease expires without a token change', async () => {
|
||||
const startTime = Date.now();
|
||||
let currentTime = startTime;
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
mockFailingRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
currentTime += 26_000;
|
||||
await setTimeout(2_100);
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('does not start a second recording for a server token after a late manifest', async () => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
jest.mocked(spawn).mockImplementation((_command, args) => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: jest.fn(),
|
||||
}) as unknown as ChildProcess;
|
||||
void setTimeout(20).then(() =>
|
||||
writeFile(`${args[6]}/session.json`, JSON.stringify({ recording: 'recording.mp4' }))
|
||||
);
|
||||
return Object.assign(Promise.resolve(), { child }) as never;
|
||||
});
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
await setTimeout(2_100);
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('keeps a recorder active after its package-manager wrapper exits', async () => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
let groupRunning = true;
|
||||
const originalKill = process.kill.bind(process);
|
||||
const signalGroup = jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (pid !== -4321) {
|
||||
return originalKill(pid, signal);
|
||||
}
|
||||
if (signal === 0) {
|
||||
if (groupRunning) {
|
||||
return true;
|
||||
}
|
||||
throw Object.assign(new Error('No such process group'), { code: 'ESRCH' });
|
||||
}
|
||||
if (signal === 'SIGINT') {
|
||||
void setTimeout(20).then(async () => {
|
||||
const args = jest.mocked(spawn).mock.calls[0][1];
|
||||
await writeFile(`${args[6]}/session.json`, JSON.stringify({ recording: 'recording.mp4' }));
|
||||
groupRunning = false;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
throw new Error(`Unexpected signal: ${signal}`);
|
||||
});
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
pid: 4321,
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: jest.fn(),
|
||||
}) as unknown as ChildProcess;
|
||||
jest.mocked(spawn).mockReturnValue(Object.assign(Promise.resolve(), { child }) as never);
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
child.stderr?.emit('data', 'serve-sim:recording-started\n');
|
||||
await setTimeout(2_100);
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
|
||||
const recordings = await IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
expect(signalGroup).toHaveBeenCalledWith(-4321, 'SIGINT');
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
expect(recordings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('does not signal a recorder that exits before it reports its start', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
const recorder = mockRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await setTimeout(50);
|
||||
recorder.exit();
|
||||
await setTimeout(50);
|
||||
// Past the manifest wait, so the recorder's completion settles.
|
||||
currentTime += 31_000;
|
||||
|
||||
expect(await finishing).toEqual([]);
|
||||
expect(recorder.child.kill).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'Screen recording for iPhone 17 Pro exited before it started; it will be retried.'
|
||||
);
|
||||
});
|
||||
|
||||
test('kills a recorder that ignores SIGINT and reports one that survives SIGKILL', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
const recorder = mockRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
recorder.child.stderr?.emit('data', 'GET /record?token=session-token\n');
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await setTimeout(50);
|
||||
currentTime += 61_000;
|
||||
|
||||
expect(await finishing).toEqual([]);
|
||||
expect(recorder.child.kill).toHaveBeenCalledWith('SIGINT');
|
||||
expect(recorder.child.kill).toHaveBeenCalledWith('SIGKILL');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.stringContaining(
|
||||
'Screen recording for iPhone 17 Pro did not stop within 5 seconds of SIGINT and will be killed.'
|
||||
)
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'iOS Simulator recording process for iPhone 17 Pro did not exit after SIGKILL.'
|
||||
);
|
||||
expect(JSON.stringify(jest.mocked(Sentry.capture).mock.calls)).not.toContain('session-token');
|
||||
}, 20_000);
|
||||
|
||||
test('reports a finalized recording whose manifest is gone at finish', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
const recorder = mockRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
const manifest = `${jest.mocked(spawn).mock.calls[0][1][6]}/session.json`;
|
||||
recorder.child.stderr?.emit('data', 'serve-sim:recording-started\n');
|
||||
await writeFile(manifest, JSON.stringify({ recording: 'recording.mp4' }));
|
||||
recorder.exit();
|
||||
await setTimeout(100);
|
||||
await rm(manifest);
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await setTimeout(50);
|
||||
currentTime += 31_000;
|
||||
|
||||
expect(await finishing).toEqual([]);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'iOS Simulator screen recording ended without a manifest',
|
||||
{ level: 'warning', extras: { deviceName: 'iPhone 17 Pro' } }
|
||||
);
|
||||
});
|
||||
|
||||
test.each([0, 20])(
|
||||
'keeps a started recording that exits with an error when its manifest appears after %i ms',
|
||||
async manifestDelayMs => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
const recorder = mockRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
const outputDirectory = jest.mocked(spawn).mock.calls[0][1][6];
|
||||
recorder.child.stderr?.emit('data', 'serve-sim:recording-started\n');
|
||||
const manifestWritten = setTimeout(manifestDelayMs).then(() =>
|
||||
writeFile(`${outputDirectory}/session.json`, JSON.stringify({ recording: 'recording.mp4' }))
|
||||
);
|
||||
if (manifestDelayMs === 0) {
|
||||
await manifestWritten;
|
||||
}
|
||||
recorder.fail(new Error('record-video exited with code 1'));
|
||||
|
||||
const recordings = await IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await manifestWritten;
|
||||
|
||||
expect(recordings).toEqual([
|
||||
{
|
||||
udid,
|
||||
deviceName: 'iPhone 17 Pro',
|
||||
runtimeDisplayName: 'iOS 26.4',
|
||||
directory: outputDirectory,
|
||||
},
|
||||
]);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
test('reports a started recorder that fails without a manifest', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
const recorder = mockRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
recorder.child.stderr?.emit('data', 'serve-sim:recording-started\n');
|
||||
recorder.fail(new Error('record-video exited with code 1'));
|
||||
await setTimeout(50);
|
||||
currentTime += 31_000;
|
||||
await setTimeout(1_100);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'Screen recording process failed for iPhone 17 Pro.'
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'iOS Simulator screen recording ended without a manifest',
|
||||
expect.objectContaining({ level: 'warning' })
|
||||
);
|
||||
});
|
||||
|
||||
test('records every booted device with the first serve-sim package', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
const secondUdid = '1C17CD51-5926-4838-951B-6D941C7BEDFF';
|
||||
IosSimulatorRecordingUtils.useServeSimPackage('@expo/serve-sim@later');
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([
|
||||
{ udid, name: 'iPhone 17 Pro', runtimeDisplayName: 'iOS 26.4' } as never,
|
||||
{ udid: secondUdid, name: 'iPhone 17', runtimeDisplayName: 'iOS 26.4' } as never,
|
||||
]);
|
||||
jest.mocked(readServeSimServersAsync).mockResolvedValue([
|
||||
{ udid, url: 'http://127.0.0.1:43563', token: 'first-token' },
|
||||
{ udid: secondUdid, url: 'http://127.0.0.1:43563', token: 'second-token' },
|
||||
]);
|
||||
const recorder = mockRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
for (let attempt = 0; attempt < 50 && jest.mocked(spawn).mock.calls.length < 2; attempt++) {
|
||||
await setTimeout(10);
|
||||
}
|
||||
|
||||
const calls = jest.mocked(spawn).mock.calls.map(([, args]) => args);
|
||||
expect(calls.map(args => args[4]).sort()).toEqual([udid, secondUdid].sort());
|
||||
expect(calls.every(args => args[1] === '@expo/serve-sim@next')).toBe(true);
|
||||
|
||||
recorder.exit();
|
||||
await setTimeout(50);
|
||||
currentTime += 31_000;
|
||||
await IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
});
|
||||
@@ -104,6 +104,39 @@ describe(listAgentDeviceArtifactsAsync, () => {
|
||||
})
|
||||
).rejects.toThrow('agent-device daemon does not expose artifact inventory.');
|
||||
});
|
||||
|
||||
it('keeps a screenshot display rotation and drops an unknown one', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
artifacts: [
|
||||
{
|
||||
id: 'rotated',
|
||||
artifactType: 'screenshot',
|
||||
filename: 'rotated.png',
|
||||
displayRotation: 'landscape-left',
|
||||
},
|
||||
{
|
||||
id: 'future',
|
||||
artifactType: 'screenshot',
|
||||
filename: 'future.png',
|
||||
displayRotation: 'sideways',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const artifacts = await listAgentDeviceArtifactsAsync({
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'daemon-token',
|
||||
});
|
||||
|
||||
expect(artifacts.map(artifact => artifact.displayRotation)).toEqual([
|
||||
'landscape-left',
|
||||
undefined,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
@@ -120,8 +153,8 @@ describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
await uploadAgentDeviceArtifactAsync(ctx, {
|
||||
@@ -146,7 +179,7 @@ describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
filename: 'report.json',
|
||||
kind: 'agent-device-test-report',
|
||||
size: data.length,
|
||||
stream: expect.anything(),
|
||||
openStream: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,8 +191,8 @@ describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
await uploadAgentDeviceArtifactAsync(ctx, {
|
||||
@@ -181,6 +214,63 @@ describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads a screenshot display rotation as artifact metadata', async () => {
|
||||
const data = Buffer.from('png-data');
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
await uploadAgentDeviceArtifactAsync(ctx, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'daemon-token',
|
||||
logger: createLoggerMock(),
|
||||
artifact: {
|
||||
id: 'artifact-id',
|
||||
artifactType: 'screenshot',
|
||||
filename: 'shot.png',
|
||||
displayRotation: 'landscape-right',
|
||||
},
|
||||
});
|
||||
|
||||
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
kind: 'screenshot',
|
||||
metadata: { displayRotation: 'landscape-right' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads no metadata when the artifact has no display rotation', async () => {
|
||||
const data = Buffer.from('artifact-data');
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
await uploadAgentDeviceArtifactAsync(ctx, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'daemon-token',
|
||||
logger: createLoggerMock(),
|
||||
artifact: { id: 'artifact-id', artifactType: 'screenshot', filename: 'shot.png' },
|
||||
});
|
||||
|
||||
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls[0][1]).not.toHaveProperty(
|
||||
'metadata'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(pollAgentDeviceArtifactsForUploadAsync, () => {
|
||||
@@ -255,8 +345,8 @@ describe(pollAgentDeviceArtifactsForUploadAsync, () => {
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockRejectedValueOnce(new Error('upload failed'))
|
||||
.mockImplementationOnce(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import turtleSpawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { EventEmitter, once } from 'node:events';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { killProcessGroup } from '../../../utils/processes';
|
||||
import { runAgentAsync } from '../agentRun';
|
||||
import {
|
||||
type AgentRunProviderCredentials,
|
||||
leaseAgentRunProviderCredentialsAsync,
|
||||
} from '../agentRunLease';
|
||||
import { CLAUDE_CODE_CLI } from '../agents/claude';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('node:child_process', () => ({
|
||||
...jest.requireActual('node:child_process'),
|
||||
spawn: jest.fn(),
|
||||
}));
|
||||
jest.mock('../agentRunLease');
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../utils/processes', () => ({
|
||||
...jest.requireActual('../../../utils/processes'),
|
||||
killProcessGroup: jest.fn(),
|
||||
}));
|
||||
|
||||
const installMock = jest.mocked(turtleSpawn);
|
||||
const spawnMock = jest.mocked(spawn);
|
||||
const leaseMock = jest.mocked(leaseAgentRunProviderCredentialsAsync);
|
||||
const killProcessGroupMock = jest.mocked(killProcessGroup);
|
||||
|
||||
const agentRunId = '0199b0a0-1111-7222-8333-444455556666';
|
||||
const anthropicLease: AgentRunProviderCredentials = {
|
||||
provider: 'anthropic',
|
||||
accessToken: 'anthropic-access-token',
|
||||
};
|
||||
const openaiLease: AgentRunProviderCredentials = {
|
||||
provider: 'openai',
|
||||
idToken: 'openai-id-token',
|
||||
accessToken: 'openai-access-token',
|
||||
accountId: 'openai-account-id',
|
||||
};
|
||||
|
||||
interface AgentInvocation {
|
||||
command: string;
|
||||
args: string[];
|
||||
env: Record<string, string>;
|
||||
cwd: string;
|
||||
stdin: jest.Mock;
|
||||
child: EventEmitter & { stdout: PassThrough; stderr: PassThrough };
|
||||
/** The run removes its files when it ends, so call this while the agent is running. */
|
||||
readHomeFile(relativePath: string): { content: string; mode: number };
|
||||
exit(status: number | null, signal?: NodeJS.Signals, options?: { closeOutput: boolean }): void;
|
||||
closeOutput(): void;
|
||||
}
|
||||
|
||||
function mockSpawn({
|
||||
onAgent,
|
||||
onInstall,
|
||||
}: {
|
||||
onAgent: (invocation: AgentInvocation) => void;
|
||||
onInstall?: () => void;
|
||||
}): AgentInvocation[] {
|
||||
installMock.mockImplementation((() => {
|
||||
try {
|
||||
onInstall?.();
|
||||
return Promise.resolve({});
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}) as any);
|
||||
const invocations: AgentInvocation[] = [];
|
||||
spawnMock.mockImplementation(((command: string, args: string[], spawnOptions: any) => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
pid: 4242,
|
||||
stdin: { on: jest.fn(), end: jest.fn() },
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
});
|
||||
// Like a real child: `close` comes after everything it printed has been read.
|
||||
const closeOutput = (): void => {
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
void Promise.all([once(child.stdout, 'end'), once(child.stderr, 'end')]).then(() =>
|
||||
child.emit('close')
|
||||
);
|
||||
};
|
||||
const invocation: AgentInvocation = {
|
||||
command,
|
||||
args,
|
||||
env: spawnOptions.env,
|
||||
cwd: spawnOptions.cwd,
|
||||
stdin: child.stdin.end,
|
||||
child,
|
||||
readHomeFile(relativePath) {
|
||||
const filePath = path.join(spawnOptions.env.HOME, relativePath);
|
||||
return {
|
||||
content: vol.readFileSync(filePath, 'utf8') as string,
|
||||
mode: vol.statSync(filePath).mode & 0o777,
|
||||
};
|
||||
},
|
||||
exit(status, signal, options = { closeOutput: true }) {
|
||||
process.nextTick(() => {
|
||||
child.emit('exit', status, signal ?? null);
|
||||
if (options.closeOutput) {
|
||||
closeOutput();
|
||||
}
|
||||
});
|
||||
},
|
||||
closeOutput,
|
||||
};
|
||||
invocations.push(invocation);
|
||||
onAgent(invocation);
|
||||
return child;
|
||||
}) as any);
|
||||
return invocations;
|
||||
}
|
||||
|
||||
function runDirectories(): string[] {
|
||||
return (vol.readdirSync(os.tmpdir()) as string[]).filter(name =>
|
||||
name.startsWith('eas-agent-run-')
|
||||
);
|
||||
}
|
||||
|
||||
function createOptions(
|
||||
overrides: Partial<Parameters<typeof runAgentAsync>[0]> = {}
|
||||
): Parameters<typeof runAgentAsync>[0] {
|
||||
return {
|
||||
agentKind: 'claude-code',
|
||||
agentRunId,
|
||||
prompt: 'Fix the failing test.',
|
||||
expoToken: 'expo-token',
|
||||
expoApiV2BaseUrl: 'https://staging-api.expo.test/v2/',
|
||||
mcpUrl: 'https://staging-mcp.expo.test/mcp',
|
||||
pathEnv: '/usr/local/bin:/usr/bin',
|
||||
npmCacheUrl: 'https://npm-cache.expo.test',
|
||||
logger: createMockLogger(),
|
||||
signal: new AbortController().signal,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe(runAgentAsync, () => {
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
expect(runDirectories()).toEqual([]);
|
||||
});
|
||||
|
||||
it('runs Claude Code with MCP as its only tool source and an allowlisted environment', async () => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const invocations = mockSpawn({
|
||||
onAgent: agent => {
|
||||
const mcpConfig = agent.readHomeFile('mcp.json');
|
||||
expect(JSON.parse(mcpConfig.content)).toEqual({
|
||||
mcpServers: {
|
||||
expo: {
|
||||
type: 'http',
|
||||
url: 'https://staging-mcp.expo.test/mcp',
|
||||
headers: {
|
||||
Authorization: 'Bearer expo-token',
|
||||
'X-Expo-Agent-Run-Id': agentRunId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mcpConfig.mode).toBe(0o600);
|
||||
agent.exit(0);
|
||||
},
|
||||
});
|
||||
|
||||
await runAgentAsync(createOptions());
|
||||
|
||||
expect(installMock.mock.calls[0].slice(0, 2)).toEqual([
|
||||
'npm',
|
||||
[
|
||||
'install',
|
||||
'--prefix',
|
||||
expect.stringMatching(/eas-agent-run-.*\/cli$/),
|
||||
'--no-save',
|
||||
'--package-lock=false',
|
||||
'--no-audit',
|
||||
'--no-fund',
|
||||
'--registry',
|
||||
'https://npm-cache.expo.test',
|
||||
'@anthropic-ai/[email protected]',
|
||||
],
|
||||
]);
|
||||
expect(invocations).toHaveLength(1);
|
||||
const [agent] = invocations;
|
||||
const runDirectory = path.dirname(agent.env.HOME);
|
||||
expect(path.dirname(runDirectory)).toBe(os.tmpdir());
|
||||
expect(agent.command).toBe(path.join(runDirectory, 'cli', 'node_modules', '.bin', 'claude'));
|
||||
expect(agent.args).toEqual([
|
||||
'-p',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--tools',
|
||||
'',
|
||||
'--allowedTools',
|
||||
'mcp__expo',
|
||||
'--mcp-config',
|
||||
path.join(agent.env.HOME, 'mcp.json'),
|
||||
'--strict-mcp-config',
|
||||
]);
|
||||
expect(agent.env).toEqual({
|
||||
PATH: '/usr/local/bin:/usr/bin',
|
||||
HOME: path.join(runDirectory, 'home'),
|
||||
TMPDIR: path.join(runDirectory, 'tmp'),
|
||||
LANG: 'C.UTF-8',
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'anthropic-access-token',
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
||||
});
|
||||
expect(agent.cwd).toBe(path.join(runDirectory, 'cwd'));
|
||||
expect(agent.stdin).toHaveBeenCalledWith('Fix the failing test.');
|
||||
});
|
||||
|
||||
it('writes the agent output as log lines with the leased credentials masked', async () => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const logger = createMockLogger();
|
||||
mockSpawn({
|
||||
onAgent: agent => {
|
||||
// One JSON event split across two chunks, in the middle of the token.
|
||||
agent.child.stdout.write(
|
||||
'{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"token: anthropic-acc'
|
||||
);
|
||||
agent.child.stdout.write('ess-token"}]}}\n');
|
||||
agent.child.stdout.write(
|
||||
'{"type":"assistant","message":{"content":[{"type":"text","text":"Done.\\nThe token was anthropic-access-token.\\n"}]}}\n'
|
||||
);
|
||||
agent.child.stdout.write(
|
||||
'{"type":"result","subtype":"error_during_execution","is_error":true,"errors":["boom"]}\n'
|
||||
);
|
||||
agent.child.stderr.write('warning: anthropic-access-token\n');
|
||||
agent.exit(0);
|
||||
},
|
||||
});
|
||||
|
||||
await runAgentAsync(createOptions({ logger }));
|
||||
|
||||
const child = jest.mocked(logger.child);
|
||||
expect(child.mock.calls).toEqual([[{ source: 'stdout' }], [{ source: 'stderr' }]]);
|
||||
const [stdoutLogger, stderrLogger] = child.mock.results.map(result => result.value);
|
||||
expect(stdoutLogger.info.mock.calls).toEqual([
|
||||
['Tool result: token: [redacted]'],
|
||||
['Agent: Done.\nThe token was [redacted].'],
|
||||
]);
|
||||
expect(stdoutLogger.error.mock.calls).toEqual([['Agent failed: boom']]);
|
||||
expect(stderrLogger.info.mock.calls).toEqual([['warning: [redacted]']]);
|
||||
});
|
||||
|
||||
it('reports a line the formatter cannot handle instead of ending the run', async () => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const logger = createMockLogger();
|
||||
const formatOutputLine = jest
|
||||
.spyOn(CLAUDE_CODE_CLI, 'formatOutputLine')
|
||||
.mockImplementationOnce(() => {
|
||||
throw new RangeError('Maximum call stack size exceeded');
|
||||
});
|
||||
mockSpawn({
|
||||
onAgent: agent => {
|
||||
agent.child.stdout.write(
|
||||
'{"type":"assistant","message":{"content":[{"type":"tool_use","name":"t","input":{}}]}}\n'
|
||||
);
|
||||
agent.child.stdout.write(
|
||||
'{"type":"assistant","message":{"content":[{"type":"text","text":"Still here."}]}}\n'
|
||||
);
|
||||
agent.exit(0);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await runAgentAsync(createOptions({ logger }));
|
||||
} finally {
|
||||
formatOutputLine.mockRestore();
|
||||
}
|
||||
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not format a line of the agent output',
|
||||
expect.any(RangeError)
|
||||
);
|
||||
const [stdoutLogger] = jest.mocked(logger.child).mock.results.map(result => result.value);
|
||||
expect(stdoutLogger.info.mock.calls).toEqual([['Agent: Still here.']]);
|
||||
});
|
||||
|
||||
it('runs Codex with its shell tool off and the credentials in a private home', async () => {
|
||||
leaseMock.mockResolvedValue(openaiLease);
|
||||
const invocations = mockSpawn({
|
||||
onAgent: agent => {
|
||||
const config = agent.readHomeFile('.codex/config.toml');
|
||||
expect(config.content).toContain(
|
||||
'[features]\nshell_tool = false\nview_image = false\nimage_generation = false\napps = false\nplugins = false\n'
|
||||
);
|
||||
expect(config.content).toContain('sandbox_mode = "read-only"');
|
||||
expect(config.content).toContain('[agents]\nenabled = false\n');
|
||||
expect(config.content).toContain(
|
||||
[
|
||||
'[mcp_servers.expo]',
|
||||
'url = "http://localhost:8787/mcp"',
|
||||
'default_tools_approval_mode = "approve"',
|
||||
'',
|
||||
'[mcp_servers.expo.http_headers]',
|
||||
'"Authorization" = "Bearer expo-token"',
|
||||
`"X-Expo-Agent-Run-Id" = "${agentRunId}"`,
|
||||
].join('\n')
|
||||
);
|
||||
expect(config.mode).toBe(0o600);
|
||||
const auth = agent.readHomeFile('.codex/auth.json');
|
||||
expect(JSON.parse(auth.content)).toEqual({
|
||||
auth_mode: 'chatgptAuthTokens',
|
||||
tokens: {
|
||||
id_token: 'openai-id-token',
|
||||
access_token: 'openai-access-token',
|
||||
refresh_token: '',
|
||||
account_id: 'openai-account-id',
|
||||
},
|
||||
last_refresh: expect.any(String),
|
||||
});
|
||||
expect(auth.mode).toBe(0o600);
|
||||
agent.exit(0);
|
||||
},
|
||||
});
|
||||
|
||||
await runAgentAsync(
|
||||
createOptions({
|
||||
agentKind: 'codex',
|
||||
mcpUrl: 'http://localhost:8787/mcp',
|
||||
npmCacheUrl: undefined,
|
||||
})
|
||||
);
|
||||
|
||||
const npmArgs = installMock.mock.calls[0][1]!;
|
||||
expect(npmArgs).not.toContain('--registry');
|
||||
expect(npmArgs.at(-1)).toBe('@openai/[email protected]');
|
||||
const [agent] = invocations;
|
||||
const runDirectory = path.dirname(agent.env.HOME);
|
||||
expect(agent.command).toBe(path.join(runDirectory, 'cli', 'node_modules', '.bin', 'codex'));
|
||||
expect(agent.args).toEqual(['exec', '--strict-config', '--json', '--skip-git-repo-check']);
|
||||
expect(agent.cwd).toBe(path.join(runDirectory, 'cwd'));
|
||||
expect(agent.env).toEqual({
|
||||
PATH: '/usr/local/bin:/usr/bin',
|
||||
HOME: path.join(runDirectory, 'home'),
|
||||
TMPDIR: path.join(runDirectory, 'tmp'),
|
||||
LANG: 'C.UTF-8',
|
||||
});
|
||||
expect(agent.stdin).toHaveBeenCalledWith('Fix the failing test.');
|
||||
});
|
||||
|
||||
it('escalates to SIGKILL when the agent ignores the stop', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const controller = new AbortController();
|
||||
let agentStarted!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
agentStarted = resolve;
|
||||
});
|
||||
const invocations = mockSpawn({ onAgent: () => agentStarted() });
|
||||
|
||||
const run = runAgentAsync(createOptions({ signal: controller.signal }));
|
||||
const rejection = expect(run).rejects.toMatchObject({ name: 'AbortError' });
|
||||
await started;
|
||||
const [agent] = invocations;
|
||||
|
||||
controller.abort();
|
||||
expect(killProcessGroupMock.mock.calls).toEqual([[agent.child, 'SIGTERM']]);
|
||||
await jest.advanceTimersByTimeAsync(10_000);
|
||||
expect(killProcessGroupMock.mock.calls).toEqual([
|
||||
[agent.child, 'SIGTERM'],
|
||||
[agent.child, 'SIGKILL'],
|
||||
]);
|
||||
agent.exit(null, 'SIGKILL');
|
||||
|
||||
await rejection;
|
||||
expect(leaseMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ends what the agent left running and waits a bounded time for its output', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
let agentStarted!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
agentStarted = resolve;
|
||||
});
|
||||
const invocations = mockSpawn({ onAgent: () => agentStarted() });
|
||||
|
||||
const run = runAgentAsync(createOptions());
|
||||
await started;
|
||||
invocations[0].exit(0, undefined, { closeOutput: false });
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
await expect(run).resolves.toBeUndefined();
|
||||
expect(killProcessGroupMock.mock.calls).toEqual([[invocations[0].child, 'SIGKILL']]);
|
||||
});
|
||||
|
||||
it('stops the agent when the step is aborted and fails with the abort reason', async () => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('the step was canceled');
|
||||
const invocations = mockSpawn({
|
||||
onAgent: agent => {
|
||||
queueMicrotask(() => {
|
||||
controller.abort(reason);
|
||||
agent.exit(null, 'SIGTERM');
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await expect(runAgentAsync(createOptions({ signal: controller.signal }))).rejects.toBe(reason);
|
||||
|
||||
expect(killProcessGroupMock.mock.calls).toEqual([
|
||||
[invocations[0].child, 'SIGTERM'],
|
||||
[invocations[0].child, 'SIGKILL'],
|
||||
]);
|
||||
expect(leaseMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resumes Claude Code with new credentials when the lease changed during the run', async () => {
|
||||
const renewedLease: AgentRunProviderCredentials = {
|
||||
...anthropicLease,
|
||||
accessToken: 'renewed-access-token',
|
||||
};
|
||||
leaseMock.mockResolvedValueOnce(anthropicLease).mockResolvedValueOnce(renewedLease);
|
||||
const logger = createMockLogger();
|
||||
const invocations = mockSpawn({
|
||||
onAgent: agent => {
|
||||
if (invocations.length === 2) {
|
||||
agent.child.stderr.write('tokens: anthropic-access-token renewed-access-token\n');
|
||||
}
|
||||
agent.exit(invocations.length === 1 ? 1 : 0);
|
||||
},
|
||||
});
|
||||
|
||||
await runAgentAsync(createOptions({ logger }));
|
||||
|
||||
expect(installMock).toHaveBeenCalledTimes(1);
|
||||
expect(invocations).toHaveLength(2);
|
||||
const [first, resumed] = invocations;
|
||||
expect(resumed.args).toEqual(['-p', '--continue', ...first.args.slice(1)]);
|
||||
expect(resumed.env.CLAUDE_CODE_OAUTH_TOKEN).toBe('renewed-access-token');
|
||||
expect(resumed.stdin).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^Your previous run of this task stopped.*\n\nFix the failing test\.$/s)
|
||||
);
|
||||
const child = jest.mocked(logger.child);
|
||||
expect(child.mock.calls).toEqual([
|
||||
[{ source: 'stdout' }],
|
||||
[{ source: 'stderr' }],
|
||||
[{ source: 'stdout' }],
|
||||
[{ source: 'stderr' }],
|
||||
]);
|
||||
const [, , , resumedStderrLogger] = child.mock.results.map(result => result.value);
|
||||
expect(resumedStderrLogger.info.mock.calls).toEqual([['tokens: [redacted] [redacted]']]);
|
||||
});
|
||||
|
||||
it('resumes Codex with new credentials when the lease changed during the run', async () => {
|
||||
const renewedLease: AgentRunProviderCredentials = {
|
||||
...openaiLease,
|
||||
accessToken: 'renewed-access-token',
|
||||
idToken: 'renewed-id-token',
|
||||
};
|
||||
leaseMock.mockResolvedValueOnce(openaiLease).mockResolvedValueOnce(renewedLease);
|
||||
const invocations = mockSpawn({
|
||||
onAgent: agent => {
|
||||
if (invocations.length === 1) {
|
||||
agent.exit(1);
|
||||
return;
|
||||
}
|
||||
expect(JSON.parse(agent.readHomeFile('.codex/auth.json').content).tokens).toMatchObject({
|
||||
id_token: 'renewed-id-token',
|
||||
access_token: 'renewed-access-token',
|
||||
});
|
||||
agent.exit(0);
|
||||
},
|
||||
});
|
||||
|
||||
await runAgentAsync(createOptions({ agentKind: 'codex' }));
|
||||
|
||||
expect(invocations).toHaveLength(2);
|
||||
expect(invocations[1].args).toEqual([
|
||||
'exec',
|
||||
'--strict-config',
|
||||
'--json',
|
||||
'--skip-git-repo-check',
|
||||
'resume',
|
||||
'--last',
|
||||
'-',
|
||||
]);
|
||||
expect(invocations[1].stdin).toHaveBeenCalledWith(
|
||||
expect.stringContaining('\n\nFix the failing test.')
|
||||
);
|
||||
expect(invocations[1].cwd).toBe(invocations[0].cwd);
|
||||
});
|
||||
|
||||
it('does not resume an agent when the step was aborted while its credentials were renewed', async () => {
|
||||
const controller = new AbortController();
|
||||
leaseMock.mockResolvedValueOnce(anthropicLease).mockImplementationOnce(async () => {
|
||||
controller.abort();
|
||||
return { ...anthropicLease, accessToken: 'renewed-access-token' };
|
||||
});
|
||||
const invocations = mockSpawn({ onAgent: agent => agent.exit(1) });
|
||||
|
||||
await expect(runAgentAsync(createOptions({ signal: controller.signal }))).rejects.toMatchObject(
|
||||
{ name: 'AbortError' }
|
||||
);
|
||||
|
||||
expect(invocations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[2, undefined, 'exited with code 2'],
|
||||
[null, 'SIGKILL' as const, 'was terminated by SIGKILL'],
|
||||
])(
|
||||
'fails when the agent fails and the lease is unchanged (status %p, signal %p)',
|
||||
async (status, signal, description) => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const invocations = mockSpawn({ onAgent: agent => agent.exit(status, signal) });
|
||||
|
||||
await expect(runAgentAsync(createOptions())).rejects.toMatchObject({
|
||||
errorCode: 'EAS_RUN_AGENT_FAILED',
|
||||
message: expect.stringContaining(description),
|
||||
});
|
||||
|
||||
expect(invocations).toHaveLength(1);
|
||||
expect(leaseMock).toHaveBeenCalledTimes(2);
|
||||
}
|
||||
);
|
||||
|
||||
it('fails with the lease error when the lease cannot be renewed after the agent failed', async () => {
|
||||
leaseMock
|
||||
.mockResolvedValueOnce(anthropicLease)
|
||||
.mockRejectedValueOnce(new Error('lease refused'));
|
||||
const invocations = mockSpawn({ onAgent: agent => agent.exit(1) });
|
||||
|
||||
await expect(runAgentAsync(createOptions())).rejects.toThrow('lease refused');
|
||||
|
||||
expect(invocations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports an agent CLI that cannot start', async () => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
mockSpawn({
|
||||
onAgent: agent => {
|
||||
process.nextTick(() => agent.child.emit('error', new Error('spawn claude ENOENT')));
|
||||
},
|
||||
});
|
||||
|
||||
await expect(runAgentAsync(createOptions())).rejects.toThrow(
|
||||
'Could not start the agent CLI on the worker: spawn claude ENOENT.'
|
||||
);
|
||||
|
||||
expect(leaseMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cleans up when the installation fails', async () => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const invocations = mockSpawn({
|
||||
onInstall: () => {
|
||||
throw new Error('npm ERR! network');
|
||||
},
|
||||
onAgent: agent => agent.exit(0),
|
||||
});
|
||||
|
||||
await expect(runAgentAsync(createOptions())).rejects.toBeInstanceOf(SystemError);
|
||||
|
||||
expect(invocations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not install or start anything when the lease is refused', async () => {
|
||||
leaseMock.mockRejectedValue(new Error('lease refused'));
|
||||
mockSpawn({ onAgent: agent => agent.exit(0) });
|
||||
|
||||
await expect(runAgentAsync(createOptions())).rejects.toThrow('lease refused');
|
||||
|
||||
expect(installMock).not.toHaveBeenCalled();
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses credentials of the wrong provider', async () => {
|
||||
leaseMock.mockResolvedValue(openaiLease);
|
||||
const invocations = mockSpawn({ onAgent: agent => agent.exit(0) });
|
||||
|
||||
await expect(runAgentAsync(createOptions({ agentKind: 'claude-code' }))).rejects.toBeInstanceOf(
|
||||
SystemError
|
||||
);
|
||||
|
||||
expect(invocations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import fetch, { type Response } from 'node-fetch';
|
||||
|
||||
import { leaseAgentRunProviderCredentialsAsync } from '../agentRunLease';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const agentRunId = '0199b0a0-1111-7222-8333-444455556666';
|
||||
const options = {
|
||||
expoApiV2BaseUrl: 'https://staging-api.expo.test/v2/',
|
||||
expoToken: 'expo-token',
|
||||
agentRunId,
|
||||
};
|
||||
|
||||
function createResponse(status: number, body: unknown): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => {
|
||||
if (body === undefined) {
|
||||
throw new Error('not JSON');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe(leaseAgentRunProviderCredentialsAsync, () => {
|
||||
it('leases Anthropic credentials with the job token', async () => {
|
||||
const lease = { provider: 'anthropic', accessToken: 'access-token' };
|
||||
fetchMock.mockResolvedValue(
|
||||
createResponse(200, { data: { ...lease, expiresAt: '2026-10-06T12:00:00.000Z' } })
|
||||
);
|
||||
|
||||
await expect(leaseAgentRunProviderCredentialsAsync(options)).resolves.toEqual(lease);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe(`https://staging-api.expo.test/v2/agent-runs/${agentRunId}/auth-lease`);
|
||||
expect(init).toMatchObject({
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer expo-token' },
|
||||
});
|
||||
});
|
||||
|
||||
it('leases OpenAI credentials', async () => {
|
||||
const lease = {
|
||||
provider: 'openai',
|
||||
idToken: 'id-token',
|
||||
accessToken: 'access-token',
|
||||
accountId: 'account-id',
|
||||
};
|
||||
fetchMock.mockResolvedValue(createResponse(200, { data: lease }));
|
||||
|
||||
await expect(leaseAgentRunProviderCredentialsAsync(options)).resolves.toEqual(lease);
|
||||
});
|
||||
|
||||
it('reports a 4xx with the message from the server and does not retry', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
createResponse(400, {
|
||||
errors: [
|
||||
{
|
||||
code: 'AGENT_RUN_PROVIDER_CONNECTION_DELETED',
|
||||
message: 'The provider connection for this agent run was deleted.',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const lease = leaseAgentRunProviderCredentialsAsync(options);
|
||||
|
||||
await expect(lease).rejects.toBeInstanceOf(UserError);
|
||||
await expect(lease).rejects.toMatchObject({
|
||||
errorCode: 'EAS_RUN_AGENT_CREDENTIALS_UNAVAILABLE',
|
||||
message: 'The provider connection for this agent run was deleted.',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports a 4xx without a readable body', async () => {
|
||||
fetchMock.mockResolvedValue(createResponse(401, undefined));
|
||||
|
||||
await expect(leaseAgentRunProviderCredentialsAsync(options)).rejects.toThrow(
|
||||
'Expo refused to issue provider credentials for this agent run (HTTP 401).'
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries server errors and network failures', async () => {
|
||||
jest.useFakeTimers();
|
||||
const lease = { provider: 'anthropic', accessToken: 'access-token' };
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponse(503, undefined))
|
||||
.mockRejectedValueOnce(new Error('socket hang up'))
|
||||
.mockResolvedValueOnce(createResponse(200, { data: lease }));
|
||||
|
||||
const assertion = expect(leaseAgentRunProviderCredentialsAsync(options)).resolves.toEqual(
|
||||
lease
|
||||
);
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('retries when the body of a successful response cannot be read', async () => {
|
||||
jest.useFakeTimers();
|
||||
const lease = { provider: 'anthropic', accessToken: 'access-token' };
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponse(200, undefined))
|
||||
.mockResolvedValueOnce(createResponse(200, { data: lease }));
|
||||
|
||||
const assertion = expect(leaseAgentRunProviderCredentialsAsync(options)).resolves.toEqual(
|
||||
lease
|
||||
);
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('gives up after three failed attempts', async () => {
|
||||
jest.useFakeTimers();
|
||||
fetchMock.mockResolvedValue(createResponse(500, undefined));
|
||||
|
||||
const assertion = expect(leaseAgentRunProviderCredentialsAsync(options)).rejects.toBeInstanceOf(
|
||||
SystemError
|
||||
);
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('rejects a successful response that is not a lease', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
createResponse(200, { data: { provider: 'anthropic', refreshToken: 'nope' } })
|
||||
);
|
||||
|
||||
await expect(leaseAgentRunProviderCredentialsAsync(options)).rejects.toBeInstanceOf(
|
||||
SystemError
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import {
|
||||
buildEgressFenceRuleset,
|
||||
findProcessesOutsideScope,
|
||||
getCgroupLevel,
|
||||
listEmulatorProcessesAsync,
|
||||
parseAdbReversePorts,
|
||||
parseConnectAuthority,
|
||||
parseFenceCounters,
|
||||
parseFenceLogLine,
|
||||
parseLoopbackListenerPorts,
|
||||
resolveAndroidEmulatorLocalEgressAsync,
|
||||
startLocalEgressRelayAsync,
|
||||
} from '../androidLocalEgress';
|
||||
import { writeLocalEgressHandoffAsync } from '../localEgress';
|
||||
|
||||
describe(parseConnectAuthority, () => {
|
||||
it('parses IPv4, hostname and bracketed IPv6 authorities', () => {
|
||||
expect(parseConnectAuthority('CONNECT 192.0.2.1:443 HTTP/1.1\r\nHost: x\r\n')).toEqual({
|
||||
host: '192.0.2.1',
|
||||
port: 443,
|
||||
});
|
||||
expect(parseConnectAuthority('CONNECT Example.com:8443 HTTP/1.0\r\n')).toEqual({
|
||||
host: 'example.com',
|
||||
port: 8443,
|
||||
});
|
||||
expect(parseConnectAuthority('CONNECT [2001:db8::1]:443 HTTP/1.1\r\n')).toEqual({
|
||||
host: '2001:db8::1',
|
||||
port: 443,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores requests that are not CONNECT', () => {
|
||||
expect(parseConnectAuthority('GET http://example.com/ HTTP/1.1\r\n')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildEgressFenceRuleset, () => {
|
||||
it('refuses traffic from the session scope that cannot use the tunnel', () => {
|
||||
const ruleset = buildEgressFenceRuleset({ cgroupLevel: 2 });
|
||||
expect(ruleset).toContain('socket cgroupv2 level 2 @fenced jump fence');
|
||||
expect(ruleset).toContain('oif "lo" meta l4proto tcp accept');
|
||||
expect(ruleset).toContain('oif "lo" ip daddr 127.0.0.53 udp dport 53 accept');
|
||||
expect(ruleset).toContain('log prefix "eas-egress " level info');
|
||||
expect(ruleset).toContain('meta l4proto tcp counter name fence_tcp reject with tcp reset');
|
||||
expect(ruleset).toContain(
|
||||
'meta l4proto udp counter name fence_udp reject with icmpx admin-prohibited'
|
||||
);
|
||||
expect(ruleset).not.toMatch(/fence_\w+ accept/);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseFenceLogLine, () => {
|
||||
it('parses a refused IPv4 TCP connection from the emulator', () => {
|
||||
expect(
|
||||
parseFenceLogLine(
|
||||
'eas-egress IN= OUT=ens3 SRC=10.128.0.5 DST=172.253.155.190 LEN=60 TOS=0x00 PREC=0x00 TTL=64 ID=1 DF PROTO=TCP SPT=42408 DPT=443 WINDOW=64240 RES=0x00 SYN URGP=0'
|
||||
)
|
||||
).toEqual({
|
||||
process: 'Android emulator',
|
||||
pid: null,
|
||||
function: 'tcp',
|
||||
action: 'blocked',
|
||||
peer: '172.253.155.190:443',
|
||||
callers: [],
|
||||
note: expect.stringContaining('the emulator itself opened'),
|
||||
});
|
||||
});
|
||||
|
||||
it('labels QUIC and compresses IPv6 destinations', () => {
|
||||
expect(
|
||||
parseFenceLogLine(
|
||||
'eas-egress IN= OUT=ens3 SRC=10.128.0.5 DST=142.250.152.94 LEN=1278 PROTO=UDP SPT=51000 DPT=443 LEN=1258'
|
||||
)
|
||||
).toMatchObject({
|
||||
function: 'udp',
|
||||
peer: '142.250.152.94:443',
|
||||
note: expect.stringContaining('QUIC'),
|
||||
});
|
||||
expect(
|
||||
parseFenceLogLine(
|
||||
'eas-egress IN= OUT=ens3 SRC=fe80:0000:0000:0000:0000:0000:0000:0001 DST=ff02:0000:0000:0000:0000:0000:0000:00fb LEN=100 TC=0 HOPLIMIT=255 FLOWLBL=0 PROTO=UDP SPT=5353 DPT=5353 LEN=60'
|
||||
)
|
||||
).toMatchObject({ function: 'udp', peer: '[ff02::fb]:5353', note: undefined });
|
||||
});
|
||||
|
||||
it('reports ICMP without a port', () => {
|
||||
expect(
|
||||
parseFenceLogLine(
|
||||
'eas-egress IN= OUT=ens3 SRC=10.128.0.5 DST=8.8.8.8 LEN=84 PROTO=ICMP TYPE=8 CODE=0 ID=7 SEQ=1'
|
||||
)
|
||||
).toMatchObject({ function: 'icmp', action: 'blocked', peer: '8.8.8.8' });
|
||||
});
|
||||
|
||||
it('ignores unrelated kernel log lines', () => {
|
||||
expect(parseFenceLogLine('kvm: some unrelated message')).toBeNull();
|
||||
expect(parseFenceLogLine('eas-egress IN= OUT=ens3')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseFenceCounters, () => {
|
||||
it('reads packet counts from nft JSON output', () => {
|
||||
expect(
|
||||
parseFenceCounters(
|
||||
JSON.stringify({
|
||||
nftables: [
|
||||
{ metainfo: { version: '1.1.6' } },
|
||||
{ counter: { family: 'inet', name: 'fence_tcp', table: 'eas_fence', packets: 4 } },
|
||||
{ counter: { family: 'inet', name: 'fence_udp', table: 'eas_fence', packets: 57 } },
|
||||
{ counter: { family: 'inet', name: 'fence_other', table: 'eas_fence', packets: 2 } },
|
||||
],
|
||||
})
|
||||
)
|
||||
).toEqual({ tcp: 4, udp: 57, other: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('process coverage', () => {
|
||||
let procRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
procRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'fake-proc-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(procRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function addProcessAsync(pid: number, comm: string, cgroup: string): Promise<void> {
|
||||
await fs.promises.mkdir(path.join(procRoot, String(pid)));
|
||||
await fs.promises.writeFile(path.join(procRoot, String(pid), 'comm'), `${comm}\n`);
|
||||
await fs.promises.writeFile(path.join(procRoot, String(pid), 'cgroup'), `0::${cgroup}\n`);
|
||||
}
|
||||
|
||||
it('lists emulator processes and finds those outside the session scope', async () => {
|
||||
const scope = 'system.slice/eas-egress-session.scope';
|
||||
await addProcessAsync(10, 'qemu-system-x86', `/${scope}`);
|
||||
await addProcessAsync(11, 'netsimd', `/${scope}`);
|
||||
await addProcessAsync(12, 'netsimd', '/system.slice/eas-build-worker.service');
|
||||
await addProcessAsync(13, 'node', '/system.slice/eas-build-worker.service');
|
||||
await fs.promises.mkdir(path.join(procRoot, 'self'));
|
||||
|
||||
const processes = await listEmulatorProcessesAsync(procRoot);
|
||||
expect(processes.map(({ pid }) => pid).sort()).toEqual([10, 11, 12]);
|
||||
expect(findProcessesOutsideScope(processes, scope)).toEqual([
|
||||
{ pid: 12, comm: 'netsimd', cgroup: '/system.slice/eas-build-worker.service' },
|
||||
]);
|
||||
expect(getCgroupLevel(scope)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('adb reverse parsing', () => {
|
||||
it('finds loopback ports a process listens on', () => {
|
||||
const ss = [
|
||||
'LISTEN 0 4096 127.0.0.1:8899 0.0.0.0:* users:(("chisel",pid=4321,fd=9))',
|
||||
'LISTEN 0 4096 127.0.0.1:8082 0.0.0.0:* users:(("chisel",pid=4321,fd=10))',
|
||||
'LISTEN 0 4096 127.0.0.1:5037 0.0.0.0:* users:(("adb",pid=999,fd=3))',
|
||||
'LISTEN 0 4096 127.0.0.1:52001 0.0.0.0:* users:(("chisel",pid=43210,fd=3))',
|
||||
].join('\n');
|
||||
expect(parseLoopbackListenerPorts(ss, 4321)).toEqual([8082, 8899]);
|
||||
});
|
||||
|
||||
it('parses adb reverse --list output', () => {
|
||||
expect(
|
||||
parseAdbReversePorts('emulator-5554 tcp:8082 tcp:8082\nemulator-5554 tcp:8083 tcp:8083\n')
|
||||
).toEqual(new Set([8082, 8083]));
|
||||
});
|
||||
});
|
||||
|
||||
describe(startLocalEgressRelayAsync, () => {
|
||||
async function getFreePortAsync(): Promise<number> {
|
||||
const server = net.createServer();
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address() as net.AddressInfo;
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
return port;
|
||||
}
|
||||
|
||||
async function exchangeAsync(port: number, request: string): Promise<string> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const socket = net.connect({ host: '127.0.0.1', port });
|
||||
let received = '';
|
||||
socket.on('data', chunk => {
|
||||
received += chunk.toString();
|
||||
});
|
||||
socket.on('error', reject);
|
||||
socket.on('close', () => resolve(received));
|
||||
socket.write(request);
|
||||
});
|
||||
}
|
||||
|
||||
async function startUpstreamAsync(
|
||||
port: number
|
||||
): Promise<{ requests: string[]; close: () => Promise<void> }> {
|
||||
const requests: string[] = [];
|
||||
const server = net.createServer(socket => {
|
||||
socket.once('data', chunk => {
|
||||
requests.push(chunk.toString());
|
||||
socket.end('upstream reply');
|
||||
});
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(port, '127.0.0.1', resolve));
|
||||
return {
|
||||
requests,
|
||||
close: async () => await new Promise<void>(resolve => server.close(() => resolve())),
|
||||
};
|
||||
}
|
||||
|
||||
it('answers the readiness CONNECT itself, even without an egress client', async () => {
|
||||
const logger = createMockLogger();
|
||||
const relay = await startLocalEgressRelayAsync({
|
||||
port: await getFreePortAsync(),
|
||||
upstreamPort: await getFreePortAsync(),
|
||||
logger,
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
exchangeAsync(relay.port, 'CONNECT 192.0.2.1:443 HTTP/1.1\r\nHost: 192.0.2.1:443\r\n\r\n')
|
||||
).resolves.toBe('HTTP/1.1 200 Connection Established\r\n\r\n');
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await relay.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses while the client is detached and forwards once it attaches', async () => {
|
||||
const logger = createMockLogger();
|
||||
const upstreamPort = await getFreePortAsync();
|
||||
const relay = await startLocalEgressRelayAsync({
|
||||
port: await getFreePortAsync(),
|
||||
upstreamPort,
|
||||
logger,
|
||||
});
|
||||
const request = 'CONNECT 93.184.216.34:443 HTTP/1.1\r\nHost: 93.184.216.34:443\r\n\r\n';
|
||||
try {
|
||||
await expect(exchangeAsync(relay.port, request)).resolves.toMatch(
|
||||
/^HTTP\/1\.1 503 Service Unavailable\r\n[\s\S]*not connected/
|
||||
);
|
||||
await exchangeAsync(relay.port, request);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
|
||||
const upstream = await startUpstreamAsync(upstreamPort);
|
||||
try {
|
||||
await expect(exchangeAsync(relay.port, request)).resolves.toBe('upstream reply');
|
||||
expect(upstream.requests).toEqual([request]);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('2 request(s) from the Android emulator were refused')
|
||||
);
|
||||
} finally {
|
||||
await upstream.close();
|
||||
}
|
||||
} finally {
|
||||
await relay.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('unwraps a CONNECT to itself and forwards the inner request', async () => {
|
||||
const upstreamPort = await getFreePortAsync();
|
||||
const relay = await startLocalEgressRelayAsync({
|
||||
port: await getFreePortAsync(),
|
||||
upstreamPort,
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
const upstream = await startUpstreamAsync(upstreamPort);
|
||||
const inner = 'CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n';
|
||||
try {
|
||||
await expect(
|
||||
exchangeAsync(
|
||||
relay.port,
|
||||
`CONNECT 10.0.2.2:${relay.port} HTTP/1.1\r\nHost: 10.0.2.2:${relay.port}\r\n\r\n${inner}`
|
||||
)
|
||||
).resolves.toBe('HTTP/1.1 200 Connection Established\r\n\r\nupstream reply');
|
||||
expect(upstream.requests).toEqual([inner]);
|
||||
} finally {
|
||||
await upstream.close();
|
||||
await relay.stopAsync();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveAndroidEmulatorLocalEgressAsync, () => {
|
||||
let handoffPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
handoffPath = path.join(
|
||||
await fs.promises.mkdtemp(path.join(os.tmpdir(), 'android-egress-handoff-')),
|
||||
'handoff.json'
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(path.dirname(handoffPath), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns null without a local egress session', async () => {
|
||||
await expect(resolveAndroidEmulatorLocalEgressAsync({ handoffPath })).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('fails when a session exists but the fence was not started', async () => {
|
||||
await writeLocalEgressHandoffAsync(
|
||||
{
|
||||
url: 'https://egress.example.com',
|
||||
token: 'token',
|
||||
fingerprint: 'key=',
|
||||
port: 8899,
|
||||
platform: 'android',
|
||||
},
|
||||
handoffPath
|
||||
);
|
||||
await expect(resolveAndroidEmulatorLocalEgressAsync({ handoffPath })).rejects.toThrow(
|
||||
'the Android emulator fence was not started'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -124,8 +124,8 @@ describe(uploadArgentArtifactAsync, () => {
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
await uploadArgentArtifactAsync(ctx, {
|
||||
@@ -151,7 +151,7 @@ describe(uploadArgentArtifactAsync, () => {
|
||||
filename: 'report.json',
|
||||
kind: undefined,
|
||||
size: data.length,
|
||||
stream: expect.anything(),
|
||||
openStream: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,8 +168,8 @@ describe(uploadArgentArtifactAsync, () => {
|
||||
.mockResolvedValueOnce(new Response(Readable.from([Buffer.from('artifact-data')])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
await uploadArgentArtifactAsync(ctx, {
|
||||
@@ -345,8 +345,8 @@ describe(pollArgentArtifactsForUploadAsync, () => {
|
||||
});
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementation(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
.mockImplementation(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
const pollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
|
||||
@@ -371,7 +371,7 @@ describe(pollArgentArtifactsForUploadAsync, () => {
|
||||
name: 'b.json (artifact-b)',
|
||||
filename: 'b.json',
|
||||
size: 'artifact-b-data'.length,
|
||||
stream: expect.anything(),
|
||||
openStream: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
|
||||
kind: 'agent-device-test-report',
|
||||
metadata: { firstFrameRecordAt: 'test-time' },
|
||||
size: reportedSize,
|
||||
stream,
|
||||
openStream: () => stream,
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
@@ -63,7 +63,8 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
|
||||
metadata: { firstFrameRecordAt: 'test-time' },
|
||||
size: reportedSize,
|
||||
},
|
||||
})
|
||||
}),
|
||||
{ fetch: expect.any(Function) }
|
||||
);
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
'https://uploads.expo.test/artifact',
|
||||
@@ -73,4 +74,52 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards cancellation through upload allocation and skips PUT when allocation aborts', async () => {
|
||||
const controller = new AbortController();
|
||||
const mutation = jest.fn().mockReturnValue({
|
||||
toPromise: async () => {
|
||||
controller.abort();
|
||||
return {
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/artifact',
|
||||
headers: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
const ctx = { graphqlClient: { mutation } } as unknown as CustomBuildContext;
|
||||
await expect(
|
||||
uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId: 'session',
|
||||
artifactId: 'id',
|
||||
name: 'Logs',
|
||||
filename: 'logs.ndjson',
|
||||
kind: 'simulator-log',
|
||||
size: 0,
|
||||
openStream: () => Readable.from([]),
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
const allocationFetch = mutation.mock.calls[0][2].fetch;
|
||||
const mockFetch = jest.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('aborted'));
|
||||
try {
|
||||
await expect(allocationFetch('https://api.expo.test/graphql', {})).rejects.toThrow('aborted');
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/graphql',
|
||||
expect.objectContaining({
|
||||
signal: expect.objectContaining({ aborted: true }),
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
mockFetch.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
import { readFile, readdir, writeFile } from 'node:fs/promises';
|
||||
import { Readable } from 'node:stream';
|
||||
import os from 'node:os';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import {
|
||||
captureDeviceRunSessionPreviewAsync,
|
||||
ensureMacosPreviewEncoderInstalledAsync,
|
||||
startDeviceRunSessionPreview,
|
||||
} from '../deviceRunSessionPreview';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
describe(startDeviceRunSessionPreview, () => {
|
||||
const image = Buffer.from('webp-image');
|
||||
let captureAsync: jest.Mock<Promise<Buffer>, [AbortSignal]>;
|
||||
let mutation: jest.Mock;
|
||||
let logger: bunyan;
|
||||
let ctx: CustomBuildContext;
|
||||
let preview: ReturnType<typeof startDeviceRunSessionPreview> | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
captureAsync = jest.fn().mockResolvedValue(image);
|
||||
mutation = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createPreviewUploadSession: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/preview',
|
||||
headers: { 'content-type': 'image/webp' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
ctx = { graphqlClient: { mutation } } as unknown as CustomBuildContext;
|
||||
logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockReset()
|
||||
.mockResolvedValue(new Response('', { status: 200 }));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await preview?.stopAsync();
|
||||
preview = undefined;
|
||||
jest.useRealTimers();
|
||||
});
|
||||
const start = () => {
|
||||
preview = startDeviceRunSessionPreview({
|
||||
ctx,
|
||||
deviceRunSessionId: 'session-id',
|
||||
captureAsync,
|
||||
logger,
|
||||
});
|
||||
return preview;
|
||||
};
|
||||
|
||||
it('uploads immediately and reuses the URL every 60 seconds', async () => {
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://uploads.expo.test/preview',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: image,
|
||||
headers: { 'content-type': 'image/webp' },
|
||||
})
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(59_999);
|
||||
expect(captureAsync).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(captureAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
await preview!.stopAsync();
|
||||
await jest.advanceTimersByTimeAsync(120_000);
|
||||
expect(captureAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('retries capture errors without creating an empty artifact', async () => {
|
||||
captureAsync.mockRejectedValueOnce(new Error('device not ready'));
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries a failed upload with the same URL', async () => {
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 403 }));
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not log the signed upload URL when the upload request fails', async () => {
|
||||
const { FetchError } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValueOnce(
|
||||
new FetchError('network timeout at: https://uploads.expo.test/preview', 'request-timeout')
|
||||
);
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
const [fields] = jest.mocked(logger.warn).mock.calls[0] as unknown as [{ err: Error }];
|
||||
expect(fields.err.message).toBe(
|
||||
'Session preview upload request failed (name: FetchError, type: request-timeout).'
|
||||
);
|
||||
expect(JSON.stringify(jest.mocked(logger.warn).mock.calls)).not.toContain('uploads.expo.test');
|
||||
});
|
||||
|
||||
it('reuses the upload URL for the full session', async () => {
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(119 * 60_000);
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not overlap captures and aborts pending work when stopped', async () => {
|
||||
captureAsync.mockImplementation(
|
||||
signal =>
|
||||
new Promise((_, reject) => {
|
||||
signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
|
||||
})
|
||||
);
|
||||
const handle = start();
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(captureAsync).toHaveBeenCalledTimes(1);
|
||||
await handle.stopAsync();
|
||||
expect(captureAsync.mock.calls[0][0].aborted).toBe(true);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not upload a capture that finishes after shutdown', async () => {
|
||||
let finish!: (image: Buffer) => void;
|
||||
captureAsync.mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finish = resolve;
|
||||
})
|
||||
);
|
||||
const handle = start();
|
||||
const stopping = handle.stopAsync();
|
||||
finish(image);
|
||||
await stopping;
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('session preview with the real GraphQL client', () => {
|
||||
const uploadSession = {
|
||||
url: 'https://uploads.expo.test/preview',
|
||||
headers: { 'content-type': 'image/webp' },
|
||||
};
|
||||
let graphqlFetch: jest.SpyInstance;
|
||||
let preview: ReturnType<typeof startDeviceRunSessionPreview> | undefined;
|
||||
let logger: bunyan;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
graphqlFetch = jest.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new globalThis.Response(
|
||||
JSON.stringify({
|
||||
data: { deviceRunSession: { createPreviewUploadSession: { uploadSession } } },
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json' } }
|
||||
)
|
||||
);
|
||||
logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockReset()
|
||||
.mockResolvedValue(new Response('', { status: 200 }));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await preview?.stopAsync();
|
||||
preview = undefined;
|
||||
jest.restoreAllMocks();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const start = () => {
|
||||
const graphqlClient = new Client({
|
||||
url: 'https://api.expo.test/graphql',
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer worker-token' } },
|
||||
});
|
||||
preview = startDeviceRunSessionPreview({
|
||||
ctx: { graphqlClient } as CustomBuildContext,
|
||||
deviceRunSessionId: 'session-id',
|
||||
captureAsync: async () => Buffer.from('webp-image'),
|
||||
logger,
|
||||
});
|
||||
return preview;
|
||||
};
|
||||
|
||||
it('preserves the worker authentication when creating the upload session', async () => {
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
const [, options] = graphqlFetch.mock.calls[0];
|
||||
expect(new Headers(options.headers).get('authorization')).toBe('Bearer worker-token');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(['shutdown', 'timeout'])('aborts a stalled GraphQL request on %s', async reason => {
|
||||
const timeout = new AbortController();
|
||||
jest.spyOn(AbortSignal, 'timeout').mockReturnValue(timeout.signal);
|
||||
let requestSignal: AbortSignal | undefined;
|
||||
let rejectRequest!: (error: Error) => void;
|
||||
graphqlFetch.mockImplementation(
|
||||
(_url, options) =>
|
||||
new Promise((_, reject) => {
|
||||
rejectRequest = reject;
|
||||
requestSignal = options.signal;
|
||||
requestSignal!.addEventListener('abort', () => reject(requestSignal!.reason), {
|
||||
once: true,
|
||||
});
|
||||
})
|
||||
);
|
||||
const handle = start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
let stopped = false;
|
||||
let stopping: Promise<void> | undefined;
|
||||
if (reason === 'shutdown') {
|
||||
stopping = handle.stopAsync().then(() => {
|
||||
stopped = true;
|
||||
});
|
||||
} else {
|
||||
timeout.abort(new Error('preview attempt timed out'));
|
||||
}
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
const aborted = requestSignal!.aborted;
|
||||
const stoppedAfterAbort = stopped;
|
||||
// Always release the fake transport so a regression cannot hang test cleanup.
|
||||
rejectRequest(new Error('test cleanup'));
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(aborted).toBe(true);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
if (stopping) {
|
||||
expect(stoppedAfterAbort).toBe(true);
|
||||
await stopping;
|
||||
} else {
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
graphqlFetch.mockResolvedValue(
|
||||
new globalThis.Response(
|
||||
JSON.stringify({
|
||||
data: { deviceRunSession: { createPreviewUploadSession: { uploadSession } } },
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json' } }
|
||||
)
|
||||
);
|
||||
jest.spyOn(AbortSignal, 'timeout').mockReturnValue(new AbortController().signal);
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(captureDeviceRunSessionPreviewAsync, () => {
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0xff, 0x00]);
|
||||
const webp = Buffer.from('encoded-webp');
|
||||
const resized = Buffer.from('resized-png');
|
||||
it.each([
|
||||
[BuildRuntimePlatform.DARWIN, 'selected-device'],
|
||||
[BuildRuntimePlatform.LINUX, 'selected-device'],
|
||||
[BuildRuntimePlatform.LINUX, 'no-device-id'],
|
||||
] as const)(
|
||||
'captures the selected device on %s and removes temporary files',
|
||||
async (runtimePlatform, device) => {
|
||||
jest.mocked(spawn).mockImplementation((command, args) => {
|
||||
if (command === 'adb' && args[0] === 'devices') {
|
||||
return Promise.resolve({
|
||||
stdout: 'List of devices attached\nselected-device\tdevice\noffline-device\toffline\n',
|
||||
}) as ReturnType<typeof spawn>;
|
||||
}
|
||||
if (command === 'adb') {
|
||||
expect(args).toEqual(['-s', 'selected-device', 'exec-out', 'screencap', '-p']);
|
||||
return Object.assign(Promise.resolve({ stdout: '', stderr: '' }), {
|
||||
child: { stdout: Readable.from([png]) },
|
||||
}) as ReturnType<typeof spawn>;
|
||||
}
|
||||
return (async () => {
|
||||
if (command === 'xcrun') {
|
||||
expect(args.slice(0, 4)).toEqual(['simctl', 'io', 'selected-device', 'screenshot']);
|
||||
await writeFile(args[4], Uint8Array.from(png));
|
||||
} else if (command === 'sips') {
|
||||
expect(args.slice(0, 2)).toEqual(['-Z', '320']);
|
||||
expect(await readFile(args[2])).toEqual(png);
|
||||
await writeFile(args[args.indexOf('--out') + 1], Uint8Array.from(resized));
|
||||
} else if (command === 'cwebp') {
|
||||
expect(await readFile(args[args.indexOf('-o') - 1])).toEqual(resized);
|
||||
await writeFile(args[args.indexOf('-o') + 1], Uint8Array.from(webp));
|
||||
} else {
|
||||
// Homebrew's FFmpeg cannot encode WebP, so macOS must not use it.
|
||||
expect(runtimePlatform).toBe(BuildRuntimePlatform.LINUX);
|
||||
expect(command).toBe('ffmpeg');
|
||||
expect(await readFile(args[args.indexOf('-i') + 1])).toEqual(png);
|
||||
expect(args).toContain('libwebp');
|
||||
await writeFile(args[args.length - 1], Uint8Array.from(webp));
|
||||
}
|
||||
return { stdout: '', stderr: '' };
|
||||
})() as ReturnType<typeof spawn>;
|
||||
});
|
||||
expect(
|
||||
await captureDeviceRunSessionPreviewAsync({
|
||||
runtimePlatform,
|
||||
device,
|
||||
env: {},
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
).toEqual(webp);
|
||||
expect(
|
||||
(await readdir(os.tmpdir())).filter(name => name.startsWith('session-preview-'))
|
||||
).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
it('skips ambiguous Android devices instead of capturing the wrong screen', async () => {
|
||||
jest
|
||||
.mocked(spawn)
|
||||
.mockResolvedValue({ stdout: 'device-one\tdevice\ndevice-two\tdevice\n' } as Awaited<
|
||||
ReturnType<typeof spawn>
|
||||
>);
|
||||
await expect(
|
||||
captureDeviceRunSessionPreviewAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
device: 'no-device-id',
|
||||
env: {},
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
).rejects.toThrow('Expected exactly one connected Android device');
|
||||
});
|
||||
|
||||
it('cleans up temporary files when capture fails', async () => {
|
||||
jest.mocked(spawn).mockRejectedValue(new Error('capture failed'));
|
||||
await expect(
|
||||
captureDeviceRunSessionPreviewAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
device: 'device',
|
||||
env: {},
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
).rejects.toThrow('capture failed');
|
||||
expect(
|
||||
(await readdir(os.tmpdir())).filter(name => name.startsWith('session-preview-'))
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(ensureMacosPreviewEncoderInstalledAsync, () => {
|
||||
const logger = { info: jest.fn() } as unknown as bunyan;
|
||||
|
||||
it.each([true, false])(
|
||||
'installs webp only when cwebp is missing (present: %s)',
|
||||
async present => {
|
||||
jest.mocked(spawn).mockReset();
|
||||
jest
|
||||
.mocked(spawn)
|
||||
.mockImplementation(
|
||||
command =>
|
||||
(command === 'cwebp' && !present
|
||||
? Promise.reject(new Error('command not found: cwebp'))
|
||||
: Promise.resolve({ stdout: '', stderr: '' })) as ReturnType<typeof spawn>
|
||||
);
|
||||
await ensureMacosPreviewEncoderInstalledAsync({ env: { HOME: '/Users/expo' }, logger });
|
||||
const brewCalls = jest.mocked(spawn).mock.calls.filter(([command]) => command === 'brew');
|
||||
if (present) {
|
||||
expect(brewCalls).toEqual([]);
|
||||
} else {
|
||||
expect(brewCalls).toEqual([
|
||||
[
|
||||
'brew',
|
||||
['install', 'webp'],
|
||||
{ env: { HOME: '/Users/expo', HOMEBREW_NO_AUTO_UPDATE: '1' }, logger },
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects when webp cannot be installed', async () => {
|
||||
jest.mocked(spawn).mockReset();
|
||||
jest.mocked(spawn).mockRejectedValue(new Error('brew failed'));
|
||||
await expect(ensureMacosPreviewEncoderInstalledAsync({ env: {}, logger })).rejects.toThrow(
|
||||
'brew failed'
|
||||
);
|
||||
});
|
||||
});
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import type { BuildStepEnv } from '@expo/steps';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { copyFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import {
|
||||
findUnlistedDeviceScreenRecordingsAsync,
|
||||
uploadDeviceRunSessionScreenRecordingsAsync,
|
||||
} from '../deviceRunSessionScreenRecordings';
|
||||
|
||||
// Runs with `yarn jest-ffmpeg`, which needs ffmpeg and ffprobe on PATH.
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
let handle: (request: IncomingMessage, response: ServerResponse) => void;
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let ctx: CustomBuildContext;
|
||||
let url: string;
|
||||
let directory: string;
|
||||
let creationBodies: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
creationBodies = [];
|
||||
directory = await mkdtemp(path.join(tmpdir(), 'recording-recovery-'));
|
||||
server = createServer((request, response) => {
|
||||
if (request.url === '/graphql') {
|
||||
request.setEncoding('utf8');
|
||||
let body = '';
|
||||
request.on('data', chunk => (body += chunk));
|
||||
request.on('end', () => {
|
||||
creationBodies.push(body);
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: { url: `${url}/artifact`, headers: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
} else {
|
||||
handle(request, response);
|
||||
}
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Missing test server address');
|
||||
}
|
||||
url = `http://127.0.0.1:${address.port}`;
|
||||
ctx = {
|
||||
graphqlClient: new Client({
|
||||
url: `${url}/graphql`,
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
|
||||
}),
|
||||
} as CustomBuildContext;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('recovers unlisted recordings from a killed host and flags the unfinished ones as partial', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const env = process.env as BuildStepEnv;
|
||||
const cut = path.join(directory, 'cut');
|
||||
const empty = path.join(directory, 'empty');
|
||||
const failed = path.join(directory, 'failed');
|
||||
const done = path.join(directory, 'done');
|
||||
await mkdir(cut);
|
||||
await mkdir(empty);
|
||||
await mkdir(failed);
|
||||
await mkdir(done);
|
||||
const manifest = {
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
status: 'recording',
|
||||
recording: 'recording.mp4.partial',
|
||||
firstFrameWallClock: { iso8601: '2026-07-10T10:00:00.000Z' },
|
||||
width: 128,
|
||||
height: 96,
|
||||
};
|
||||
execFileSync('ffmpeg', [
|
||||
'-v',
|
||||
'error',
|
||||
'-f',
|
||||
'lavfi',
|
||||
'-i',
|
||||
'color=c=red:s=128x96:r=1',
|
||||
'-t',
|
||||
'3',
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-g',
|
||||
'1',
|
||||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
'-movflags',
|
||||
'frag_keyframe+empty_moov',
|
||||
'-f',
|
||||
'mp4',
|
||||
path.join(cut, 'recording.mp4.partial'),
|
||||
]);
|
||||
await writeFile(path.join(cut, 'session.json'), JSON.stringify(manifest));
|
||||
await writeFile(path.join(empty, 'recording.mp4.partial'), new Uint8Array(28));
|
||||
await writeFile(path.join(empty, 'session.json'), JSON.stringify(manifest));
|
||||
await copyFile(
|
||||
path.join(cut, 'recording.mp4.partial'),
|
||||
path.join(failed, 'recording.mp4.partial')
|
||||
);
|
||||
await writeFile(
|
||||
path.join(failed, 'session.json'),
|
||||
JSON.stringify({
|
||||
...manifest,
|
||||
status: 'failed',
|
||||
error: 'Recording finalization exceeded 30000 ms.',
|
||||
})
|
||||
);
|
||||
|
||||
await copyFile(path.join(cut, 'recording.mp4.partial'), path.join(done, 'recording.mp4'));
|
||||
await writeFile(
|
||||
path.join(done, 'session.json'),
|
||||
JSON.stringify({ ...manifest, status: 'complete', recording: 'recording.mp4' })
|
||||
);
|
||||
|
||||
const recordings = await findUnlistedDeviceScreenRecordingsAsync({
|
||||
root: directory,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
expect(recordings).toEqual([
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
directory: cut,
|
||||
},
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
directory: done,
|
||||
},
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
directory: failed,
|
||||
},
|
||||
]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('empty/recording.mp4.partial'));
|
||||
|
||||
let putBytes = 0;
|
||||
handle = (request, response) => {
|
||||
request.on('data', chunk => (putBytes += chunk.length));
|
||||
request.on('end', () => {
|
||||
response.statusCode = 200;
|
||||
response.end();
|
||||
});
|
||||
};
|
||||
await expect(
|
||||
uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
recordings,
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
expect(putBytes).toBe(3 * (await stat(path.join(cut, 'recording.mp4.partial'))).size);
|
||||
const inputs = creationBodies
|
||||
.map(body => JSON.parse(body).variables.input)
|
||||
.sort((a, b) => a.filename.localeCompare(b.filename));
|
||||
expect(inputs.map(input => input.filename)).toEqual(['cut.mp4', 'done.mp4', 'failed.mp4']);
|
||||
expect(inputs.map(input => input.name.includes(', partial)'))).toEqual([true, false, true]);
|
||||
expect(inputs[1].metadata).not.toHaveProperty('partial');
|
||||
expect(inputs[0].metadata).toMatchObject({
|
||||
partial: true,
|
||||
partialReason: 'The Device Hub stopped before it could finalize the recording.',
|
||||
width: 128,
|
||||
height: 96,
|
||||
});
|
||||
expect(inputs[2].metadata).toMatchObject({
|
||||
partial: true,
|
||||
partialReason: 'Recording finalization exceeded 30000 ms.',
|
||||
});
|
||||
});
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { screenshotArtifactDetails } from '../deviceRunSessionScreenshotNames';
|
||||
|
||||
const timestamp = '2026-09-24T08-45-59-123Z';
|
||||
const name = 'Screenshot 2026-09-24 08:45:59 UTC';
|
||||
const gitCommitHash = 'a1b2c3d' + '0'.repeat(33);
|
||||
const session = {
|
||||
platform: 'IOS' as const,
|
||||
app: { slug: 'workflow-project' },
|
||||
build: { id: 'installed-build', gitCommitHash, app: { slug: 'my-app' } },
|
||||
};
|
||||
|
||||
it('omits the commit for archive and empty sessions', () => {
|
||||
expect(screenshotArtifactDetails(timestamp, { ...session, build: null })).toEqual({
|
||||
name,
|
||||
filename: `workflow-project-ios-${timestamp}.png`,
|
||||
metadata: { __eas_type: 'screenshot', appSlug: 'workflow-project', platform: 'ios' },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([null, 'not-a-commit'])('omits a missing or invalid build commit: %s', gitCommitHash => {
|
||||
const result = screenshotArtifactDetails(timestamp, {
|
||||
...session,
|
||||
build: { ...session.build, gitCommitHash },
|
||||
});
|
||||
expect(result.filename).toBe(`my-app-ios-${timestamp}.png`);
|
||||
expect(result.metadata.gitCommitHash).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sanitizes and bounds the app slug for filesystem-safe names', () => {
|
||||
const result = screenshotArtifactDetails(timestamp, {
|
||||
...session,
|
||||
build: { ...session.build, app: { slug: '../../My App/' + 'x'.repeat(100) } },
|
||||
});
|
||||
expect(result.filename).toMatch(/^My-App-x+-ios-a1b2c3d-/);
|
||||
expect(result.filename).not.toMatch(/[/\\]/);
|
||||
expect(result.filename.length).toBeLessThan(150);
|
||||
});
|
||||
|
||||
it('accepts a full SHA-256 commit hash', () => {
|
||||
const gitCommitHash = 'b'.repeat(64);
|
||||
const result = screenshotArtifactDetails(timestamp, {
|
||||
...session,
|
||||
build: { ...session.build, gitCommitHash },
|
||||
});
|
||||
expect(result.filename).toBe(`my-app-ios-bbbbbbb-${timestamp}.png`);
|
||||
expect(result.metadata.gitCommitHash).toBe(gitCommitHash);
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import fs from 'node:fs';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { lstat, mkdtemp, readdir, rename, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import {
|
||||
startDeviceRunSessionScreenshotsAsync,
|
||||
uploadDeviceRunSessionScreenshotsAsync,
|
||||
} from '../deviceRunSessionScreenshots';
|
||||
|
||||
jest.mock('../deviceRunSessionArtifacts');
|
||||
jest.mock('../../../sentry');
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const options = { deviceRunSessionId: 'session-id', logger };
|
||||
let directory: string;
|
||||
let uploaded: Buffer[];
|
||||
let failedUploads: Map<string, { attempts: number; lastError: Error }>;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
directory = await mkdtemp(path.join(os.tmpdir(), 'screenshots-test-'));
|
||||
uploaded = [];
|
||||
failedUploads = new Map();
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementation(async (_ctx, { openStream }) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of openStream()) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
uploaded.push(Buffer.concat(chunks));
|
||||
});
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
const flush = () =>
|
||||
uploadDeviceRunSessionScreenshotsAsync(ctx, {
|
||||
...options,
|
||||
directory,
|
||||
failedUploads,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
it('uploads completed captures with their bytes and stable IDs, ignoring partial files and symlinks', async () => {
|
||||
const filename = 'screenshot-2026-09-24T08-45-59-123Z-ffffffffffff.png';
|
||||
// Sorts before the real file, so following the link would upload it before the target is gone.
|
||||
const link = 'screenshot-2026-09-24T08-45-59-123Z-000000000000.png';
|
||||
await writeFile(path.join(directory, filename), 'png-content');
|
||||
await writeFile(path.join(directory, `${filename}.tmp`), 'partial');
|
||||
await symlink(path.join(directory, filename), path.join(directory, link));
|
||||
await flush();
|
||||
await flush();
|
||||
expect(uploaded).toEqual([Buffer.from('png-content')]);
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
deviceRunSessionId: 'session-id',
|
||||
artifactId: filename.slice(0, -4),
|
||||
name: 'Screenshot 2026-09-24 08:45:59 UTC',
|
||||
filename: 'screenshot-2026-09-24T08-45-59-123Z.png',
|
||||
kind: 'screenshot',
|
||||
size: 11,
|
||||
metadata: { __eas_type: 'screenshot' },
|
||||
})
|
||||
);
|
||||
expect(await readdir(directory)).not.toContain(filename);
|
||||
expect((await lstat(path.join(directory, link))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it('uploads captures that never failed first, oldest first, before retrying failed ones', async () => {
|
||||
const failed = 'screenshot-2026-09-24T08-45-00-000Z-aaaaaaaaaaaa.png';
|
||||
const newer = 'screenshot-2026-09-24T08-45-59-123Z-bbbbbbbbbbbb.png';
|
||||
const older = 'screenshot-2026-09-24T08-45-30-000Z-cccccccccccc.png';
|
||||
for (const name of [newer, failed, older]) {
|
||||
await writeFile(path.join(directory, name), name);
|
||||
}
|
||||
failedUploads.set(failed, { attempts: 1, lastError: new Error('stalled') });
|
||||
await flush();
|
||||
expect(
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mock.calls.map(([, { artifactId }]) => artifactId)
|
||||
).toEqual([older, newer, failed].map(name => name.slice(0, -4)));
|
||||
});
|
||||
|
||||
it('keeps failed uploads and retries the same artifact ID, logging the failed attempt count', async () => {
|
||||
const filename = `screenshot-2026-09-24T08-45-59-123Z-${randomBytes(6).toString('hex')}.png`;
|
||||
await writeFile(path.join(directory, filename), 'png');
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementationOnce(async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
await flush();
|
||||
expect(await readdir(directory)).toContain(filename);
|
||||
await flush();
|
||||
expect(uploaded).toEqual([Buffer.from('png')]);
|
||||
expect(await readdir(directory)).toEqual([]);
|
||||
expect(
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls.map(([, args]) => args.artifactId)
|
||||
).toEqual([filename.slice(0, -4), filename.slice(0, -4)]);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.stringMatching(
|
||||
/^Uploaded preview screenshot screenshot-2026-09-24T08-45-59-123Z\.png \(3 B\) in \d+ ms after 1 failed attempt\.$/
|
||||
)
|
||||
);
|
||||
expect(failedUploads.size).toBe(0);
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads atomic captures immediately, including a capture arriving during another upload', async () => {
|
||||
jest.useFakeTimers();
|
||||
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
|
||||
let releaseFirstUpload!: () => void;
|
||||
const firstUploadGate = new Promise<void>(resolve => {
|
||||
releaseFirstUpload = resolve;
|
||||
});
|
||||
let signalFirstUpload!: () => void;
|
||||
const firstUploadStarted = new Promise<void>(resolve => {
|
||||
signalFirstUpload = resolve;
|
||||
});
|
||||
let signalSecondUpload!: () => void;
|
||||
const secondUploadStarted = new Promise<void>(resolve => {
|
||||
signalSecondUpload = resolve;
|
||||
});
|
||||
const uploadNormally = jest.mocked(uploadDeviceRunSessionArtifactAsync).getMockImplementation();
|
||||
if (!uploadNormally) {
|
||||
throw new Error('Expected the default upload implementation.');
|
||||
}
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (...args) => {
|
||||
signalFirstUpload();
|
||||
await firstUploadGate;
|
||||
await uploadNormally(...args);
|
||||
})
|
||||
.mockImplementationOnce(async (...args) => {
|
||||
signalSecondUpload();
|
||||
await uploadNormally(...args);
|
||||
});
|
||||
const firstFile = path.join(
|
||||
collector.directory,
|
||||
'screenshot-2026-09-24T08-45-59-123Z-aaaaaaaaaaaa.png'
|
||||
);
|
||||
const secondFile = path.join(
|
||||
collector.directory,
|
||||
'screenshot-2026-09-24T08-45-59-124Z-bbbbbbbbbbbb.png'
|
||||
);
|
||||
try {
|
||||
await writeFile(`${firstFile}.tmp`, 'first-capture');
|
||||
await rename(`${firstFile}.tmp`, firstFile);
|
||||
await firstUploadStarted;
|
||||
await writeFile(`${secondFile}.tmp`, 'second-capture');
|
||||
await rename(`${secondFile}.tmp`, secondFile);
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledTimes(1);
|
||||
releaseFirstUpload();
|
||||
await secondUploadStarted;
|
||||
await collector.finishAsync(true);
|
||||
expect(uploaded).toEqual([Buffer.from('first-capture'), Buffer.from('second-capture')]);
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
releaseFirstUpload();
|
||||
jest.useRealTimers();
|
||||
await collector.finishAsync(true);
|
||||
await rm(collector.directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('uses periodic scans when watching is unavailable and does not overlap uploads', async () => {
|
||||
jest.useFakeTimers();
|
||||
const watchSpy = jest.spyOn(fs, 'watch').mockImplementation(() => {
|
||||
throw new Error('Watching unavailable');
|
||||
});
|
||||
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
|
||||
let releaseUpload!: () => void;
|
||||
const uploadGate = new Promise<void>(resolve => {
|
||||
releaseUpload = resolve;
|
||||
});
|
||||
let signalUploadStarted!: () => void;
|
||||
const uploadStarted = new Promise<void>(resolve => {
|
||||
signalUploadStarted = resolve;
|
||||
});
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(collector.directory, 'screenshot-2026-09-24T08-45-59-123Z-aaaaaaaaaaaa.png'),
|
||||
'live-capture'
|
||||
);
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementationOnce(async () => {
|
||||
signalUploadStarted();
|
||||
await uploadGate;
|
||||
});
|
||||
jest.advanceTimersByTime(30_000);
|
||||
await uploadStarted;
|
||||
jest.advanceTimersByTime(60_000);
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledTimes(1);
|
||||
releaseUpload();
|
||||
await collector.finishAsync(true);
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
releaseUpload();
|
||||
watchSpy.mockRestore();
|
||||
jest.useRealTimers();
|
||||
await collector.finishAsync(true);
|
||||
await rm(collector.directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('warns for the first three attempts and every tenth after, without reporting to Sentry', async () => {
|
||||
const filename = `screenshot-2026-09-24T08-45-59-123Z-${randomBytes(6).toString('hex')}.png`;
|
||||
await writeFile(path.join(directory, filename), 'png');
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockRejectedValue(new Error('offline'));
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await flush();
|
||||
}
|
||||
expect(jest.mocked(logger.warn).mock.calls.map(([fields]) => fields.attempt)).toEqual([
|
||||
1, 2, 3, 10,
|
||||
]);
|
||||
expect(logger.warn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ file: path.join(directory, filename), attempt: 1, size: 3 }),
|
||||
'Could not upload preview screenshot (attempt 1). Keeping it for retry.'
|
||||
);
|
||||
expect(logger.warn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ attempt: 2 }),
|
||||
'Could not upload preview screenshot (attempt 2). Keeping it for retry.'
|
||||
);
|
||||
expect(failedUploads.get(filename)).toEqual({
|
||||
attempts: 12,
|
||||
lastError: expect.objectContaining({ message: 'offline' }),
|
||||
});
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes an empty directory on finish and returns the same task when finish repeats', async () => {
|
||||
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
|
||||
const finish = collector.finishAsync(true);
|
||||
expect(collector.finishAsync(true)).toBe(finish);
|
||||
await finish;
|
||||
await expect(readdir(collector.directory)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
expect(logger.info).toHaveBeenCalledWith('Uploaded 0 preview screenshots during the session.');
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the directory and warns when the session host is still running', async () => {
|
||||
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
|
||||
try {
|
||||
await collector.finishAsync(false);
|
||||
expect(await readdir(collector.directory)).toEqual([]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ directory: collector.directory },
|
||||
'The session host is still running, so preview screenshots it saves from now on are not uploaded.'
|
||||
);
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(collector.directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('warns with the files not uploaded and their last error, and reports them to Sentry once', async () => {
|
||||
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
|
||||
const filename = 'screenshot-2026-09-24T08-45-59-123Z-aaaaaaaaaaaa.png';
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockRejectedValue(new Error('offline'));
|
||||
try {
|
||||
await writeFile(path.join(collector.directory, filename), 'png');
|
||||
await collector.finishAsync(true);
|
||||
const notUploaded = [{ name: filename, attempts: expect.any(Number), lastError: 'offline' }];
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ directory: collector.directory, files: notUploaded },
|
||||
'1 preview screenshot was not uploaded.'
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Preview screenshots were not uploaded',
|
||||
expect.objectContaining({
|
||||
message: '1 preview screenshot was not uploaded.',
|
||||
}),
|
||||
{ extras: { files: notUploaded } }
|
||||
);
|
||||
} finally {
|
||||
await rm(collector.directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a screenshot as not uploaded when the shutdown deadline cuts off its upload', async () => {
|
||||
jest.useFakeTimers();
|
||||
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
|
||||
const filename = 'screenshot-2026-09-24T08-45-59-123Z-aaaaaaaaaaaa.png';
|
||||
const file = path.join(collector.directory, filename);
|
||||
let signalUploadStarted!: () => void;
|
||||
const uploadStarted = new Promise<void>(resolve => {
|
||||
signalUploadStarted = resolve;
|
||||
});
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, { signal }) => {
|
||||
signalUploadStarted();
|
||||
await new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => reject(signal.reason));
|
||||
});
|
||||
});
|
||||
try {
|
||||
await writeFile(file, 'png');
|
||||
const finish = collector.finishAsync(true);
|
||||
await uploadStarted;
|
||||
jest.advanceTimersByTime(30_000);
|
||||
await finish;
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ directory: collector.directory },
|
||||
'Preview screenshot flush timed out after 30 s.'
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ file, size: 3 },
|
||||
'Shutdown deadline reached before this preview screenshot uploaded. Keeping the file.'
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ directory: collector.directory, files: [{ name: filename }] },
|
||||
'1 preview screenshot was not uploaded.'
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith('Uploaded 0 preview screenshots during the session.');
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
expect(await readdir(collector.directory)).toEqual([filename]);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
await rm(collector.directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a failure record once and deletes it while uploading captures from the same scan', async () => {
|
||||
const png = 'screenshot-2026-09-24T08-45-59-123Z-aaaaaaaaaaaa.png';
|
||||
const failed = 'screenshot-2026-09-24T08-45-59-124Z-bbbbbbbbbbbb.png';
|
||||
const record = { file: failed, error: 'ENOSPC: no space left', at: '2026-09-24T08:45:59.124Z' };
|
||||
await writeFile(path.join(directory, png), 'png');
|
||||
await writeFile(
|
||||
path.join(directory, 'screenshot-2026-09-24T08-45-59-124Z-bbbbbbbbbbbb.failed.json'),
|
||||
JSON.stringify(record)
|
||||
);
|
||||
expect(await flush()).toEqual({ uploaded: 1, saveFailures: 1 });
|
||||
expect(await flush()).toEqual({ uploaded: 0, saveFailures: 0 });
|
||||
expect(uploaded).toEqual([Buffer.from('png')]);
|
||||
expect(await readdir(directory)).toEqual([]);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
record,
|
||||
'The session host could not save a preview screenshot.'
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'The session host could not save a preview screenshot',
|
||||
expect.objectContaining({ message: 'ENOSPC: no space left' }),
|
||||
{ extras: { file: failed, at: record.at } }
|
||||
);
|
||||
});
|
||||
|
||||
it('warns about a malformed failure record, deletes it, and does not report it to Sentry', async () => {
|
||||
const file = path.join(directory, 'screenshot-2026-09-24T08-45-59-124Z-bbbbbbbbbbbb.failed.json');
|
||||
await writeFile(file, JSON.stringify({ file: 'screenshot.png' }));
|
||||
await flush();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.anything(), file },
|
||||
'Could not read a preview screenshot failure record.'
|
||||
);
|
||||
expect(await readdir(directory)).toEqual([]);
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles a failure record as soon as it appears and counts it on finish', async () => {
|
||||
jest.useFakeTimers();
|
||||
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
|
||||
let signalReported!: () => void;
|
||||
const reported = new Promise<void>(resolve => {
|
||||
signalReported = resolve;
|
||||
});
|
||||
jest.mocked(Sentry.capture).mockImplementationOnce(() => signalReported());
|
||||
const record = path.join(
|
||||
collector.directory,
|
||||
'screenshot-2026-09-24T08-45-59-124Z-bbbbbbbbbbbb.failed.json'
|
||||
);
|
||||
try {
|
||||
await writeFile(`${record}.tmp`, JSON.stringify({ file: 'x.png', error: 'EIO', at: 'now' }));
|
||||
await rename(`${record}.tmp`, record);
|
||||
await reported;
|
||||
await collector.finishAsync(true);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Uploaded 0 preview screenshots during the session; the session host could not save 1.'
|
||||
);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
await collector.finishAsync(true);
|
||||
await rm(collector.directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import {
|
||||
loadScreenshotSessionAsync,
|
||||
screenshotArtifactDetails,
|
||||
} from '../deviceRunSessionScreenshotNames';
|
||||
import { uploadDeviceRunSessionScreenshotsAsync } from '../deviceRunSessionScreenshots';
|
||||
jest.unmock('node-fetch');
|
||||
jest.mock('../../../sentry');
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>(r => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
let handle: (request: IncomingMessage, response: ServerResponse) => void;
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let ctx: CustomBuildContext;
|
||||
let url: string;
|
||||
let directory: string;
|
||||
let creates: number;
|
||||
let authorization: string | undefined;
|
||||
let stallCreation: boolean;
|
||||
let creationStarted: ReturnType<typeof deferred<IncomingMessage>>;
|
||||
let creationBodies: string[];
|
||||
const installedCommit = 'a1b2c3d' + '0'.repeat(33);
|
||||
let sessionLookupFails: boolean;
|
||||
let sessionLookups: number;
|
||||
let uploadQuery: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
creates = 0;
|
||||
sessionLookups = 0;
|
||||
sessionLookupFails = false;
|
||||
uploadQuery = '';
|
||||
authorization = undefined;
|
||||
stallCreation = false;
|
||||
creationBodies = [];
|
||||
creationStarted = deferred();
|
||||
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
|
||||
server = createServer((request, response) => {
|
||||
if (request.url?.startsWith('/graphql')) {
|
||||
authorization = request.headers.authorization;
|
||||
request.setEncoding('utf8');
|
||||
let body = '';
|
||||
if (request.method === 'GET') {
|
||||
const requestUrl = new URL(request.url, 'http://localhost');
|
||||
body = JSON.stringify({ query: requestUrl.searchParams.get('query') });
|
||||
}
|
||||
request.on('data', chunk => (body += chunk));
|
||||
request.on('end', () => {
|
||||
const operation = JSON.parse(body);
|
||||
if (operation.query.includes('query ScreenshotSession')) {
|
||||
sessionLookups++;
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
if (sessionLookupFails) {
|
||||
response.end(JSON.stringify({ errors: [{ message: 'Lookup unavailable' }] }));
|
||||
} else {
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
deviceRunSessions: {
|
||||
byId: {
|
||||
platform: 'IOS',
|
||||
app: { slug: 'session-project' },
|
||||
build: {
|
||||
id: 'installed-build',
|
||||
gitCommitHash: installedCommit,
|
||||
app: { slug: 'my-app' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
creates++;
|
||||
creationBodies.push(body);
|
||||
creationStarted.resolve(request);
|
||||
if (stallCreation) {
|
||||
return;
|
||||
}
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: { url: `${url}/artifact${uploadQuery}`, headers: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
} else {
|
||||
handle(request, response);
|
||||
}
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Missing test server address');
|
||||
}
|
||||
url = `http://127.0.0.1:${address.port}`;
|
||||
ctx = {
|
||||
graphqlClient: new Client({
|
||||
url: `${url}/graphql`,
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
|
||||
}),
|
||||
} as CustomBuildContext;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('publishes a manual preview PNG through the artifact GraphQL and HTTP upload path', async () => {
|
||||
const payload = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aF9sAAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
);
|
||||
const filename = 'screenshot-2026-09-24T08-45-59-123Z-a1b2c3d4e5f6.png';
|
||||
await writeFile(path.join(directory, filename), payload);
|
||||
const bodies: Buffer[] = [];
|
||||
handle = (request, response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
request.on('data', chunk => chunks.push(Buffer.from(chunk)));
|
||||
request.on('end', () => {
|
||||
bodies.push(Buffer.concat(chunks));
|
||||
response.end();
|
||||
});
|
||||
};
|
||||
const session = loadScreenshotSessionAsync(ctx, 'drs-id', {
|
||||
warn: jest.fn(),
|
||||
} as unknown as bunyan);
|
||||
await uploadDeviceRunSessionScreenshotsAsync(ctx, {
|
||||
session,
|
||||
directory,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
failedUploads: new Map(),
|
||||
logger: { info: jest.fn(), warn: jest.fn() } as unknown as bunyan,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(bodies).toEqual([payload]);
|
||||
expect(creates).toBe(1);
|
||||
expect(sessionLookups).toBe(1);
|
||||
expect(authorization).toBe('Bearer test-auth');
|
||||
expect(JSON.parse(creationBodies[0]).variables).toEqual(
|
||||
expect.objectContaining({
|
||||
deviceRunSessionId: 'drs-id',
|
||||
input: expect.objectContaining({
|
||||
name: 'Screenshot 2026-09-24 08:45:59 UTC',
|
||||
filename: 'my-app-ios-a1b2c3d-2026-09-24T08-45-59-123Z.png',
|
||||
kind: 'screenshot',
|
||||
size: payload.length,
|
||||
metadata: {
|
||||
__eas_type: 'screenshot',
|
||||
appSlug: 'my-app',
|
||||
platform: 'ios',
|
||||
buildId: 'installed-build',
|
||||
gitCommitHash: installedCommit,
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
await expect(stat(path.join(directory, filename))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
it('rereads the PNG when the artifact PUT is retried', async () => {
|
||||
const filename = 'screenshot-2026-09-24T08-45-59-123Z-a1b2c3d4e5f6.png';
|
||||
await writeFile(path.join(directory, filename), 'retried-capture');
|
||||
const bodies: string[] = [];
|
||||
handle = (request, response) => {
|
||||
let body = '';
|
||||
request.setEncoding('utf8');
|
||||
request.on('data', chunk => (body += chunk));
|
||||
request.on('end', () => {
|
||||
bodies.push(body);
|
||||
response.statusCode = bodies.length === 1 ? 503 : 200;
|
||||
response.end();
|
||||
});
|
||||
};
|
||||
const uploaded = await uploadDeviceRunSessionScreenshotsAsync(ctx, {
|
||||
directory,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
failedUploads: new Map(),
|
||||
logger: { info: jest.fn(), warn: jest.fn() } as unknown as bunyan,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(uploaded).toEqual({ uploaded: 1, saveFailures: 0 });
|
||||
expect(creates).toBe(1);
|
||||
expect(bodies).toEqual(['retried-capture', 'retried-capture']);
|
||||
await expect(stat(path.join(directory, filename))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
it('aborts a stalled GraphQL upload request and keeps the PNG', async () => {
|
||||
stallCreation = true;
|
||||
const filename = 'screenshot-2026-09-24T08-45-59-123Z-a1b2c3d4e5f6.png';
|
||||
await writeFile(path.join(directory, filename), 'png');
|
||||
const controller = new AbortController();
|
||||
const upload = uploadDeviceRunSessionScreenshotsAsync(ctx, {
|
||||
directory,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
failedUploads: new Map(),
|
||||
logger: { info: jest.fn(), warn: jest.fn() } as unknown as bunyan,
|
||||
signal: controller.signal,
|
||||
});
|
||||
await creationStarted.promise;
|
||||
controller.abort();
|
||||
await upload;
|
||||
expect((await stat(path.join(directory, filename))).size).toBe(3);
|
||||
});
|
||||
|
||||
it('keeps the signed upload URL out of the error when the upload connection drops', async () => {
|
||||
uploadQuery = '?X-Goog-Signature=secret-signature';
|
||||
handle = request => request.socket.destroy();
|
||||
const filename = 'screenshot-2026-09-24T08-45-59-123Z-a1b2c3d4e5f6.png';
|
||||
await writeFile(path.join(directory, filename), 'png');
|
||||
const failedUploads = new Map<string, { attempts: number; lastError: Error }>();
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
await uploadDeviceRunSessionScreenshotsAsync(ctx, {
|
||||
directory,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
failedUploads,
|
||||
logger,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
const error = failedUploads.get(filename)?.lastError;
|
||||
expect(error?.message).toBe(
|
||||
`Failed to upload device run session artifact ${filename.slice(0, -4)}: ECONNRESET.`
|
||||
);
|
||||
expect(error?.cause).toBeUndefined();
|
||||
expect(JSON.stringify(jest.mocked(logger.warn).mock.calls)).not.toContain('secret-signature');
|
||||
expect(JSON.stringify(jest.mocked(logger.warn).mock.calls)).not.toContain('/artifact');
|
||||
});
|
||||
|
||||
it('falls back without using the workflow commit when the session lookup fails', async () => {
|
||||
sessionLookupFails = true;
|
||||
ctx.metadata = { appName: 'wrong-app', gitCommitHash: 'f'.repeat(40) };
|
||||
const logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
const session = await loadScreenshotSessionAsync(ctx, 'drs-id', logger);
|
||||
expect(session).toBeNull();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ deviceRunSessionId: 'drs-id' }),
|
||||
'Could not load screenshot session details; using capture timestamps.'
|
||||
);
|
||||
expect(screenshotArtifactDetails('2026-09-24T08-45-59-123Z', session)).toEqual({
|
||||
name: 'Screenshot 2026-09-24 08:45:59 UTC',
|
||||
filename: 'screenshot-2026-09-24T08-45-59-123Z.png',
|
||||
metadata: { __eas_type: 'screenshot' },
|
||||
});
|
||||
});
|
||||
Loaded 100 of 401 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user