mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 03:38:08 -10:00
Compare commits
86
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3aee2d0ca | ||
|
|
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 | ||
|
|
43068db22a | ||
|
|
2b7f1954fc | ||
|
|
3b64599aea | ||
|
|
96fe9a239e | ||
|
|
195807643f | ||
|
|
21256e6d55 | ||
|
|
705fa875d3 | ||
|
|
498384357e | ||
|
|
53938e4cf8 | ||
|
|
e18ffcd48e | ||
|
|
df12f86da9 | ||
|
|
3dba7f2bf7 | ||
|
|
e7efe14336 | ||
|
|
78f2103bcb | ||
|
|
565b0ec425 | ||
|
|
253d72bb4d | ||
|
|
35522e9b28 | ||
|
|
4aa9522825 | ||
|
|
a92b0b0ad5 | ||
|
|
66cdccb0e7 | ||
|
|
2eefa44f8f | ||
|
|
55302dd830 | ||
|
|
924eb87157 | ||
|
|
f546bf3352 | ||
|
|
97553334d4 | ||
|
|
31d12b9888 | ||
|
|
dc2658f63b | ||
|
|
b8b9de78fc | ||
|
|
8ff599b1f2 | ||
|
|
e6f02af973 | ||
|
|
a163b5a76d |
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,31 @@
|
||||
name: test-egress-guard
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
pull_request:
|
||||
paths:
|
||||
- packages/build-tools/resources/egress-guard/**
|
||||
- packages/build-tools/src/steps/utils/localEgressGuard.ts
|
||||
- packages/build-tools/src/steps/utils/__tests__/localEgressGuard*.ts
|
||||
- packages/build-tools/src/steps/functions/startIosSimulator.ts
|
||||
- packages/worker/.eas/workflows/test-egress-guard.yml
|
||||
- .github/workflows/test-egress-guard.yml
|
||||
|
||||
jobs:
|
||||
test-egress-guard:
|
||||
# The simulator tests need macOS with Xcode, which the EAS macOS workers have.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
- uses: ./.github/actions/setup-mise
|
||||
- name: Install eas-cli
|
||||
run: npm install --global eas-cli@latest
|
||||
- name: Run the test-egress-guard EAS workflow on macOS
|
||||
working-directory: packages/worker
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
|
||||
run: |
|
||||
eas workflow:run test-egress-guard.yml \
|
||||
--ref ${{ github.event.pull_request.head.sha || github.sha }} \
|
||||
--non-interactive \
|
||||
--wait
|
||||
@@ -15,9 +15,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: ['20', '22', '24']
|
||||
node: ['20.18.3', '22', '24']
|
||||
include:
|
||||
- node: '20'
|
||||
- node: '20.18.3'
|
||||
coverage: true
|
||||
name: Test with Node ${{ matrix.node }}
|
||||
steps:
|
||||
@@ -26,6 +26,9 @@ jobs:
|
||||
uses: ./.github/actions/setup-mise
|
||||
env:
|
||||
MISE_NODE_VERSION: ${{ matrix.node }}
|
||||
- name: Install ffmpeg
|
||||
# build-tools recording-recovery tests cut and decode 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
|
||||
env:
|
||||
|
||||
+126
-2
@@ -8,12 +8,136 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [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))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [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))
|
||||
- [build-tools] Record both iPhone Duo panels side by side and upright, so iOS Simulator screen recordings are no longer black while the device is folded.
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [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] 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. ([#XXXX](https://github.com/expo/eas-cli/pull/XXXX) 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))
|
||||
|
||||
## [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
|
||||
|
||||
- [eas-cli] Support Xcode 27, which replaces Simulator.app with Device Hub, in `build:dev`, `build:run`, and `run` for iOS simulator builds. ([#4405](https://github.com/expo/eas-cli/pull/4405) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [24.4.1](https://github.com/expo/eas-cli/releases/tag/v24.4.1) - 2026-09-15
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Bump `@expo/apple-utils` to `2.2.1`. ([#4404](https://github.com/expo/eas-cli/pull/4404) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [24.4.0](https://github.com/expo/eas-cli/releases/tag/v24.4.0) - 2026-09-14
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Require Node `^20.18.3 || >=22.0.0`. ([#4279](https://github.com/expo/eas-cli/pull/4279) by [@ramonclaudio](https://github.com/ramonclaudio))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Inject a guard into every iOS Simulator process during `--egress local` sessions that refuses connections which bypass the system proxy and reports them, with the calling frameworks, in the session log. ([#4393](https://github.com/expo/eas-cli/pull/4393) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Set proxy environment variables inside the iOS Simulator for `--egress local` sessions, so clients that read them (gRPC, libcurl) also exit from the egress client. ([#4390](https://github.com/expo/eas-cli/pull/4390) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
### 🐛 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))
|
||||
|
||||
## [24.3.0](https://github.com/expo/eas-cli/releases/tag/v24.3.0) - 2026-09-11
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-build-job] Add a shared protocol for sandbox daemon commands. ([#4346](https://github.com/expo/eas-cli/pull/4346) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [24.2.0](https://github.com/expo/eas-cli/releases/tag/v24.2.0) - 2026-09-11
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-build-job] Remove repository URLs from Git job inputs and fetch them when resolving project sources. ([#4360](https://github.com/expo/eas-cli/pull/4360) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Add `eas workflow:insights` to display Workflows Insights for a time range: run counts and success rate compared with the previous period, runs over time, and a per-workflow breakdown, with `--json` output for scripts. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Add `eas workflow:insights:maestro` to display Maestro test insights for a time range: pass and flake rates, a sortable per-flow table, and a single flow's history with error patterns and recent runs, with `--json` output for scripts. ([#4370](https://github.com/expo/eas-cli/pull/4370) by [@hSATAC](https://github.com/hSATAC))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Reject `--end` without `--start` in the `observe:*`, `update:insights`, `channel:insights`, and `update:view --insights` commands instead of silently falling back to the default time range. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Reject a time range that does not run forwards in the `observe:*`, `update:insights`, `channel:insights`, `update:view --insights`, and `workflow:insights` commands instead of sending it to the server. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Reject non-integer values for `--limit` and `--offset` instead of sending them to the server. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [build-tools] Require PATH project sources for local builds. ([#4362](https://github.com/expo/eas-cli/pull/4362) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [24.1.2](https://github.com/expo/eas-cli/releases/tag/v24.1.2) - 2026-09-11
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Shorten the local egress banner printed by `eas simulator:start` and stop mentioning `eas simulator:egress` in interactive mode, where the client runs in the same terminal. ([#4383](https://github.com/expo/eas-cli/pull/4383) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [24.1.1](https://github.com/expo/eas-cli/releases/tag/v24.1.1) - 2026-09-11
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
+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.1.1",
|
||||
"PRODUCTION": "24.1.1"
|
||||
"STAGING": "24.8.0",
|
||||
"PRODUCTION": "24.8.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "24.1.2",
|
||||
"version": "24.8.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
+3
-3
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.11",
|
||||
"@types/node": "20.14.8",
|
||||
"@types/node": "20.19.43",
|
||||
"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]"
|
||||
}
|
||||
@@ -8,3 +8,5 @@ bin/record-sim
|
||||
|
||||
src/graphql-env.d.ts
|
||||
schema.graphql
|
||||
bin/egress-guard.dylib
|
||||
bin/egress-guard-check
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "24.1.0",
|
||||
"version": "24.8.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -24,6 +24,8 @@
|
||||
"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",
|
||||
"generate-appium-commands": "mise exec [email protected] -- node scripts/generate-appium-commands.js",
|
||||
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
|
||||
@@ -39,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.0.0",
|
||||
"@expo/downloader": "24.8.0",
|
||||
"@expo/eas-build-job": "24.8.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "24.0.0",
|
||||
"@expo/logger": "24.8.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "24.0.0",
|
||||
"@expo/template-file": "24.0.0",
|
||||
"@expo/turtle-spawn": "24.0.0",
|
||||
"@expo/steps": "24.8.0",
|
||||
"@expo/template-file": "24.8.0",
|
||||
"@expo/turtle-spawn": "24.8.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
@@ -59,12 +61,13 @@
|
||||
"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",
|
||||
"node-fetch": "^2.7.0",
|
||||
"node-forge": "^1.3.1",
|
||||
"node-pty": "1.2.0-beta.15",
|
||||
"node-stream-zip": "1.15.0",
|
||||
"nullthrows": "^1.1.1",
|
||||
"plist": "^3.1.0",
|
||||
@@ -75,6 +78,7 @@
|
||||
"semver": "^7.6.2",
|
||||
"tar": "7.5.19",
|
||||
"uuid": "9.0.1",
|
||||
"ws": "8.21.1",
|
||||
"yaml": "^2.8.1",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
@@ -84,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": "20.19.43",
|
||||
"@types/node-fetch": "^2.6.11",
|
||||
"@types/node-forge": "^1.3.11",
|
||||
"@types/plist": "^3.0.5",
|
||||
@@ -92,14 +96,15 @@
|
||||
"@types/retry": "^0.12.5",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@types/ws": "8.5.10",
|
||||
"jest": "^29.7.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
# egress-guard
|
||||
|
||||
A small dylib injected into every process the iOS Simulator launches during a
|
||||
`--egress local` session. It interposes the socket calls that open outbound
|
||||
traffic (`connect`, `connectx`, `sendto`, `sendmsg`, and the `$NOCANCEL`
|
||||
variants of the last three that libsystem exports alongside them) and refuses
|
||||
any destination that is not loopback. A sockaddr whose family was left
|
||||
`AF_UNSPEC` is classified as the family its length implies, which is how the
|
||||
kernel treats it for TCP `connect` and IPv4 sends; on a datagram socket
|
||||
`connect(AF_UNSPEC)` is left to the kernel, which dissolves the association. The system proxy and the `--egress-allow`
|
||||
forwards live on loopback, so everything that honors the proxy keeps working
|
||||
and everything that bypasses it fails with `ECONNREFUSED` in the process that
|
||||
tried, with the calling frameworks recorded.
|
||||
|
||||
Why interposing: simulator processes share the worker's uid, so a packet
|
||||
filter cannot tell them apart from the worker's own traffic. dyld interposing
|
||||
from an inserted library reaches calls made inside Apple's own frameworks
|
||||
(CFNetwork, Network.framework), and `launchctl setenv DYLD_INSERT_LIBRARIES`
|
||||
inside the simulator makes launchd hand the library to every process it
|
||||
spawns. The simulator does not enforce library validation, which is what makes
|
||||
this possible there and nowhere else.
|
||||
|
||||
Configuration comes from the environment the simulator's launchd provides:
|
||||
|
||||
- `EAS_EGRESS_GUARD_LOG`: file the guard appends events to (host path).
|
||||
- `EAS_EGRESS_GUARD_MODE`: `block` (default, and what any unknown value means)
|
||||
or `log` (observe only).
|
||||
|
||||
Each event is one tab-separated line:
|
||||
|
||||
```
|
||||
eas-egress-guard\t<process>\t<pid>\t<function>\t<blocked|logged>\t<peer>\t<caller>,<caller>...
|
||||
```
|
||||
|
||||
A process lists each `(function, peer)` once, up to 128 of them. When that
|
||||
table fills it writes one more line with function `overflow` and the limit as
|
||||
the peer; further distinct destinations are still refused but not listed.
|
||||
|
||||
Layout:
|
||||
|
||||
- `policy.c` / `policy.h`: classification, mode, formatting, per-process
|
||||
dedupe. Pure C, host-testable.
|
||||
- `guard.c`: the interposers and the constructor that reads the environment.
|
||||
- `check.c`: `egress-guard-check`, run inside the simulator right after the
|
||||
guard is installed. Exits non-zero unless the library is loaded in a fresh
|
||||
process and behaves as the mode says on `connect`, `connect$NOCANCEL` and a
|
||||
`connect` with an `AF_UNSPEC` sockaddr; the worker fails the session on that.
|
||||
- `tests/policy_test.c`: host unit tests, `tests/run-policy-tests.sh`.
|
||||
- `tests/guard_test.c`, `tests/guard_insert_test.c`: host tests of the
|
||||
interposers themselves, `tests/run-guard-tests.sh`. The first compiles
|
||||
`guard.c` in and forces the lock schedules (a signal handler making a socket
|
||||
call inside the critical section, a fork while another thread holds the lock,
|
||||
contention); the second inserts the built library like the simulator does and
|
||||
checks descriptor reuse, the `$NOCANCEL` symbols, `AF_UNSPEC` shapes and the
|
||||
UDP disconnect. Nothing in them opens a socket to a non-loopback address.
|
||||
- `tests/nettest.swift`: probe binary the simulator end-to-end test runs inside
|
||||
a device; exercises URLSession, Network.framework, BSD TCP and UDP, DNS.
|
||||
- `build.sh`: universal simulator dylib into `packages/build-tools/bin/`.
|
||||
|
||||
Installation order matters. `simctl` forwards every `SIMCTL_CHILD_`-prefixed
|
||||
variable of its own environment to the process it starts, and for `simctl
|
||||
boot` that process is the simulator's launchd, so the worker boots with the
|
||||
guard and proxy variables in that form and every process of the boot inherits
|
||||
them (measured: 176 of 176). `launchctl setenv` after boot is kept for a
|
||||
device that was already booted, but by then the boot's own processes have
|
||||
started without it. It also cannot change a variable the boot already carried:
|
||||
a device booted with one guard configuration keeps it until it is shut down,
|
||||
which is why the worker boots devices itself and every configuration in the
|
||||
end-to-end test gets its own boot. The self-check runs once boot completes and is followed
|
||||
by a coverage report from `lsof`, listing any process without the library. Both the dylib and the check binary are built by
|
||||
`packages/worker/package.sh` for the iOS worker tarball and by the
|
||||
`test-egress-guard` EAS workflow, not committed.
|
||||
|
||||
Failure semantics: a missing library, a failed `launchctl setenv`, or a failed
|
||||
self-check fails the session, since a `--egress local` session without the
|
||||
guard would silently leak. Only an unwritable event log is a warning, because
|
||||
refusals still happen and only the reporting is lost. Recording is best-effort
|
||||
in the process too: the event log is opened per event rather than held open
|
||||
(a process that closes descriptors it does not own, as `launchd_sim` does,
|
||||
would otherwise make the guard write into whatever reused the number), and a
|
||||
dedupe slot that cannot be taken within a bounded number of attempts (a signal
|
||||
handler re-entering the guard, a fork child inheriting a held lock) skips the
|
||||
event rather than blocking or aborting the process.
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the guard as a universal (arm64 + x86_64) iOS Simulator dylib into
|
||||
# packages/build-tools/bin/egress-guard.dylib. Requires Xcode.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
# Output directory; defaults to packages/build-tools/bin.
|
||||
bin_dir="${1:-../../bin}"
|
||||
mkdir -p "$bin_dir"
|
||||
out=$(mktemp -d)
|
||||
sdk=$(xcrun --sdk iphonesimulator --show-sdk-path)
|
||||
for arch in arm64 x86_64; do
|
||||
xcrun --sdk iphonesimulator clang \
|
||||
-std=c11 -Wall -Wextra -Werror -O2 \
|
||||
-target "$arch-apple-ios15.0-simulator" -isysroot "$sdk" \
|
||||
-dynamiclib -install_name @rpath/egress-guard.dylib \
|
||||
-o "$out/egress-guard-$arch.dylib" policy.c guard.c
|
||||
done
|
||||
lipo -create -output "$bin_dir/egress-guard.dylib" "$out/egress-guard-arm64.dylib" "$out/egress-guard-x86_64.dylib"
|
||||
lipo -info "$bin_dir/egress-guard.dylib"
|
||||
|
||||
# The self-check the worker runs inside the simulator after installing the guard.
|
||||
for arch in arm64 x86_64; do
|
||||
xcrun --sdk iphonesimulator clang \
|
||||
-std=c11 -Wall -Wextra -Werror -O2 \
|
||||
-target "$arch-apple-ios15.0-simulator" -isysroot "$sdk" \
|
||||
-o "$out/egress-guard-check-$arch" check.c
|
||||
done
|
||||
lipo -create -output "$bin_dir/egress-guard-check" "$out/egress-guard-check-arm64" "$out/egress-guard-check-x86_64"
|
||||
chmod +x "$bin_dir/egress-guard-check"
|
||||
lipo -info "$bin_dir/egress-guard-check"
|
||||
@@ -0,0 +1,112 @@
|
||||
// Self-check run inside a simulator right after the guard is installed:
|
||||
// proves the guard library is loaded in a freshly spawned process and that it
|
||||
// behaves as the requested mode says, on every connect entry point the guard
|
||||
// covers. Exit 0 on success; 2 when the library is not loaded; 3 when it is
|
||||
// loaded but did not behave; 1 on usage errors.
|
||||
//
|
||||
// egress-guard-check --mode block|log
|
||||
#include <arpa/inet.h>
|
||||
#include <dlfcn.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <mach-o/dyld.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
// TEST-NET-1 (RFC 5737): never routed, so without the guard a non-blocking
|
||||
// connect reports EINPROGRESS and nothing ever answers.
|
||||
#define PROBE_ADDRESS "192.0.2.1"
|
||||
#define PROBE_PORT 9
|
||||
|
||||
typedef int (*connect_fn)(int, const struct sockaddr *, socklen_t);
|
||||
|
||||
static int guard_loaded(void) {
|
||||
for (uint32_t i = 0; i < _dyld_image_count(); i++) {
|
||||
const char *name = _dyld_get_image_name(i);
|
||||
if (name != NULL && strstr(name, "egress-guard.dylib") != NULL) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// One non-blocking connect to the probe address through `entry`, with the
|
||||
// sockaddr family the caller wants. Returns the connect result; errno is
|
||||
// stored in *err.
|
||||
static int probe(connect_fn entry, sa_family_t family, int *err) {
|
||||
int fd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (fd < 0) {
|
||||
*err = errno;
|
||||
return -2;
|
||||
}
|
||||
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK);
|
||||
struct sockaddr_in to;
|
||||
memset(&to, 0, sizeof to);
|
||||
to.sin_family = family;
|
||||
to.sin_len = sizeof to;
|
||||
to.sin_port = htons(PROBE_PORT);
|
||||
inet_pton(AF_INET, PROBE_ADDRESS, &to.sin_addr);
|
||||
int rc = entry(fd, (struct sockaddr *)&to, sizeof to);
|
||||
*err = errno;
|
||||
close(fd);
|
||||
return rc;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
const char *mode = "block";
|
||||
if (argc == 3 && strcmp(argv[1], "--mode") == 0) {
|
||||
mode = argv[2];
|
||||
} else if (argc != 1) {
|
||||
fprintf(stderr, "usage: egress-guard-check [--mode block|log]\n");
|
||||
return 1;
|
||||
}
|
||||
if (!guard_loaded()) {
|
||||
printf("egress-guard-check: the guard library is not loaded in this process\n");
|
||||
return 2;
|
||||
}
|
||||
connect_fn connect_nocancel = (connect_fn)dlsym(RTLD_DEFAULT, "connect$NOCANCEL");
|
||||
if (connect_nocancel == NULL) {
|
||||
printf("egress-guard-check: connect$NOCANCEL is not exported in this process\n");
|
||||
return 3;
|
||||
}
|
||||
|
||||
// The plain call, the non-cancelable variant, and a sockaddr whose family
|
||||
// was left AF_UNSPEC, which the kernel connects as IPv4.
|
||||
const struct {
|
||||
const char *name;
|
||||
connect_fn entry;
|
||||
sa_family_t family;
|
||||
} probes[] = {
|
||||
{"connect", connect, AF_INET},
|
||||
{"connect$NOCANCEL", connect_nocancel, AF_INET},
|
||||
{"connect with AF_UNSPEC", connect, AF_UNSPEC},
|
||||
};
|
||||
int block = strcmp(mode, "block") == 0;
|
||||
for (size_t i = 0; i < sizeof probes / sizeof probes[0]; i++) {
|
||||
int err = 0;
|
||||
int rc = probe(probes[i].entry, probes[i].family, &err);
|
||||
if (rc == -2) {
|
||||
printf("egress-guard-check: socket() failed: %s\n", strerror(err));
|
||||
return 3;
|
||||
}
|
||||
if (block && !(rc == -1 && err == ECONNREFUSED)) {
|
||||
printf("egress-guard-check: guard loaded but a non-loopback %s was not refused (rc=%d, errno=%d %s)\n",
|
||||
probes[i].name, rc, err, strerror(err));
|
||||
return 3;
|
||||
}
|
||||
if (!block && !(rc == 0 || (rc == -1 && err == EINPROGRESS))) {
|
||||
printf("egress-guard-check: guard loaded in log mode but %s failed (errno=%d %s)\n",
|
||||
probes[i].name, err, strerror(err));
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
if (block) {
|
||||
printf("egress-guard-check: guard loaded; non-loopback connections are refused\n");
|
||||
} else {
|
||||
printf("egress-guard-check: guard loaded in log mode; connections pass through\n");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
// The local egress guard: interposes the calls that open outbound traffic in
|
||||
// every simulator process it is inserted into, refuses non-loopback
|
||||
// destinations in block mode, and records one event per destination per
|
||||
// process. See README.md and policy.h.
|
||||
#include "policy.h"
|
||||
|
||||
#include <dlfcn.h>
|
||||
#include <errno.h>
|
||||
#include <execinfo.h>
|
||||
#include <fcntl.h>
|
||||
#include <os/lock.h>
|
||||
#include <sched.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define EG_LOG_ENV "EAS_EGRESS_GUARD_LOG"
|
||||
#define EG_MODE_ENV "EAS_EGRESS_GUARD_MODE"
|
||||
#define EG_MAX_CALLERS 6
|
||||
#define EG_CALLER_LENGTH 64
|
||||
#define EG_LOCK_ATTEMPTS 64
|
||||
|
||||
static int eg_initialized = 0;
|
||||
static void *eg_own_image = NULL;
|
||||
static char eg_log_path[1024];
|
||||
static eg_mode_t eg_mode = EG_MODE_BLOCK;
|
||||
static eg_seen_t eg_seen;
|
||||
static int eg_overflow_reported = 0;
|
||||
static os_unfair_lock eg_lock = OS_UNFAIR_LOCK_INIT;
|
||||
|
||||
static void eg_init(void) {
|
||||
if (eg_initialized) {
|
||||
return;
|
||||
}
|
||||
eg_mode = eg_parse_mode(getenv(EG_MODE_ENV));
|
||||
Dl_info self;
|
||||
if (dladdr((const void *)eg_init, &self)) {
|
||||
eg_own_image = self.dli_fbase;
|
||||
}
|
||||
const char *log_path = getenv(EG_LOG_ENV);
|
||||
// The path is kept rather than an open descriptor: a process that closes
|
||||
// descriptors it does not know about (launchd_sim does) would otherwise
|
||||
// leave the guard writing into whatever file reused the number.
|
||||
if (log_path != NULL && log_path[0] != '\0' && strlen(log_path) < sizeof eg_log_path) {
|
||||
strcpy(eg_log_path, log_path);
|
||||
}
|
||||
eg_initialized = 1;
|
||||
}
|
||||
|
||||
__attribute__((constructor)) static void eg_constructor(void) { eg_init(); }
|
||||
|
||||
// Image names of the frames above the guard's own, innermost first, with
|
||||
// consecutive repeats collapsed: "Network,CFNetwork,MyApp".
|
||||
__attribute__((noinline)) static int eg_collect_callers(char names[EG_MAX_CALLERS][EG_CALLER_LENGTH]) {
|
||||
void *frames[EG_MAX_CALLERS + 6];
|
||||
int frame_count = backtrace(frames, EG_MAX_CALLERS + 6);
|
||||
int count = 0;
|
||||
for (int i = 1; i < frame_count && count < EG_MAX_CALLERS; i++) {
|
||||
Dl_info info;
|
||||
const char *image = "?";
|
||||
if (dladdr(frames[i], &info)) {
|
||||
if (info.dli_fbase == eg_own_image) {
|
||||
continue;
|
||||
}
|
||||
if (info.dli_fname != NULL) {
|
||||
const char *slash = strrchr(info.dli_fname, '/');
|
||||
image = slash ? slash + 1 : info.dli_fname;
|
||||
}
|
||||
}
|
||||
if (count > 0 && strcmp(names[count - 1], image) == 0) {
|
||||
continue;
|
||||
}
|
||||
strncpy(names[count], image, EG_CALLER_LENGTH - 1);
|
||||
names[count][EG_CALLER_LENGTH - 1] = '\0';
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// A lock held by this same thread (a signal handler making a socket call
|
||||
// while the interrupted call held it) or by a thread that did not survive
|
||||
// fork() never becomes available and os_unfair_lock_lock aborts the process
|
||||
// in both cases. Give up on recording the event instead; refusal does not
|
||||
// depend on it. Ordinary contention over the microsecond-long critical
|
||||
// section resolves within the first attempts.
|
||||
static int eg_lock_bounded(void) {
|
||||
for (int i = 0; i < EG_LOCK_ATTEMPTS; i++) {
|
||||
if (os_unfair_lock_trylock(&eg_lock)) {
|
||||
return 1;
|
||||
}
|
||||
sched_yield();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// One line in the event log. O_APPEND keeps whole lines intact across
|
||||
// processes writing concurrently; the log is opened per event, at most
|
||||
// EG_SEEN_CAPACITY + 1 times per process.
|
||||
__attribute__((noinline)) static void eg_write_event(const char *function, const char *action,
|
||||
const char *peer, int with_callers) {
|
||||
if (eg_log_path[0] == '\0') {
|
||||
return;
|
||||
}
|
||||
char names[EG_MAX_CALLERS][EG_CALLER_LENGTH];
|
||||
const char *callers[EG_MAX_CALLERS];
|
||||
int caller_count = with_callers ? eg_collect_callers(names) : 0;
|
||||
for (int i = 0; i < caller_count; i++) {
|
||||
callers[i] = names[i];
|
||||
}
|
||||
char line[1024];
|
||||
int n = eg_format_event(line, sizeof line, getprogname(), getpid(), function, action, peer,
|
||||
callers, caller_count);
|
||||
if (n <= 0) {
|
||||
return;
|
||||
}
|
||||
int fd = open(eg_log_path, O_WRONLY | O_APPEND | O_CREAT | O_CLOEXEC, 0644);
|
||||
if (fd >= 0) {
|
||||
(void)write(fd, line, (size_t)n);
|
||||
close(fd);
|
||||
}
|
||||
}
|
||||
|
||||
// Returns 1 when the call must be refused.
|
||||
__attribute__((noinline)) static int eg_handle(const char *function, const struct sockaddr *sa,
|
||||
socklen_t len) {
|
||||
eg_class_t cls = eg_classify(sa, len);
|
||||
if (cls != EG_REMOTE) {
|
||||
return 0;
|
||||
}
|
||||
eg_init();
|
||||
int deny = eg_should_deny(eg_mode, cls);
|
||||
|
||||
char peer[96];
|
||||
if (eg_format_peer(sa, len, peer, sizeof peer) != 0) {
|
||||
strncpy(peer, "?", sizeof peer);
|
||||
}
|
||||
char key[EG_SEEN_KEY_LENGTH];
|
||||
snprintf(key, sizeof key, "%s %s", function, peer);
|
||||
int fresh = 0;
|
||||
int overflowed = 0;
|
||||
if (eg_lock_bounded()) {
|
||||
fresh = eg_seen_insert(&eg_seen, key);
|
||||
if (!fresh && eg_seen.overflow > 0 && !eg_overflow_reported) {
|
||||
eg_overflow_reported = 1;
|
||||
overflowed = 1;
|
||||
}
|
||||
os_unfair_lock_unlock(&eg_lock);
|
||||
}
|
||||
|
||||
const char *action = deny ? "blocked" : "logged";
|
||||
if (fresh) {
|
||||
eg_write_event(function, action, peer, 1);
|
||||
} else if (overflowed) {
|
||||
// Once per process: the table is full, so further distinct destinations
|
||||
// are still refused but no longer listed.
|
||||
char limit[64];
|
||||
snprintf(limit, sizeof limit, "%d distinct destinations", EG_SEEN_CAPACITY);
|
||||
eg_write_event(EG_OVERFLOW_FUNCTION, action, limit, 0);
|
||||
}
|
||||
return deny;
|
||||
}
|
||||
|
||||
// connect() with an AF_UNSPEC address on a datagram socket dissolves the
|
||||
// association and sends nothing; the kernel answers EAFNOSUPPORT. Leave that
|
||||
// to the kernel. On a stream socket the kernel connects to the address the
|
||||
// length implies, which eg_handle classifies.
|
||||
static int eg_dissolves_association(int fd, const struct sockaddr *sa, socklen_t len) {
|
||||
if (sa == NULL || len < 2 || sa->sa_family != AF_UNSPEC) {
|
||||
return 0;
|
||||
}
|
||||
int type = 0;
|
||||
socklen_t type_len = sizeof type;
|
||||
return getsockopt(fd, SOL_SOCKET, SO_TYPE, &type, &type_len) == 0 && type == SOCK_DGRAM;
|
||||
}
|
||||
|
||||
typedef int (*eg_connect_fn)(int, const struct sockaddr *, socklen_t);
|
||||
typedef ssize_t (*eg_sendto_fn)(int, const void *, size_t, int, const struct sockaddr *,
|
||||
socklen_t);
|
||||
typedef ssize_t (*eg_sendmsg_fn)(int, const struct msghdr *, int);
|
||||
|
||||
// The non-cancelable variants libsystem_kernel exports next to the cancelable
|
||||
// ones; dyld interposing is per symbol, so each needs its own entry.
|
||||
extern int eg_connect_nocancel_original(int, const struct sockaddr *, socklen_t) __asm__(
|
||||
"_connect$NOCANCEL");
|
||||
extern ssize_t eg_sendto_nocancel_original(int, const void *, size_t, int,
|
||||
const struct sockaddr *, socklen_t) __asm__(
|
||||
"_sendto$NOCANCEL");
|
||||
extern ssize_t eg_sendmsg_nocancel_original(int, const struct msghdr *, int) __asm__(
|
||||
"_sendmsg$NOCANCEL");
|
||||
|
||||
static int eg_connect_through(eg_connect_fn original, int fd, const struct sockaddr *sa,
|
||||
socklen_t len) {
|
||||
if (!eg_dissolves_association(fd, sa, len) && eg_handle("connect", sa, len)) {
|
||||
errno = ECONNREFUSED;
|
||||
return -1;
|
||||
}
|
||||
return original(fd, sa, len);
|
||||
}
|
||||
|
||||
static int eg_connect(int fd, const struct sockaddr *sa, socklen_t len) {
|
||||
return eg_connect_through(connect, fd, sa, len);
|
||||
}
|
||||
|
||||
static int eg_connect_nocancel(int fd, const struct sockaddr *sa, socklen_t len) {
|
||||
return eg_connect_through(eg_connect_nocancel_original, fd, sa, len);
|
||||
}
|
||||
|
||||
static int eg_connectx(int s, const sa_endpoints_t *endpoints, sae_associd_t associd,
|
||||
unsigned int flags, const struct iovec *iov, unsigned int iovcnt,
|
||||
size_t *len, sae_connid_t *connid) {
|
||||
if (endpoints != NULL && endpoints->sae_dstaddr != NULL &&
|
||||
!eg_dissolves_association(s, endpoints->sae_dstaddr, endpoints->sae_dstaddrlen) &&
|
||||
eg_handle("connectx", endpoints->sae_dstaddr, endpoints->sae_dstaddrlen)) {
|
||||
errno = ECONNREFUSED;
|
||||
return -1;
|
||||
}
|
||||
return connectx(s, endpoints, associd, flags, iov, iovcnt, len, connid);
|
||||
}
|
||||
|
||||
static ssize_t eg_sendto_through(eg_sendto_fn original, int fd, const void *buf, size_t n,
|
||||
int flags, const struct sockaddr *sa, socklen_t len) {
|
||||
if (sa != NULL && eg_handle("sendto", sa, len)) {
|
||||
errno = ECONNREFUSED;
|
||||
return -1;
|
||||
}
|
||||
return original(fd, buf, n, flags, sa, len);
|
||||
}
|
||||
|
||||
static ssize_t eg_sendto(int fd, const void *buf, size_t n, int flags, const struct sockaddr *sa,
|
||||
socklen_t len) {
|
||||
return eg_sendto_through(sendto, fd, buf, n, flags, sa, len);
|
||||
}
|
||||
|
||||
static ssize_t eg_sendto_nocancel(int fd, const void *buf, size_t n, int flags,
|
||||
const struct sockaddr *sa, socklen_t len) {
|
||||
return eg_sendto_through(eg_sendto_nocancel_original, fd, buf, n, flags, sa, len);
|
||||
}
|
||||
|
||||
static ssize_t eg_sendmsg_through(eg_sendmsg_fn original, int fd, const struct msghdr *msg,
|
||||
int flags) {
|
||||
if (msg != NULL && msg->msg_name != NULL && msg->msg_namelen > 0 &&
|
||||
eg_handle("sendmsg", (const struct sockaddr *)msg->msg_name, msg->msg_namelen)) {
|
||||
errno = ECONNREFUSED;
|
||||
return -1;
|
||||
}
|
||||
return original(fd, msg, flags);
|
||||
}
|
||||
|
||||
static ssize_t eg_sendmsg(int fd, const struct msghdr *msg, int flags) {
|
||||
return eg_sendmsg_through(sendmsg, fd, msg, flags);
|
||||
}
|
||||
|
||||
static ssize_t eg_sendmsg_nocancel(int fd, const struct msghdr *msg, int flags) {
|
||||
return eg_sendmsg_through(eg_sendmsg_nocancel_original, fd, msg, flags);
|
||||
}
|
||||
|
||||
// dyld applies these to every image in the process, including the shared
|
||||
// cache, which is how calls made inside CFNetwork and Network.framework are
|
||||
// caught.
|
||||
__attribute__((used)) static const struct {
|
||||
const void *replacement;
|
||||
const void *original;
|
||||
} eg_interposers[] __attribute__((section("__DATA,__interpose"))) = {
|
||||
{(const void *)eg_connect, (const void *)connect},
|
||||
{(const void *)eg_connect_nocancel, (const void *)eg_connect_nocancel_original},
|
||||
{(const void *)eg_connectx, (const void *)connectx},
|
||||
{(const void *)eg_sendto, (const void *)sendto},
|
||||
{(const void *)eg_sendto_nocancel, (const void *)eg_sendto_nocancel_original},
|
||||
{(const void *)eg_sendmsg, (const void *)sendmsg},
|
||||
{(const void *)eg_sendmsg_nocancel, (const void *)eg_sendmsg_nocancel_original},
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
#include "policy.h"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static int is_v4_local(uint32_t host_order) {
|
||||
return (host_order >> 24) == 127 || host_order == 0;
|
||||
}
|
||||
|
||||
// XNU accepts a sockaddr whose family is AF_UNSPEC as the family its length
|
||||
// implies: TCP connect() translates it on both socket families and IPv4
|
||||
// sendto()/sendmsg() deliver with it. Classify it the way the kernel uses it.
|
||||
static sa_family_t eg_family(const struct sockaddr *sa, socklen_t len) {
|
||||
if (sa->sa_family == AF_UNSPEC) {
|
||||
if (len == sizeof(struct sockaddr_in)) {
|
||||
return AF_INET;
|
||||
}
|
||||
if (len == sizeof(struct sockaddr_in6)) {
|
||||
return AF_INET6;
|
||||
}
|
||||
}
|
||||
return sa->sa_family;
|
||||
}
|
||||
|
||||
eg_class_t eg_classify(const struct sockaddr *sa, socklen_t len) {
|
||||
if (sa == NULL || len < 2) {
|
||||
return EG_PASSTHROUGH;
|
||||
}
|
||||
sa_family_t family = eg_family(sa, len);
|
||||
if (family == AF_INET) {
|
||||
if (len < sizeof(struct sockaddr_in)) {
|
||||
return EG_PASSTHROUGH;
|
||||
}
|
||||
const struct sockaddr_in *in = (const struct sockaddr_in *)sa;
|
||||
return is_v4_local(ntohl(in->sin_addr.s_addr)) ? EG_LOOPBACK : EG_REMOTE;
|
||||
}
|
||||
if (family == AF_INET6) {
|
||||
if (len < sizeof(struct sockaddr_in6)) {
|
||||
return EG_PASSTHROUGH;
|
||||
}
|
||||
const struct in6_addr *a6 = &((const struct sockaddr_in6 *)sa)->sin6_addr;
|
||||
if (IN6_IS_ADDR_LOOPBACK(a6) || IN6_IS_ADDR_UNSPECIFIED(a6)) {
|
||||
return EG_LOOPBACK;
|
||||
}
|
||||
if (IN6_IS_ADDR_V4MAPPED(a6)) {
|
||||
uint32_t v4;
|
||||
memcpy(&v4, &a6->s6_addr[12], sizeof v4);
|
||||
return is_v4_local(ntohl(v4)) ? EG_LOOPBACK : EG_REMOTE;
|
||||
}
|
||||
return EG_REMOTE;
|
||||
}
|
||||
return EG_PASSTHROUGH;
|
||||
}
|
||||
|
||||
eg_mode_t eg_parse_mode(const char *value) {
|
||||
if (value != NULL && strcmp(value, "log") == 0) {
|
||||
return EG_MODE_LOG;
|
||||
}
|
||||
return EG_MODE_BLOCK;
|
||||
}
|
||||
|
||||
int eg_should_deny(eg_mode_t mode, eg_class_t cls) {
|
||||
return mode == EG_MODE_BLOCK && cls == EG_REMOTE;
|
||||
}
|
||||
|
||||
int eg_format_peer(const struct sockaddr *sa, socklen_t len, char *out, size_t n) {
|
||||
char ip[INET6_ADDRSTRLEN];
|
||||
int written;
|
||||
if (sa == NULL || len < 2) {
|
||||
return -1;
|
||||
}
|
||||
sa_family_t family = eg_family(sa, len);
|
||||
if (family == AF_INET && len >= sizeof(struct sockaddr_in)) {
|
||||
const struct sockaddr_in *in = (const struct sockaddr_in *)sa;
|
||||
if (inet_ntop(AF_INET, &in->sin_addr, ip, sizeof ip) == NULL) {
|
||||
return -1;
|
||||
}
|
||||
written = snprintf(out, n, "%s:%d", ip, ntohs(in->sin_port));
|
||||
} else if (family == AF_INET6 && len >= sizeof(struct sockaddr_in6)) {
|
||||
const struct sockaddr_in6 *in6 = (const struct sockaddr_in6 *)sa;
|
||||
if (inet_ntop(AF_INET6, &in6->sin6_addr, ip, sizeof ip) == NULL) {
|
||||
return -1;
|
||||
}
|
||||
written = snprintf(out, n, "[%s]:%d", ip, ntohs(in6->sin6_port));
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
return (written < 0 || (size_t)written >= n) ? -1 : 0;
|
||||
}
|
||||
|
||||
int eg_seen_insert(eg_seen_t *seen, const char *key) {
|
||||
for (int i = 0; i < seen->count; i++) {
|
||||
if (strncmp(seen->keys[i], key, EG_SEEN_KEY_LENGTH - 1) == 0) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
if (seen->count >= EG_SEEN_CAPACITY) {
|
||||
seen->overflow++;
|
||||
return 0;
|
||||
}
|
||||
strncpy(seen->keys[seen->count], key, EG_SEEN_KEY_LENGTH - 1);
|
||||
seen->keys[seen->count][EG_SEEN_KEY_LENGTH - 1] = '\0';
|
||||
seen->count++;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Append `text` to out[*pos], replacing field separators; returns 0 when it fit.
|
||||
static int append_field(char *out, size_t n, size_t *pos, const char *text) {
|
||||
for (const char *c = text; *c; c++) {
|
||||
if (*pos + 1 >= n) {
|
||||
return -1;
|
||||
}
|
||||
out[(*pos)++] = (*c == '\t' || *c == '\n' || *c == '\r') ? ' ' : *c;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int append_raw(char *out, size_t n, size_t *pos, const char *text) {
|
||||
size_t len = strlen(text);
|
||||
if (*pos + len + 1 > n) {
|
||||
return -1;
|
||||
}
|
||||
memcpy(out + *pos, text, len);
|
||||
*pos += len;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int eg_format_event(char *out, size_t n, const char *progname, int pid, const char *function,
|
||||
const char *action, const char *peer, const char *const *callers,
|
||||
int caller_count) {
|
||||
size_t pos = 0;
|
||||
char pid_text[16];
|
||||
snprintf(pid_text, sizeof pid_text, "%d", pid);
|
||||
if (append_raw(out, n, &pos, EG_EVENT_PREFIX "\t") != 0 ||
|
||||
append_field(out, n, &pos, progname ? progname : "?") != 0 ||
|
||||
append_raw(out, n, &pos, "\t") != 0 || append_raw(out, n, &pos, pid_text) != 0 ||
|
||||
append_raw(out, n, &pos, "\t") != 0 || append_field(out, n, &pos, function) != 0 ||
|
||||
append_raw(out, n, &pos, "\t") != 0 || append_field(out, n, &pos, action) != 0 ||
|
||||
append_raw(out, n, &pos, "\t") != 0 || append_field(out, n, &pos, peer) != 0 ||
|
||||
append_raw(out, n, &pos, "\t") != 0) {
|
||||
return -1;
|
||||
}
|
||||
for (int i = 0; i < caller_count && callers != NULL; i++) {
|
||||
if (i > 0 && append_raw(out, n, &pos, ",") != 0) {
|
||||
return -1;
|
||||
}
|
||||
// Commas separate callers, so they cannot appear inside one.
|
||||
for (const char *c = callers[i]; *c; c++) {
|
||||
char ch = (*c == ',' || *c == '\t' || *c == '\n' || *c == '\r') ? ' ' : *c;
|
||||
if (pos + 1 >= n) {
|
||||
return -1;
|
||||
}
|
||||
out[pos++] = ch;
|
||||
}
|
||||
}
|
||||
if (append_raw(out, n, &pos, "\n") != 0) {
|
||||
return -1;
|
||||
}
|
||||
out[pos] = '\0';
|
||||
return (int)pos;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Policy and formatting for the local egress guard. Pure functions with no
|
||||
// interposing, so they compile and test on macOS as well as in the simulator.
|
||||
#ifndef EAS_EGRESS_GUARD_POLICY_H
|
||||
#define EAS_EGRESS_GUARD_POLICY_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <sys/socket.h>
|
||||
|
||||
// What a destination address is, as far as the guard cares.
|
||||
typedef enum {
|
||||
EG_PASSTHROUGH = 0, // not an internet address (unix sockets, NULL, short); never touched
|
||||
EG_LOOPBACK = 1, // 127.0.0.0/8, ::1, ::ffff:127.x, unspecified; the proxy and forwards live here
|
||||
EG_REMOTE = 2, // anything else, including link-local and multicast
|
||||
} eg_class_t;
|
||||
|
||||
typedef enum {
|
||||
EG_MODE_BLOCK = 0, // refuse EG_REMOTE with ECONNREFUSED
|
||||
EG_MODE_LOG = 1, // observe only
|
||||
} eg_mode_t;
|
||||
|
||||
eg_class_t eg_classify(const struct sockaddr *sa, socklen_t len);
|
||||
|
||||
// "block" or unset selects block; "log" selects log. Anything else is block:
|
||||
// an unknown mode must fail closed.
|
||||
eg_mode_t eg_parse_mode(const char *value);
|
||||
|
||||
int eg_should_deny(eg_mode_t mode, eg_class_t cls);
|
||||
|
||||
// Writes "1.2.3.4:443" or "[2001:db8::1]:443". Returns 0 on success.
|
||||
int eg_format_peer(const struct sockaddr *sa, socklen_t len, char *out, size_t n);
|
||||
|
||||
// Fixed-capacity set of strings, so a retrying client logs a destination once
|
||||
// per process instead of once per attempt.
|
||||
#define EG_SEEN_CAPACITY 128
|
||||
#define EG_SEEN_KEY_LENGTH 192
|
||||
typedef struct {
|
||||
char keys[EG_SEEN_CAPACITY][EG_SEEN_KEY_LENGTH];
|
||||
int count;
|
||||
int overflow; // insertions refused because the table was full
|
||||
} eg_seen_t;
|
||||
|
||||
// 1 when the key was not present and was inserted; 0 when already present or
|
||||
// when the table is full (counted in overflow).
|
||||
int eg_seen_insert(eg_seen_t *seen, const char *key);
|
||||
|
||||
#define EG_EVENT_PREFIX "eas-egress-guard"
|
||||
|
||||
// Function name of the one event a process writes when its seen table fills:
|
||||
// from then on distinct destinations are still refused but no longer listed.
|
||||
#define EG_OVERFLOW_FUNCTION "overflow"
|
||||
|
||||
// One tab-separated line, newline terminated:
|
||||
// eas-egress-guard\t<progname>\t<pid>\t<function>\t<action>\t<peer>\t<caller>,<caller>...\n
|
||||
// Tabs and newlines inside fields are replaced with spaces. Returns the length
|
||||
// written, or -1 when it does not fit.
|
||||
int eg_format_event(char *out, size_t n, const char *progname, int pid, const char *function,
|
||||
const char *action, const char *peer, const char *const *callers,
|
||||
int caller_count);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,291 @@
|
||||
// Black-box host tests of the built guard, loaded through
|
||||
// DYLD_INSERT_LIBRARIES like the simulator does. Remote calls use fd -1 or are
|
||||
// refused before the kernel; the only real sockets are on loopback. Build and
|
||||
// run with tests/run-guard-tests.sh.
|
||||
//
|
||||
// guard-insert-test <case> <log-path> [<extra-path>]
|
||||
#include <arpa/inet.h>
|
||||
#include <dlfcn.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/uio.h>
|
||||
#include <unistd.h>
|
||||
|
||||
typedef int (*connect_fn)(int, const struct sockaddr *, socklen_t);
|
||||
typedef ssize_t (*sendto_fn)(int, const void *, size_t, int, const struct sockaddr *, socklen_t);
|
||||
typedef ssize_t (*sendmsg_fn)(int, const struct msghdr *, int);
|
||||
|
||||
static struct sockaddr_in remote(int port) {
|
||||
struct sockaddr_in a;
|
||||
memset(&a, 0, sizeof a);
|
||||
a.sin_family = AF_INET;
|
||||
a.sin_len = sizeof a;
|
||||
a.sin_port = htons(port);
|
||||
inet_pton(AF_INET, "192.0.2.1", &a.sin_addr);
|
||||
return a;
|
||||
}
|
||||
|
||||
static struct sockaddr_in6 remote6(int port) {
|
||||
struct sockaddr_in6 a;
|
||||
memset(&a, 0, sizeof a);
|
||||
a.sin6_family = AF_INET6;
|
||||
a.sin6_len = sizeof a;
|
||||
a.sin6_port = htons(port);
|
||||
inet_pton(AF_INET6, "2001:db8::1", &a.sin6_addr);
|
||||
return a;
|
||||
}
|
||||
|
||||
static int refused_sendto(int port) {
|
||||
struct sockaddr_in to = remote(port);
|
||||
errno = 0;
|
||||
return sendto(-1, "x", 1, 0, (struct sockaddr *)&to, sizeof to) == -1 && errno == ECONNREFUSED;
|
||||
}
|
||||
|
||||
static off_t file_size(const char *path) {
|
||||
struct stat st;
|
||||
return stat(path, &st) == 0 ? st.st_size : -1;
|
||||
}
|
||||
|
||||
static int count_lines(const char *path, const char *needle) {
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) {
|
||||
return -1;
|
||||
}
|
||||
int count = 0;
|
||||
char line[1024];
|
||||
while (fgets(line, sizeof line, f)) {
|
||||
if (strstr(line, needle)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
return count;
|
||||
}
|
||||
|
||||
// Daemon-style cleanup: close every descriptor the process does not know
|
||||
// about, then open an application file. The guard's event must land in the
|
||||
// event log, not in that file.
|
||||
static int fd_reuse(const char *log_path, const char *app_path) {
|
||||
for (int fd = 3; fd < getdtablesize(); fd++) {
|
||||
close(fd);
|
||||
}
|
||||
int app_fd = open(app_path, O_CREAT | O_TRUNC | O_WRONLY, 0600);
|
||||
if (app_fd < 0 || !refused_sendto(9)) {
|
||||
return 2;
|
||||
}
|
||||
close(app_fd);
|
||||
if (file_size(app_path) != 0) {
|
||||
printf("FAIL fd-reuse: %lld bytes written into an unrelated file\n",
|
||||
(long long)file_size(app_path));
|
||||
return 1;
|
||||
}
|
||||
if (count_lines(log_path, "\tsendto\tblocked\t192.0.2.1:9\t") != 1) {
|
||||
printf("FAIL fd-reuse: event missing from the event log\n");
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Started with stdout closed, as the runner arranges: the process's own stdout
|
||||
// output must not end up in the event log, and the event must.
|
||||
static int low_fd(const char *log_path) {
|
||||
printf("STDOUT-LINE\n");
|
||||
fflush(stdout);
|
||||
if (!refused_sendto(9)) {
|
||||
return 2;
|
||||
}
|
||||
if (count_lines(log_path, "STDOUT-LINE") != 0) {
|
||||
return 1;
|
||||
}
|
||||
return count_lines(log_path, "\tsendto\tblocked\t192.0.2.1:9\t") == 1 ? 0 : 1;
|
||||
}
|
||||
|
||||
static int nocancel(void) {
|
||||
connect_fn nc_connect = (connect_fn)dlsym(RTLD_DEFAULT, "connect$NOCANCEL");
|
||||
sendto_fn nc_sendto = (sendto_fn)dlsym(RTLD_DEFAULT, "sendto$NOCANCEL");
|
||||
sendmsg_fn nc_sendmsg = (sendmsg_fn)dlsym(RTLD_DEFAULT, "sendmsg$NOCANCEL");
|
||||
if (!nc_connect || !nc_sendto || !nc_sendmsg) {
|
||||
return 2;
|
||||
}
|
||||
struct sockaddr_in to = remote(443);
|
||||
int failures = 0;
|
||||
errno = 0;
|
||||
if (nc_connect(-1, (struct sockaddr *)&to, sizeof to) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL nocancel: connect$NOCANCEL not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
errno = 0;
|
||||
if (nc_sendto(-1, "x", 1, 0, (struct sockaddr *)&to, sizeof to) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL nocancel: sendto$NOCANCEL not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
char payload[] = "x";
|
||||
struct iovec iov = {.iov_base = payload, .iov_len = 1};
|
||||
struct msghdr msg = {.msg_name = &to, .msg_namelen = sizeof to, .msg_iov = &iov, .msg_iovlen = 1};
|
||||
errno = 0;
|
||||
if (nc_sendmsg(-1, &msg, 0) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL nocancel: sendmsg$NOCANCEL not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
|
||||
// The shapes the kernel accepts as IPv4/IPv6 despite sa_family == AF_UNSPEC.
|
||||
static int unspec(void) {
|
||||
int failures = 0;
|
||||
struct sockaddr_in to = remote(443);
|
||||
to.sin_family = AF_UNSPEC;
|
||||
int tcp = socket(AF_INET, SOCK_STREAM, 0);
|
||||
errno = 0;
|
||||
if (connect(tcp, (struct sockaddr *)&to, sizeof to) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL unspec: TCP connect with AF_UNSPEC not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
close(tcp);
|
||||
struct sockaddr_in6 to6 = remote6(443);
|
||||
to6.sin6_family = AF_UNSPEC;
|
||||
int tcp6 = socket(AF_INET6, SOCK_STREAM, 0);
|
||||
errno = 0;
|
||||
if (connect(tcp6, (struct sockaddr *)&to6, sizeof to6) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL unspec: TCP6 connect with AF_UNSPEC not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
close(tcp6);
|
||||
errno = 0;
|
||||
if (sendto(-1, "x", 1, 0, (struct sockaddr *)&to, sizeof to) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL unspec: sendto with AF_UNSPEC not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
char payload[] = "x";
|
||||
struct iovec iov = {.iov_base = payload, .iov_len = 1};
|
||||
struct msghdr msg = {.msg_name = &to, .msg_namelen = sizeof to, .msg_iov = &iov, .msg_iovlen = 1};
|
||||
errno = 0;
|
||||
if (sendmsg(-1, &msg, 0) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL unspec: sendmsg with AF_UNSPEC not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
|
||||
// A UDP socket associated with loopback dissolves the association through
|
||||
// connect(AF_UNSPEC), whatever address bytes follow the family. The guard
|
||||
// must let the kernel do that.
|
||||
static int udp_disconnect(void) {
|
||||
int fd = socket(AF_INET, SOCK_DGRAM, 0);
|
||||
struct sockaddr_in loopback;
|
||||
memset(&loopback, 0, sizeof loopback);
|
||||
loopback.sin_family = AF_INET;
|
||||
loopback.sin_len = sizeof loopback;
|
||||
loopback.sin_port = htons(9);
|
||||
loopback.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
if (connect(fd, (struct sockaddr *)&loopback, sizeof loopback) != 0) {
|
||||
return 2;
|
||||
}
|
||||
struct sockaddr_in peer;
|
||||
socklen_t peer_len = sizeof peer;
|
||||
if (getpeername(fd, (struct sockaddr *)&peer, &peer_len) != 0) {
|
||||
return 2;
|
||||
}
|
||||
int failures = 0;
|
||||
for (int stale = 0; stale < 2; stale++) {
|
||||
struct sockaddr_in dissolve = stale ? remote(443) : loopback;
|
||||
if (!stale) {
|
||||
memset(&dissolve, 0, sizeof dissolve);
|
||||
dissolve.sin_len = sizeof dissolve;
|
||||
}
|
||||
dissolve.sin_family = AF_UNSPEC;
|
||||
if (connect(fd, (struct sockaddr *)&loopback, sizeof loopback) != 0) {
|
||||
return 2;
|
||||
}
|
||||
errno = 0;
|
||||
int rc = connect(fd, (struct sockaddr *)&dissolve, sizeof dissolve);
|
||||
int err = errno;
|
||||
peer_len = sizeof peer;
|
||||
int still_connected = getpeername(fd, (struct sockaddr *)&peer, &peer_len) == 0;
|
||||
if (rc != -1 || err != EAFNOSUPPORT || still_connected) {
|
||||
printf("FAIL udp-disconnect (%s address): rc=%d errno=%d still_connected=%d\n",
|
||||
stale ? "stale remote" : "zeroed", rc, err, still_connected);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
close(fd);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
|
||||
// Past 128 distinct destinations a process writes one overflow line and
|
||||
// nothing more; refusal continues.
|
||||
static int overflow(const char *log_path) {
|
||||
for (int port = 1; port <= 140; port++) {
|
||||
if (!refused_sendto(port)) {
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
int listed = count_lines(log_path, "\tsendto\tblocked\t192.0.2.1:");
|
||||
int overflow_lines = count_lines(log_path, "\toverflow\tblocked\t128 distinct destinations\t");
|
||||
if (listed != 128 || overflow_lines != 1) {
|
||||
printf("FAIL overflow: %d destinations listed, %d overflow line(s)\n", listed, overflow_lines);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The event names the images above the guard, not the guard itself.
|
||||
static int callers(const char *log_path) {
|
||||
if (!refused_sendto(9)) {
|
||||
return 2;
|
||||
}
|
||||
FILE *f = fopen(log_path, "r");
|
||||
if (f == NULL) {
|
||||
return 2;
|
||||
}
|
||||
char line[1024];
|
||||
int ok = 0;
|
||||
while (fgets(line, sizeof line, f)) {
|
||||
if (strstr(line, "\tsendto\tblocked\t192.0.2.1:9\t")) {
|
||||
const char *callers_field = strrchr(line, '\t') + 1;
|
||||
ok = strstr(callers_field, "guard-insert-test") != NULL &&
|
||||
strstr(callers_field, "egress-guard") == NULL;
|
||||
if (!ok) {
|
||||
printf("FAIL callers: %s", line);
|
||||
}
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc < 3) {
|
||||
return 2;
|
||||
}
|
||||
const char *name = argv[1];
|
||||
const char *log_path = argv[2];
|
||||
if (strcmp(name, "fd-reuse") == 0) {
|
||||
return argc == 4 ? fd_reuse(log_path, argv[3]) : 2;
|
||||
}
|
||||
if (strcmp(name, "low-fd") == 0) {
|
||||
return low_fd(log_path);
|
||||
}
|
||||
if (strcmp(name, "nocancel") == 0) {
|
||||
return nocancel();
|
||||
}
|
||||
if (strcmp(name, "unspec") == 0) {
|
||||
return unspec();
|
||||
}
|
||||
if (strcmp(name, "udp-disconnect") == 0) {
|
||||
return udp_disconnect();
|
||||
}
|
||||
if (strcmp(name, "callers") == 0) {
|
||||
return callers(log_path);
|
||||
}
|
||||
if (strcmp(name, "overflow") == 0) {
|
||||
return overflow(log_path);
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// White-box host tests of guard.c's dedupe lock: the schedules that would
|
||||
// abort the process are forced deterministically. Every case runs in its own
|
||||
// process; sockets are never opened (fd -1), so nothing can leave the host.
|
||||
// Build and run with tests/run-guard-tests.sh.
|
||||
#include "../policy.h"
|
||||
|
||||
#include <pthread.h>
|
||||
#include <signal.h>
|
||||
#include <sys/wait.h>
|
||||
|
||||
static int scheduled_seen_insert(eg_seen_t *seen, const char *key);
|
||||
#define eg_seen_insert scheduled_seen_insert
|
||||
#include "../guard.c"
|
||||
#undef eg_seen_insert
|
||||
|
||||
#include <arpa/inet.h>
|
||||
|
||||
#define CONTENTION_THREADS 8
|
||||
#define CONTENTION_KEYS 12
|
||||
#define CONTENTION_ROUNDS 25
|
||||
|
||||
static int interrupt_insert;
|
||||
static int ready_pipe[2];
|
||||
static int release_pipe[2];
|
||||
|
||||
static struct sockaddr_in remote(int port) {
|
||||
struct sockaddr_in a;
|
||||
memset(&a, 0, sizeof a);
|
||||
a.sin_family = AF_INET;
|
||||
a.sin_len = sizeof a;
|
||||
a.sin_port = htons(port);
|
||||
inet_pton(AF_INET, "192.0.2.1", &a.sin_addr);
|
||||
return a;
|
||||
}
|
||||
|
||||
// Block mode must refuse before touching the kernel, even on an invalid fd.
|
||||
static void send_remote(int port) {
|
||||
struct sockaddr_in to = remote(port);
|
||||
if (eg_sendto(-1, "x", 1, 0, (struct sockaddr *)&to, sizeof to) != -1 || errno != ECONNREFUSED) {
|
||||
_exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
static void on_signal(int sig) {
|
||||
(void)sig;
|
||||
send_remote(9);
|
||||
}
|
||||
|
||||
static int scheduled_seen_insert(eg_seen_t *seen, const char *key) {
|
||||
if (interrupt_insert) {
|
||||
interrupt_insert = 0;
|
||||
raise(SIGUSR1);
|
||||
}
|
||||
return eg_seen_insert(seen, key);
|
||||
}
|
||||
|
||||
static int signal_reentry(void) {
|
||||
signal(SIGUSR1, on_signal);
|
||||
interrupt_insert = 1;
|
||||
send_remote(9);
|
||||
send_remote(10);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void *hold_lock(void *unused) {
|
||||
(void)unused;
|
||||
char byte = 'x';
|
||||
os_unfair_lock_lock(&eg_lock);
|
||||
(void)write(ready_pipe[1], &byte, 1);
|
||||
(void)read(release_pipe[0], &byte, 1);
|
||||
os_unfair_lock_unlock(&eg_lock);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int fork_while_locked(void) {
|
||||
if (pipe(ready_pipe) || pipe(release_pipe)) {
|
||||
return 2;
|
||||
}
|
||||
pthread_t thread;
|
||||
if (pthread_create(&thread, NULL, hold_lock, NULL)) {
|
||||
return 2;
|
||||
}
|
||||
char byte;
|
||||
if (read(ready_pipe[0], &byte, 1) != 1) {
|
||||
return 2;
|
||||
}
|
||||
pid_t child = fork();
|
||||
if (child < 0) {
|
||||
return 2;
|
||||
}
|
||||
if (child == 0) {
|
||||
alarm(5);
|
||||
send_remote(9);
|
||||
send_remote(10);
|
||||
_exit(0);
|
||||
}
|
||||
(void)write(release_pipe[1], "x", 1);
|
||||
pthread_join(thread, NULL);
|
||||
int status;
|
||||
waitpid(child, &status, 0);
|
||||
if (WIFSIGNALED(status)) {
|
||||
printf("FAIL fork: child terminated by signal %d\n", WTERMSIG(status));
|
||||
return 1;
|
||||
}
|
||||
return WIFEXITED(status) ? WEXITSTATUS(status) : 2;
|
||||
}
|
||||
|
||||
static void *hammer(void *unused) {
|
||||
(void)unused;
|
||||
for (int round = 0; round < CONTENTION_ROUNDS; round++) {
|
||||
for (int key = 0; key < CONTENTION_KEYS; key++) {
|
||||
send_remote(1000 + key);
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Under contention a thread may give up its insert; the next attempt on the
|
||||
// same key must then record it, so every key ends up logged exactly once.
|
||||
static int contention(const char *log_path) {
|
||||
pthread_t threads[CONTENTION_THREADS];
|
||||
for (int i = 0; i < CONTENTION_THREADS; i++) {
|
||||
if (pthread_create(&threads[i], NULL, hammer, NULL)) {
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < CONTENTION_THREADS; i++) {
|
||||
pthread_join(threads[i], NULL);
|
||||
}
|
||||
FILE *log = fopen(log_path, "r");
|
||||
if (log == NULL) {
|
||||
return 2;
|
||||
}
|
||||
int counts[CONTENTION_KEYS] = {0};
|
||||
int total = 0;
|
||||
char line[1024];
|
||||
while (fgets(line, sizeof line, log)) {
|
||||
total++;
|
||||
for (int key = 0; key < CONTENTION_KEYS; key++) {
|
||||
char needle[64];
|
||||
snprintf(needle, sizeof needle, "\tsendto\tblocked\t192.0.2.1:%d\t", 1000 + key);
|
||||
if (strstr(line, needle)) {
|
||||
counts[key]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
fclose(log);
|
||||
int failures = 0;
|
||||
for (int key = 0; key < CONTENTION_KEYS; key++) {
|
||||
if (counts[key] != 1) {
|
||||
printf("FAIL contention: 192.0.2.1:%d logged %d times\n", 1000 + key, counts[key]);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
if (total != CONTENTION_KEYS) {
|
||||
printf("FAIL contention: %d lines for %d keys\n", total, CONTENTION_KEYS);
|
||||
failures++;
|
||||
}
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) {
|
||||
return 2;
|
||||
}
|
||||
eg_mode = EG_MODE_BLOCK;
|
||||
if (strcmp(argv[1], "signal") == 0) {
|
||||
return signal_reentry();
|
||||
}
|
||||
if (strcmp(argv[1], "fork") == 0) {
|
||||
return fork_while_locked();
|
||||
}
|
||||
if (strcmp(argv[1], "contention") == 0) {
|
||||
return contention(eg_log_path);
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Probe run inside a simulator by the egress guard end-to-end test. Exercises
|
||||
// every outbound path the guard must cover and prints one JSON object.
|
||||
//
|
||||
// nettest --proxy-port <port> --udp-port <port>
|
||||
//
|
||||
// proxy-port: a CONNECT proxy on the host's loopback, standing in for the
|
||||
// egress proxy. udp-port: a UDP echo on loopback.
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
var results: [String: String] = [:]
|
||||
let args = CommandLine.arguments
|
||||
func arg(_ name: String) -> Int {
|
||||
guard let i = args.firstIndex(of: name), i + 1 < args.count else { return 0 }
|
||||
return Int(args[i + 1]) ?? 0
|
||||
}
|
||||
let proxyPort = arg("--proxy-port")
|
||||
let udpPort = arg("--udp-port")
|
||||
let remoteHost = "example.com"
|
||||
let remotePort = 443
|
||||
|
||||
func classify(_ error: Error?) -> String {
|
||||
guard let e = error as NSError? else { return "ok" }
|
||||
if e.domain == NSURLErrorDomain && e.code == NSURLErrorCannotConnectToHost { return "refused" }
|
||||
if e.domain == NSPOSIXErrorDomain && e.code == Int(ECONNREFUSED) { return "refused" }
|
||||
return "error(\(e.domain):\(e.code))"
|
||||
}
|
||||
|
||||
// 1. URLSession straight to the internet. Blocked by the guard.
|
||||
func urlSessionDirect() {
|
||||
let g = DispatchGroup(); g.enter()
|
||||
let c = URLSessionConfiguration.ephemeral; c.timeoutIntervalForRequest = 10
|
||||
c.connectionProxyDictionary = [:] // explicitly no proxy, even if the host has one
|
||||
URLSession(configuration: c).dataTask(with: URL(string: "https://\(remoteHost)/")!) { _, r, e in
|
||||
results["urlsessionDirect"] = e == nil ? "ok(\((r as? HTTPURLResponse)?.statusCode ?? 0))" : classify(e); g.leave()
|
||||
}.resume(); g.wait()
|
||||
}
|
||||
|
||||
// 2. URLSession through a CONNECT proxy on loopback. Allowed: the guard only
|
||||
// sees a loopback connect.
|
||||
func urlSessionProxied() {
|
||||
let g = DispatchGroup(); g.enter()
|
||||
let c = URLSessionConfiguration.ephemeral; c.timeoutIntervalForRequest = 15
|
||||
// The kCFNetworkProxies* constants are not exposed to Swift on iOS; these are their values.
|
||||
c.connectionProxyDictionary = ["HTTPSEnable": 1, "HTTPSProxy": "127.0.0.1", "HTTPSPort": proxyPort]
|
||||
URLSession(configuration: c).dataTask(with: URL(string: "https://\(remoteHost)/")!) { _, r, e in
|
||||
results["urlsessionProxied"] = e == nil ? "ok(\((r as? HTTPURLResponse)?.statusCode ?? 0))" : classify(e); g.leave()
|
||||
}.resume(); g.wait()
|
||||
}
|
||||
|
||||
// 3. Network.framework connection straight out. Blocked.
|
||||
func nwConnectionDirect() {
|
||||
let g = DispatchGroup(); g.enter()
|
||||
let p = NWParameters.tls; p.preferNoProxies = true
|
||||
let c = NWConnection(host: NWEndpoint.Host(remoteHost), port: NWEndpoint.Port(integerLiteral: UInt16(remotePort)), using: p)
|
||||
var done = false
|
||||
c.stateUpdateHandler = { s in
|
||||
guard !done else { return }
|
||||
switch s {
|
||||
case .ready: done = true; results["nwconnectionDirect"] = "ok"; c.cancel(); g.leave()
|
||||
case .failed(let e): done = true; results["nwconnectionDirect"] = classify(e); g.leave()
|
||||
case .waiting(let e): done = true; results["nwconnectionDirect"] = "waiting(" + classify(e) + ")"; c.cancel(); g.leave()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
c.start(queue: .global())
|
||||
_ = g.wait(timeout: .now() + 15)
|
||||
if results["nwconnectionDirect"] == nil { results["nwconnectionDirect"] = "timeout"; c.cancel() }
|
||||
}
|
||||
|
||||
// 4. Plain BSD TCP connect to a resolved address. Blocked.
|
||||
func bsdConnectDirect() {
|
||||
var hints = addrinfo(); hints.ai_socktype = SOCK_STREAM; hints.ai_family = AF_INET
|
||||
var info: UnsafeMutablePointer<addrinfo>? = nil
|
||||
guard getaddrinfo(remoteHost, "443", &hints, &info) == 0, let ai = info else { results["bsdConnectDirect"] = "dns-failed"; return }
|
||||
results["dns"] = "ok"
|
||||
let fd = socket(ai.pointee.ai_family, ai.pointee.ai_socktype, ai.pointee.ai_protocol)
|
||||
let rc = connect(fd, ai.pointee.ai_addr, ai.pointee.ai_addrlen)
|
||||
results["bsdConnectDirect"] = rc == 0 ? "ok" : (errno == ECONNREFUSED ? "refused" : "errno(\(errno))")
|
||||
close(fd); freeaddrinfo(info)
|
||||
}
|
||||
|
||||
// 5. UDP datagram straight out. Blocked. 6. UDP datagram to loopback echo. Allowed.
|
||||
func udp(_ key: String, _ host: String, _ port: Int, expectEcho: Bool) {
|
||||
let fd = socket(AF_INET, SOCK_DGRAM, 0)
|
||||
var to = sockaddr_in(); to.sin_family = sa_family_t(AF_INET); to.sin_port = in_port_t(UInt16(port)).bigEndian
|
||||
inet_pton(AF_INET, host, &to.sin_addr)
|
||||
var payload: [UInt8] = Array("ping".utf8)
|
||||
let sent = withUnsafePointer(to: &to) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
sendto(fd, &payload, payload.count, 0, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
|
||||
if sent < 0 { results[key] = errno == ECONNREFUSED ? "refused" : "errno(\(errno))"; close(fd); return }
|
||||
if expectEcho {
|
||||
var tv = timeval(tv_sec: 3, tv_usec: 0); setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
|
||||
var buf = [UInt8](repeating: 0, count: 16)
|
||||
let n = recv(fd, &buf, buf.count, 0)
|
||||
results[key] = n > 0 ? "ok" : "no-echo"
|
||||
} else { results[key] = "sent" }
|
||||
close(fd)
|
||||
}
|
||||
|
||||
urlSessionDirect()
|
||||
urlSessionProxied()
|
||||
nwConnectionDirect()
|
||||
bsdConnectDirect()
|
||||
udp("udpDirect", "8.8.8.8", 53, expectEcho: false)
|
||||
udp("udpLoopback", "127.0.0.1", udpPort, expectEcho: true)
|
||||
|
||||
// 7. The same literal destination twice: the guard must log it once per process.
|
||||
func bsdConnectLiteral(_ key: String) {
|
||||
var to = sockaddr_in(); to.sin_family = sa_family_t(AF_INET); to.sin_port = in_port_t(UInt16(443)).bigEndian
|
||||
inet_pton(AF_INET, "1.1.1.1", &to.sin_addr)
|
||||
let fd = socket(AF_INET, SOCK_STREAM, 0)
|
||||
let rc = withUnsafePointer(to: &to) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
connect(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
|
||||
results[key] = rc == 0 ? "ok" : (errno == ECONNREFUSED ? "refused" : "errno(\(errno))")
|
||||
close(fd)
|
||||
}
|
||||
bsdConnectLiteral("literalFirst")
|
||||
bsdConnectLiteral("literalSecond")
|
||||
|
||||
// 8. A sockaddr_in whose family was left AF_UNSPEC: the kernel connects it as
|
||||
// IPv4, so the guard must refuse it. 9. connect(AF_UNSPEC) on a UDP socket
|
||||
// dissolves the association; the guard must leave that to the kernel.
|
||||
func unspecConnect() {
|
||||
var to = sockaddr_in(); to.sin_family = sa_family_t(AF_UNSPEC); to.sin_port = in_port_t(UInt16(443)).bigEndian
|
||||
inet_pton(AF_INET, "1.0.0.1", &to.sin_addr)
|
||||
let fd = socket(AF_INET, SOCK_STREAM, 0)
|
||||
let rc = withUnsafePointer(to: &to) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
connect(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
|
||||
results["unspecConnect"] = rc == 0 ? "ok" : (errno == ECONNREFUSED ? "refused" : "errno(\(errno))")
|
||||
close(fd)
|
||||
}
|
||||
func udpDisconnect() {
|
||||
let fd = socket(AF_INET, SOCK_DGRAM, 0)
|
||||
var to = sockaddr_in(); to.sin_family = sa_family_t(AF_INET); to.sin_port = in_port_t(UInt16(udpPort)).bigEndian
|
||||
inet_pton(AF_INET, "127.0.0.1", &to.sin_addr)
|
||||
let associated = withUnsafePointer(to: &to) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
connect(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
|
||||
var dissolve = sockaddr_in(); dissolve.sin_family = sa_family_t(AF_UNSPEC); dissolve.sin_port = in_port_t(UInt16(443)).bigEndian
|
||||
inet_pton(AF_INET, "1.1.1.1", &dissolve.sin_addr)
|
||||
let rc = withUnsafePointer(to: &dissolve) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
connect(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
|
||||
let err = errno
|
||||
var peer = sockaddr_in(); var peerLength = socklen_t(MemoryLayout<sockaddr_in>.size)
|
||||
let stillConnected = withUnsafeMutablePointer(to: &peer) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
getpeername(fd, sa, &peerLength) } } == 0
|
||||
results["udpDisconnect"] = associated != 0 ? "associate-failed"
|
||||
: (rc == -1 && err == EAFNOSUPPORT && !stillConnected) ? "ok" : "errno(\(err)),connected=\(stillConnected)"
|
||||
close(fd)
|
||||
}
|
||||
// 10. The non-cancelable variant of sendto, as libsystem exports it. Blocked.
|
||||
func nocancelSendto() {
|
||||
typealias SendtoFn = @convention(c) (Int32, UnsafeRawPointer?, Int, Int32, UnsafePointer<sockaddr>?, socklen_t) -> Int
|
||||
guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "sendto$NOCANCEL") else {
|
||||
results["nocancelSendto"] = "no-symbol"; return
|
||||
}
|
||||
let nocancelSendto = unsafeBitCast(symbol, to: SendtoFn.self)
|
||||
let fd = socket(AF_INET, SOCK_DGRAM, 0)
|
||||
var to = sockaddr_in(); to.sin_family = sa_family_t(AF_INET); to.sin_port = in_port_t(UInt16(53)).bigEndian
|
||||
inet_pton(AF_INET, "8.8.8.8", &to.sin_addr)
|
||||
var payload: [UInt8] = Array("ping".utf8)
|
||||
let sent = withUnsafePointer(to: &to) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
nocancelSendto(fd, &payload, payload.count, 0, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
|
||||
results["nocancelSendto"] = sent < 0 ? (errno == ECONNREFUSED ? "refused" : "errno(\(errno))") : "sent"
|
||||
close(fd)
|
||||
}
|
||||
unspecConnect()
|
||||
udpDisconnect()
|
||||
nocancelSendto()
|
||||
|
||||
let json = try! JSONSerialization.data(withJSONObject: results, options: [.sortedKeys])
|
||||
print(String(data: json, encoding: .utf8)!)
|
||||
@@ -0,0 +1,143 @@
|
||||
// Host-runnable tests for the guard's policy and formatting. Build and run
|
||||
// with tests/run-policy-tests.sh; every failure prints the failing line.
|
||||
#include "../policy.h"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/un.h>
|
||||
|
||||
static int failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
failures++; \
|
||||
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static struct sockaddr_in v4(const char *ip, int port) {
|
||||
struct sockaddr_in a; memset(&a, 0, sizeof a);
|
||||
a.sin_family = AF_INET; a.sin_len = sizeof a; a.sin_port = htons(port);
|
||||
inet_pton(AF_INET, ip, &a.sin_addr);
|
||||
return a;
|
||||
}
|
||||
static struct sockaddr_in6 v6(const char *ip, int port) {
|
||||
struct sockaddr_in6 a; memset(&a, 0, sizeof a);
|
||||
a.sin6_family = AF_INET6; a.sin6_len = sizeof a; a.sin6_port = htons(port);
|
||||
inet_pton(AF_INET6, ip, &a.sin6_addr);
|
||||
return a;
|
||||
}
|
||||
|
||||
static void test_classify(void) {
|
||||
struct sockaddr_in a4; struct sockaddr_in6 a6; struct sockaddr_un un;
|
||||
|
||||
a4 = v4("127.0.0.1", 8899); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_LOOPBACK);
|
||||
a4 = v4("127.42.0.9", 80); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_LOOPBACK);
|
||||
a4 = v4("0.0.0.0", 80); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_LOOPBACK);
|
||||
a4 = v4("93.184.216.34", 443); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_REMOTE);
|
||||
a4 = v4("192.168.1.10", 8081); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_REMOTE);
|
||||
a4 = v4("169.254.1.1", 80); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_REMOTE);
|
||||
a4 = v4("224.0.0.251", 5353); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_REMOTE);
|
||||
|
||||
a6 = v6("::1", 8899); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_LOOPBACK);
|
||||
a6 = v6("::", 80); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_LOOPBACK);
|
||||
a6 = v6("::ffff:127.0.0.1", 8899); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_LOOPBACK);
|
||||
a6 = v6("::ffff:93.184.216.34", 443); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_REMOTE);
|
||||
a6 = v6("2606:4700::1", 443); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_REMOTE);
|
||||
a6 = v6("fe80::1", 443); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_REMOTE);
|
||||
|
||||
memset(&un, 0, sizeof un); un.sun_family = AF_UNIX; strcpy(un.sun_path, "/var/run/mDNSResponder");
|
||||
CHECK(eg_classify((struct sockaddr *)&un, sizeof un) == EG_PASSTHROUGH);
|
||||
CHECK(eg_classify(NULL, 0) == EG_PASSTHROUGH);
|
||||
a4 = v4("93.184.216.34", 443); CHECK(eg_classify((struct sockaddr *)&a4, 4) == EG_PASSTHROUGH); // too short to read
|
||||
|
||||
// AF_UNSPEC is what the kernel makes of it: the family the exact length implies.
|
||||
a4 = v4("93.184.216.34", 443); a4.sin_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_REMOTE);
|
||||
CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4 + 8) == EG_PASSTHROUGH); // kernel: EINVAL
|
||||
a4 = v4("127.0.0.1", 8899); a4.sin_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_LOOPBACK);
|
||||
a4 = v4("0.0.0.0", 0); a4.sin_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_LOOPBACK); // UDP disconnect shape
|
||||
a6 = v6("2606:4700::1", 443); a6.sin6_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_REMOTE);
|
||||
a6 = v6("::ffff:93.184.216.34", 443); a6.sin6_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_REMOTE);
|
||||
a6 = v6("::1", 443); a6.sin6_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_LOOPBACK);
|
||||
memset(&un, 0, sizeof un); un.sun_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&un, sizeof un) == EG_PASSTHROUGH);
|
||||
}
|
||||
|
||||
static void test_mode(void) {
|
||||
CHECK(eg_parse_mode(NULL) == EG_MODE_BLOCK);
|
||||
CHECK(eg_parse_mode("") == EG_MODE_BLOCK);
|
||||
CHECK(eg_parse_mode("block") == EG_MODE_BLOCK);
|
||||
CHECK(eg_parse_mode("log") == EG_MODE_LOG);
|
||||
CHECK(eg_parse_mode("LOG") == EG_MODE_BLOCK);
|
||||
CHECK(eg_parse_mode("permissive") == EG_MODE_BLOCK);
|
||||
|
||||
CHECK(eg_should_deny(EG_MODE_BLOCK, EG_REMOTE) == 1);
|
||||
CHECK(eg_should_deny(EG_MODE_BLOCK, EG_LOOPBACK) == 0);
|
||||
CHECK(eg_should_deny(EG_MODE_BLOCK, EG_PASSTHROUGH) == 0);
|
||||
CHECK(eg_should_deny(EG_MODE_LOG, EG_REMOTE) == 0);
|
||||
}
|
||||
|
||||
static void test_format_peer(void) {
|
||||
char out[64];
|
||||
struct sockaddr_in a4 = v4("93.184.216.34", 443);
|
||||
CHECK(eg_format_peer((struct sockaddr *)&a4, sizeof a4, out, sizeof out) == 0);
|
||||
CHECK(strcmp(out, "93.184.216.34:443") == 0);
|
||||
struct sockaddr_in6 a6 = v6("2606:4700::1", 443);
|
||||
CHECK(eg_format_peer((struct sockaddr *)&a6, sizeof a6, out, sizeof out) == 0);
|
||||
CHECK(strcmp(out, "[2606:4700::1]:443") == 0);
|
||||
CHECK(eg_format_peer((struct sockaddr *)&a4, sizeof a4, out, 8) != 0);
|
||||
struct sockaddr_un un; memset(&un, 0, sizeof un); un.sun_family = AF_UNIX;
|
||||
CHECK(eg_format_peer((struct sockaddr *)&un, sizeof un, out, sizeof out) != 0);
|
||||
a4.sin_family = AF_UNSPEC;
|
||||
CHECK(eg_format_peer((struct sockaddr *)&a4, sizeof a4, out, sizeof out) == 0);
|
||||
CHECK(strcmp(out, "93.184.216.34:443") == 0);
|
||||
a6.sin6_family = AF_UNSPEC;
|
||||
CHECK(eg_format_peer((struct sockaddr *)&a6, sizeof a6, out, sizeof out) == 0);
|
||||
CHECK(strcmp(out, "[2606:4700::1]:443") == 0);
|
||||
}
|
||||
|
||||
static void test_seen(void) {
|
||||
static eg_seen_t seen; memset(&seen, 0, sizeof seen);
|
||||
CHECK(eg_seen_insert(&seen, "connect 1.1.1.1:443") == 1);
|
||||
CHECK(eg_seen_insert(&seen, "connect 1.1.1.1:443") == 0);
|
||||
CHECK(eg_seen_insert(&seen, "sendto 1.1.1.1:443") == 1);
|
||||
CHECK(seen.count == 2);
|
||||
char key[64];
|
||||
for (int i = 0; i < EG_SEEN_CAPACITY + 5; i++) { snprintf(key, sizeof key, "k%d", i); eg_seen_insert(&seen, key); }
|
||||
CHECK(seen.count == EG_SEEN_CAPACITY);
|
||||
CHECK(seen.overflow == 7); // 2 + 128 + 5 attempts, 128 fit
|
||||
CHECK(eg_seen_insert(&seen, "k0") == 0); // still remembered
|
||||
}
|
||||
|
||||
static void test_format_event(void) {
|
||||
char out[512];
|
||||
const char *callers[] = {"Network", "CFNetwork", "MyApp"};
|
||||
int n = eg_format_event(out, sizeof out, "MyApp", 4242, "connect", "blocked", "93.184.216.34:443", callers, 3);
|
||||
CHECK(n > 0);
|
||||
CHECK(strcmp(out, "eas-egress-guard\tMyApp\t4242\tconnect\tblocked\t93.184.216.34:443\tNetwork,CFNetwork,MyApp\n") == 0);
|
||||
|
||||
const char *tabbed[] = {"a\tb"};
|
||||
n = eg_format_event(out, sizeof out, "we\nird\tname", 1, "sendto", "logged", "1.2.3.4:53", tabbed, 1);
|
||||
CHECK(n > 0);
|
||||
CHECK(strcmp(out, "eas-egress-guard\twe ird name\t1\tsendto\tlogged\t1.2.3.4:53\ta b\n") == 0);
|
||||
|
||||
n = eg_format_event(out, sizeof out, "NoCallers", 7, "connectx", "blocked", "[::2]:80", NULL, 0);
|
||||
CHECK(n > 0 && strcmp(out, "eas-egress-guard\tNoCallers\t7\tconnectx\tblocked\t[::2]:80\t\n") == 0);
|
||||
|
||||
CHECK(eg_format_event(out, 16, "MyApp", 4242, "connect", "blocked", "93.184.216.34:443", callers, 3) == -1);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
test_classify(); test_mode(); test_format_peer(); test_seen(); test_format_event();
|
||||
if (failures) { fprintf(stderr, "%d failure(s)\n", failures); return 1; }
|
||||
printf("egress-guard policy tests: all passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the guard for the host (macOS) and runs the guard tests against it:
|
||||
# white-box lock schedules with guard.c compiled in, and black-box cases with
|
||||
# the built library inserted the way the simulator inserts it. No socket is
|
||||
# ever opened to a non-loopback address.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
out=$(mktemp -d)
|
||||
trap 'rm -rf "$out"' EXIT
|
||||
|
||||
clang -std=c11 -Wall -Wextra -Werror -O2 -dynamiclib policy.c guard.c -o "$out/egress-guard.dylib"
|
||||
clang -std=c11 -Wall -Wextra -Werror -O2 -pthread policy.c tests/guard_test.c -o "$out/guard-test"
|
||||
clang -std=c11 -Wall -Wextra -Werror -O2 tests/guard_insert_test.c -o "$out/guard-insert-test"
|
||||
|
||||
failures=0
|
||||
report() {
|
||||
if [[ $2 -eq 0 ]]; then
|
||||
echo "ok $1"
|
||||
else
|
||||
echo "FAIL $1 (exit $2)"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# White-box: guard.c compiled into the test, lock schedules forced.
|
||||
for name in signal fork contention; do
|
||||
status=0
|
||||
: > "$out/$name.log"
|
||||
EAS_EGRESS_GUARD_MODE=block EAS_EGRESS_GUARD_LOG="$out/$name.log" \
|
||||
"$out/guard-test" "$name" || status=$?
|
||||
report "$name" "$status"
|
||||
done
|
||||
|
||||
# Black-box: the built library inserted into a plain process.
|
||||
export DYLD_INSERT_LIBRARIES="$out/egress-guard.dylib"
|
||||
export EAS_EGRESS_GUARD_MODE=block
|
||||
|
||||
status=0
|
||||
: > "$out/fd-reuse.log"
|
||||
EAS_EGRESS_GUARD_LOG="$out/fd-reuse.log" \
|
||||
"$out/guard-insert-test" fd-reuse "$out/fd-reuse.log" "$out/application-file" || status=$?
|
||||
report fd-reuse "$status"
|
||||
|
||||
status=0
|
||||
: > "$out/low-fd.log"
|
||||
EAS_EGRESS_GUARD_LOG="$out/low-fd.log" \
|
||||
"$out/guard-insert-test" low-fd "$out/low-fd.log" 1>&- || status=$?
|
||||
report low-fd "$status"
|
||||
|
||||
for name in nocancel unspec udp-disconnect callers overflow; do
|
||||
status=0
|
||||
: > "$out/$name.log"
|
||||
EAS_EGRESS_GUARD_LOG="$out/$name.log" \
|
||||
"$out/guard-insert-test" "$name" "$out/$name.log" || status=$?
|
||||
report "$name" "$status"
|
||||
done
|
||||
|
||||
if [[ $failures -ne 0 ]]; then
|
||||
echo "egress-guard guard tests: $failures failure(s)"
|
||||
exit 1
|
||||
fi
|
||||
echo "egress-guard guard tests: all passed"
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the policy unit tests for the host (macOS) and runs them.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
out=$(mktemp -d)
|
||||
clang -std=c11 -Wall -Wextra -Werror -o "$out/policy_test" policy.c tests/policy_test.c
|
||||
"$out/policy_test"
|
||||
@@ -15,6 +15,7 @@ let package = Package(
|
||||
.target(
|
||||
name: "RecordSim",
|
||||
linkerSettings: [
|
||||
.linkedFramework("Accelerate"),
|
||||
.linkedFramework("AVFoundation"),
|
||||
.linkedFramework("CoreGraphics"),
|
||||
.linkedFramework("CoreMedia"),
|
||||
|
||||
@@ -9,6 +9,13 @@ It implements one recording path:
|
||||
3. Feed one continuous `AVAssetWriter`.
|
||||
4. Emit Apple HLS/CMAF-style fragmented MP4 segments.
|
||||
|
||||
Foldable simulators such as iPhone Duo keep a framebuffer for every integrated
|
||||
panel. The recorder puts all integrated panels side by side in one video, in
|
||||
screen ID order, so the video follows the device when it folds. The panel that
|
||||
is not in use is black. Each panel is rotated upright by the `nativeRotation`
|
||||
in the device profile, which turns the iPhone Duo inner panel 90 degrees
|
||||
clockwise. Other simulators record their largest framebuffer.
|
||||
|
||||
The output is suitable for uploading while recording. `session.json` contains
|
||||
the ordered segment metadata needed to build a playlist later.
|
||||
|
||||
|
||||
+121
-22
@@ -8,6 +8,18 @@ final class FramebufferDisplaySource {
|
||||
let width: Int
|
||||
let height: Int
|
||||
let seed: UInt32
|
||||
/// Clockwise rotation in degrees (0, 90, 180, or 270) that shows the panel upright.
|
||||
let rotation: Int
|
||||
|
||||
var uprightWidth: Int { rotation % 180 == 0 ? width : height }
|
||||
var uprightHeight: Int { rotation % 180 == 0 ? height : width }
|
||||
}
|
||||
|
||||
/// The panels to record, in a stable order. A nil entry is a panel that has no surface yet.
|
||||
struct FrameSnapshot {
|
||||
let panels: [SurfaceSnapshot?]
|
||||
|
||||
var seeds: [UInt32?] { panels.map { $0?.seed } }
|
||||
}
|
||||
|
||||
private let deviceUDID: String
|
||||
@@ -15,6 +27,10 @@ final class FramebufferDisplaySource {
|
||||
private let onFrame: () -> Void
|
||||
private let onSurfaceChange: () -> Void
|
||||
private var descriptors: [NSObject] = []
|
||||
/// Integrated panels in screen ID order, which puts the iPhone Duo front panel first.
|
||||
private var integratedPanels: [(descriptor: NSObject, rotation: Int)] = []
|
||||
/// Mounting rotation of each integrated panel from the device profile, by screen ID.
|
||||
private var nativeRotations: [UInt32: Int] = [:]
|
||||
private var callbackUUIDs: [ObjectIdentifier: NSUUID] = [:]
|
||||
private var retainedBlocks: [AnyObject] = []
|
||||
private var ioClient: NSObject?
|
||||
@@ -46,40 +62,54 @@ final class FramebufferDisplaySource {
|
||||
throw RecorderError.make(3, "Failed to get simulator IO client")
|
||||
}
|
||||
ioClient = io
|
||||
nativeRotations = Self.nativeRotations(device: device)
|
||||
try wireUpFramebuffer()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
unregisterCallbacks()
|
||||
descriptors.removeAll()
|
||||
integratedPanels.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
|
||||
/// Foldable simulators such as iPhone Duo keep an IOSurface alive for every integrated panel,
|
||||
/// and the panel that is not in use stays black. Record all integrated panels so the video
|
||||
/// follows a fold; other simulators record their largest surface.
|
||||
func frameSnapshot() -> FrameSnapshot? {
|
||||
if integratedPanels.count >= 2 {
|
||||
let panels = integratedPanels.map {
|
||||
surfaceSnapshot(for: $0.descriptor, rotation: $0.rotation)
|
||||
}
|
||||
return panels.contains { $0 != nil } ? FrameSnapshot(panels: panels) : nil
|
||||
}
|
||||
return bestSnapshot
|
||||
let largest = descriptors.compactMap { surfaceSnapshot(for: $0, rotation: 0) }.max {
|
||||
$0.width * $0.height < $1.width * $1.height
|
||||
}
|
||||
return largest.map { FrameSnapshot(panels: [$0]) }
|
||||
}
|
||||
|
||||
private func surfaceSnapshot(for descriptor: NSObject, rotation: Int) -> SurfaceSnapshot? {
|
||||
guard
|
||||
let surfaceObject = descriptor.perform(NSSelectorFromString("framebufferSurface"))?
|
||||
.takeUnretainedValue()
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let surface = unsafeBitCast(surfaceObject, to: IOSurface.self)
|
||||
let width = IOSurfaceGetWidth(surface)
|
||||
let height = IOSurfaceGetHeight(surface)
|
||||
guard width > 0, height > 0 else {
|
||||
return nil
|
||||
}
|
||||
return SurfaceSnapshot(
|
||||
surface: surface,
|
||||
width: width,
|
||||
height: height,
|
||||
seed: IOSurfaceGetSeed(surface),
|
||||
rotation: rotation
|
||||
)
|
||||
}
|
||||
|
||||
func rewireFramebuffer() throws {
|
||||
@@ -94,6 +124,12 @@ final class FramebufferDisplaySource {
|
||||
let nextDescriptors = try findFramebufferDescriptors(io: io)
|
||||
unregisterCallbacks()
|
||||
descriptors = nextDescriptors
|
||||
integratedPanels = Self.integratedPanels(in: nextDescriptors).map { panel in
|
||||
// A panel mounted at 270 degrees, such as the iPhone Duo inner panel, turns 90
|
||||
// degrees clockwise to be upright.
|
||||
let nativeRotation = nativeRotations[panel.screenID] ?? 0
|
||||
return (panel.descriptor, (360 - nativeRotation) % 360)
|
||||
}
|
||||
retainedBlocks.removeAll()
|
||||
do {
|
||||
for descriptor in descriptors {
|
||||
@@ -102,6 +138,7 @@ final class FramebufferDisplaySource {
|
||||
} catch {
|
||||
unregisterCallbacks()
|
||||
descriptors.removeAll()
|
||||
integratedPanels.removeAll()
|
||||
retainedBlocks.removeAll()
|
||||
throw error
|
||||
}
|
||||
@@ -135,6 +172,61 @@ final class FramebufferDisplaySource {
|
||||
return candidates
|
||||
}
|
||||
|
||||
/// Xcode releases without the screen properties API report no integrated panels, which keeps
|
||||
/// them on the largest-surface path.
|
||||
private static func integratedPanels(
|
||||
in descriptors: [NSObject]
|
||||
) -> [(screenID: UInt32, descriptor: NSObject)] {
|
||||
typealias Panel = (screenID: UInt32, descriptor: NSObject)
|
||||
let panels = descriptors.compactMap { descriptor -> Panel? in
|
||||
let selector = NSSelectorFromString("screenProperties")
|
||||
guard descriptor.responds(to: selector),
|
||||
let properties = descriptor.perform(selector)?.takeUnretainedValue()
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let object: AnyObject = properties
|
||||
guard object.recordSimScreenType?() == integratedScreenType,
|
||||
let screenID = object.recordSimScreenID?()
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return (screenID, descriptor)
|
||||
}
|
||||
return panels.sorted { $0.screenID < $1.screenID }
|
||||
}
|
||||
|
||||
/// The device profile that serve-sim also reads. A missing profile means no rotation.
|
||||
private static func nativeRotations(device: NSObject) -> [UInt32: Int] {
|
||||
let typeSelector = NSSelectorFromString("deviceType")
|
||||
let capabilitiesSelector = NSSelectorFromString("capabilities")
|
||||
guard device.responds(to: typeSelector),
|
||||
let type = device.perform(typeSelector)?.takeUnretainedValue() as? NSObject,
|
||||
type.responds(to: capabilitiesSelector),
|
||||
let profile = type.perform(capabilitiesSelector)?.takeUnretainedValue()
|
||||
as? [String: Any],
|
||||
let capabilities = profile["capabilities"] as? [String: Any],
|
||||
let displays = capabilities["displays"] as? [[String: Any]]
|
||||
else {
|
||||
return [:]
|
||||
}
|
||||
var rotations: [UInt32: Int] = [:]
|
||||
for display in displays {
|
||||
guard display["displayType"] as? String == "integrated",
|
||||
let id = display["screenID"] as? NSNumber,
|
||||
let screenID = UInt32(exactly: id.int64Value),
|
||||
let rotation = display["nativeRotation"] as? NSNumber,
|
||||
[0, 90, 180, 270].contains(rotation.intValue)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
rotations[screenID] = rotation.intValue
|
||||
}
|
||||
return rotations
|
||||
}
|
||||
|
||||
private static let integratedScreenType: UInt64 = 0
|
||||
|
||||
private func registerCallbacks(descriptor: NSObject) throws {
|
||||
let selector = NSSelectorFromString(
|
||||
"registerScreenCallbacksWithUUID:callbackQueue:frameCallback:surfacesChangedCallback:propertiesChangedCallback:"
|
||||
@@ -190,3 +282,10 @@ final class FramebufferDisplaySource {
|
||||
callbackUUIDs.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
/// Getters from the CoreSimDeviceIO SimScreenProperties protocol. Optional dispatch keeps Xcode
|
||||
/// releases that lack them working.
|
||||
@objc private protocol SimScreenPropertiesAccess {
|
||||
@objc(screenID) func recordSimScreenID() -> UInt32
|
||||
@objc(screenType) func recordSimScreenType() -> UInt64
|
||||
}
|
||||
+167
-65
@@ -1,4 +1,5 @@
|
||||
import AVFoundation
|
||||
import Accelerate
|
||||
import CoreVideo
|
||||
import Foundation
|
||||
import IOSurface
|
||||
@@ -27,10 +28,9 @@ public final class SimulatorRecorder {
|
||||
private var monotonicClock = MonotonicClock()
|
||||
private var firstAcceptedCaptureTime: CMTime?
|
||||
private var firstAcceptedWallClock: Date?
|
||||
private var recordingWidth: Int?
|
||||
private var recordingHeight: Int?
|
||||
private var canvasLayout: PanelCanvasLayout?
|
||||
private var lastPTS: CMTime?
|
||||
private var lastSeed: UInt32?
|
||||
private var lastSeeds: [UInt32?]?
|
||||
private var lastFrameCallbackElapsed: TimeInterval?
|
||||
private var lastFirstFrameRewireElapsed: TimeInterval = 0
|
||||
private var lastAppendedPixelBuffer: CVPixelBuffer?
|
||||
@@ -147,14 +147,14 @@ public final class SimulatorRecorder {
|
||||
guard let firstAcceptedWallClock else {
|
||||
throw RecorderError.make(25, "Missing first frame wall-clock timestamp")
|
||||
}
|
||||
guard let recordingWidth, let recordingHeight else {
|
||||
guard let canvasLayout else {
|
||||
throw RecorderError.make(27, "Missing recording dimensions")
|
||||
}
|
||||
return try outputWriter.writeManifest(
|
||||
configuration: configuration,
|
||||
firstFrameWallClock: firstAcceptedWallClock,
|
||||
width: recordingWidth,
|
||||
height: recordingHeight
|
||||
width: canvasLayout.width,
|
||||
height: canvasLayout.height
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -239,19 +239,13 @@ public final class SimulatorRecorder {
|
||||
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 {
|
||||
guard let snapshot = displaySource?.frameSnapshot() else {
|
||||
return
|
||||
}
|
||||
|
||||
let seed = snapshot.seed
|
||||
let matchesLastAppendedSeed = writerQueue.sync { lastSeed == seed }
|
||||
if !force, matchesLastAppendedSeed {
|
||||
let seeds = snapshot.seeds
|
||||
let matchesLastAppendedSeeds = writerQueue.sync { lastSeeds == seeds }
|
||||
if !force, matchesLastAppendedSeeds {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -262,14 +256,10 @@ public final class SimulatorRecorder {
|
||||
let capturedAt = monotonicClock.elapsedTime()
|
||||
let capturedAtWallClock = Date()
|
||||
do {
|
||||
let pixelBuffer = try copySurfaceToOwnedPixelBuffer(
|
||||
surface,
|
||||
width: surfaceWidth,
|
||||
height: surfaceHeight
|
||||
)
|
||||
let pixelBuffer = try copyFrameToOwnedPixelBuffer(snapshot)
|
||||
appendOwned(
|
||||
pixelBuffer: pixelBuffer,
|
||||
seed: seed,
|
||||
seeds: seeds,
|
||||
capturedAt: capturedAt,
|
||||
capturedAtWallClock: capturedAtWallClock
|
||||
)
|
||||
@@ -296,17 +286,14 @@ public final class SimulatorRecorder {
|
||||
return
|
||||
}
|
||||
|
||||
guard let snapshot = displaySource.surfaceSnapshot(),
|
||||
snapshot.width > 0,
|
||||
snapshot.height > 0
|
||||
else {
|
||||
guard let snapshot = displaySource.frameSnapshot() else {
|
||||
rewireFramebuffer()
|
||||
return
|
||||
}
|
||||
|
||||
let observedSeed = snapshot.seed
|
||||
let lastAppendedSeed = writerQueue.sync { lastSeed }
|
||||
guard let lastAppendedSeed, observedSeed != lastAppendedSeed else {
|
||||
let observedSeeds = snapshot.seeds
|
||||
let lastAppendedSeeds = writerQueue.sync { lastSeeds }
|
||||
guard let lastAppendedSeeds, observedSeeds != lastAppendedSeeds else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -369,22 +356,20 @@ public final class SimulatorRecorder {
|
||||
pendingLock.unlock()
|
||||
}
|
||||
|
||||
private func copySurfaceToOwnedPixelBuffer(
|
||||
_ surface: IOSurface,
|
||||
width: Int,
|
||||
height: Int
|
||||
private func copyFrameToOwnedPixelBuffer(
|
||||
_ snapshot: FramebufferDisplaySource.FrameSnapshot
|
||||
) throws -> CVPixelBuffer {
|
||||
let pool = try writerQueue.sync {
|
||||
let (pool, layout) = try writerQueue.sync {
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
if writer == nil {
|
||||
try startWriter(width: width, height: height)
|
||||
try startWriter(layout: PanelCanvasLayout(panels: snapshot.panels))
|
||||
}
|
||||
guard let pool = adaptor?.pixelBufferPool else {
|
||||
guard let pool = adaptor?.pixelBufferPool, let canvasLayout else {
|
||||
throw RecorderError.make(40, "AVAssetWriter pixel buffer pool is unavailable")
|
||||
}
|
||||
return pool
|
||||
return (pool, canvasLayout)
|
||||
}
|
||||
|
||||
var output: CVPixelBuffer?
|
||||
@@ -397,39 +382,129 @@ public final class SimulatorRecorder {
|
||||
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, [])
|
||||
}
|
||||
|
||||
defer { 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
|
||||
let destinationWidth = CVPixelBufferGetWidth(destination)
|
||||
let destinationHeight = CVPixelBufferGetHeight(destination)
|
||||
for (index, slot) in layout.slots.enumerated() {
|
||||
let slotWidth = max(0, min(slot.width, destinationWidth - slot.x))
|
||||
guard slotWidth > 0 else {
|
||||
continue
|
||||
}
|
||||
let panel = index < snapshot.panels.count ? snapshot.panels[index] : nil
|
||||
try copyPanel(
|
||||
panel?.surface,
|
||||
rotation: panel?.rotation ?? 0,
|
||||
into: destinationAddress.advanced(by: slot.x * 4),
|
||||
stride: destinationStride,
|
||||
width: slotWidth,
|
||||
height: destinationHeight
|
||||
)
|
||||
}
|
||||
return destination
|
||||
}
|
||||
|
||||
/// Pool buffers are reused, so every pixel of the region that the surface does not cover,
|
||||
/// including the whole region of a panel without a surface, is cleared to black.
|
||||
private func copyPanel(
|
||||
_ surface: IOSurface?,
|
||||
rotation: Int,
|
||||
into destinationAddress: UnsafeMutableRawPointer,
|
||||
stride destinationStride: Int,
|
||||
width: Int,
|
||||
height: Int
|
||||
) throws {
|
||||
let regionBytes = width * 4
|
||||
guard let surface else {
|
||||
for row in 0..<height {
|
||||
memset(destinationAddress.advanced(by: row * destinationStride), 0, regionBytes)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let surfaceLockStatus = IOSurfaceLock(surface, .readOnly, nil)
|
||||
guard surfaceLockStatus == KERN_SUCCESS else {
|
||||
throw RecorderError.make(49, "Failed to lock IOSurface: \(surfaceLockStatus)")
|
||||
}
|
||||
defer { IOSurfaceUnlock(surface, .readOnly, nil) }
|
||||
|
||||
let sourceAddress = IOSurfaceGetBaseAddress(surface)
|
||||
let sourceStride = IOSurfaceGetBytesPerRow(surface)
|
||||
if rotation != 0 {
|
||||
try rotatePanel(
|
||||
surface,
|
||||
rotation: rotation,
|
||||
into: destinationAddress,
|
||||
stride: destinationStride,
|
||||
width: width,
|
||||
height: height
|
||||
)
|
||||
return
|
||||
}
|
||||
let copyHeight = min(height, IOSurfaceGetHeight(surface))
|
||||
let copyBytes = min(min(width, IOSurfaceGetWidth(surface)) * 4, sourceStride)
|
||||
for row in 0..<height {
|
||||
let destinationRow = destinationAddress.advanced(by: row * destinationStride)
|
||||
let copiedBytes = row < copyHeight ? copyBytes : 0
|
||||
if copiedBytes > 0 {
|
||||
memcpy(destinationRow, sourceAddress.advanced(by: row * sourceStride), copiedBytes)
|
||||
}
|
||||
if copiedBytes < regionBytes {
|
||||
memset(destinationRow.advanced(by: copiedBytes), 0, regionBytes - copiedBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Expects the surface to be locked. vImage centers a surface whose upright size differs from
|
||||
/// the region and fills the rest with black.
|
||||
private func rotatePanel(
|
||||
_ surface: IOSurface,
|
||||
rotation: Int,
|
||||
into destinationAddress: UnsafeMutableRawPointer,
|
||||
stride destinationStride: Int,
|
||||
width: Int,
|
||||
height: Int
|
||||
) throws {
|
||||
let rotationConstant: Int
|
||||
switch rotation {
|
||||
case 90: rotationConstant = kRotate90DegreesClockwise
|
||||
case 180: rotationConstant = kRotate180DegreesClockwise
|
||||
case 270: rotationConstant = kRotate270DegreesClockwise
|
||||
default: throw RecorderError.make(51, "Unsupported panel rotation: \(rotation)")
|
||||
}
|
||||
let surfaceWidth = IOSurfaceGetWidth(surface)
|
||||
let surfaceHeight = IOSurfaceGetHeight(surface)
|
||||
let rotatedHeight = min(height, rotation == 180 ? surfaceHeight : surfaceWidth)
|
||||
var source = vImage_Buffer(
|
||||
data: IOSurfaceGetBaseAddress(surface),
|
||||
height: vImagePixelCount(surfaceHeight),
|
||||
width: vImagePixelCount(surfaceWidth),
|
||||
rowBytes: IOSurfaceGetBytesPerRow(surface)
|
||||
)
|
||||
var destination = vImage_Buffer(
|
||||
data: destinationAddress,
|
||||
height: vImagePixelCount(rotatedHeight),
|
||||
width: vImagePixelCount(width),
|
||||
rowBytes: destinationStride
|
||||
)
|
||||
var black: [UInt8] = [0, 0, 0, 0]
|
||||
let error = vImageRotate90_ARGB8888(
|
||||
&source, &destination, UInt8(rotationConstant), &black, vImage_Flags(kvImageNoFlags))
|
||||
guard error == kvImageNoError else {
|
||||
throw RecorderError.make(52, "Failed to rotate panel: \(error)")
|
||||
}
|
||||
for row in rotatedHeight..<height {
|
||||
memset(destinationAddress.advanced(by: row * destinationStride), 0, width * 4)
|
||||
}
|
||||
}
|
||||
|
||||
private func appendOwned(
|
||||
pixelBuffer: CVPixelBuffer,
|
||||
seed: UInt32,
|
||||
seeds: [UInt32?],
|
||||
capturedAt: CMTime,
|
||||
capturedAtWallClock: Date
|
||||
) {
|
||||
@@ -441,7 +516,7 @@ public final class SimulatorRecorder {
|
||||
do {
|
||||
try self.appendOnWriterQueue(
|
||||
pixelBuffer: pixelBuffer,
|
||||
seed: seed,
|
||||
seeds: seeds,
|
||||
capturedAt: capturedAt,
|
||||
capturedAtWallClock: capturedAtWallClock
|
||||
)
|
||||
@@ -454,7 +529,7 @@ public final class SimulatorRecorder {
|
||||
|
||||
private func appendOnWriterQueue(
|
||||
pixelBuffer: CVPixelBuffer,
|
||||
seed: UInt32,
|
||||
seeds: [UInt32?],
|
||||
capturedAt: CMTime,
|
||||
capturedAtWallClock: Date
|
||||
) throws {
|
||||
@@ -480,7 +555,7 @@ public final class SimulatorRecorder {
|
||||
firstAcceptedWallClock = capturedAtWallClock
|
||||
}
|
||||
lastPTS = pts
|
||||
lastSeed = seed
|
||||
lastSeeds = seeds
|
||||
lastAppendedPixelBuffer = pixelBuffer
|
||||
signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
@@ -552,7 +627,9 @@ public final class SimulatorRecorder {
|
||||
return CMTimeSubtract(capturedAt, start)
|
||||
}
|
||||
|
||||
private func startWriter(width: Int, height: Int) throws {
|
||||
private func startWriter(layout: PanelCanvasLayout) throws {
|
||||
let width = layout.width
|
||||
let height = layout.height
|
||||
guard let outputWriter else {
|
||||
throw RecorderError.make(46, "Missing output writer")
|
||||
}
|
||||
@@ -600,8 +677,7 @@ public final class SimulatorRecorder {
|
||||
self.writer = writer
|
||||
self.input = input
|
||||
self.adaptor = adaptor
|
||||
recordingWidth = width
|
||||
recordingHeight = height
|
||||
canvasLayout = layout
|
||||
}
|
||||
|
||||
private func assetWriterSettings(width: Int, height: Int) -> [String: Any] {
|
||||
@@ -642,3 +718,29 @@ public final class SimulatorRecorder {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Upright panels side by side, top aligned, sized from the first frame. A panel without a
|
||||
/// surface in that frame gets no region.
|
||||
private struct PanelCanvasLayout {
|
||||
struct Slot {
|
||||
let x: Int
|
||||
let width: Int
|
||||
}
|
||||
|
||||
let slots: [Slot]
|
||||
let width: Int
|
||||
let height: Int
|
||||
|
||||
init(panels: [FramebufferDisplaySource.SurfaceSnapshot?]) {
|
||||
var x = 0
|
||||
var slots: [Slot] = []
|
||||
for panel in panels {
|
||||
let width = panel?.uprightWidth ?? 0
|
||||
slots.append(Slot(x: x, width: width))
|
||||
x += width
|
||||
}
|
||||
self.slots = slots
|
||||
width = x
|
||||
height = panels.compactMap { $0?.uprightHeight }.max() ?? 0
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ describe('projectSources', () => {
|
||||
expect(logger.info).toHaveBeenCalledWith('Normalizing project source permissions');
|
||||
});
|
||||
|
||||
it.each([false, true])('uses the correct repository URL (local: %s)', async isLocal => {
|
||||
it.each([true, false])('uses the refreshed URL (legacy URL: %s)', async includeRepositoryUrl => {
|
||||
const robotAccessToken = randomUUID();
|
||||
const buildId = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
|
||||
@@ -63,7 +63,9 @@ describe('projectSources', () => {
|
||||
appId: randomUUID(),
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
...(includeRepositoryUrl
|
||||
? { repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git' }
|
||||
: {}),
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash,
|
||||
},
|
||||
@@ -78,7 +80,7 @@ describe('projectSources', () => {
|
||||
__API_SERVER_URL: 'https://api.expo.dev',
|
||||
EXPO_TOKEN: robotAccessToken,
|
||||
EAS_BUILD_ID: buildId,
|
||||
EAS_BUILD_RUNNER: isLocal ? 'local-build-plugin' : 'eas-build',
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
},
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
@@ -107,18 +109,13 @@ describe('projectSources', () => {
|
||||
expect.objectContaining({
|
||||
archiveSource: {
|
||||
...ctx.job.projectArchive,
|
||||
repositoryUrl: isLocal
|
||||
? 'https://x-access-token:[email protected]/expo/eas-cli.git'
|
||||
: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
},
|
||||
})
|
||||
);
|
||||
if (isLocal) {
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['http', 'network', 'json', 'schema'])(
|
||||
it.each(['http', 'network', 'json', 'schema', 'missing-url', 'PATH', 'GCS', 'R2'])(
|
||||
'throws a system error if refresh fails (%s)',
|
||||
async failure => {
|
||||
const robotAccessToken = randomUUID();
|
||||
@@ -134,7 +131,6 @@ describe('projectSources', () => {
|
||||
appId: randomUUID(),
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash: randomBytes(20).toString('hex'),
|
||||
},
|
||||
@@ -170,6 +166,18 @@ describe('projectSources', () => {
|
||||
if (failure === 'json') {
|
||||
throw cause;
|
||||
}
|
||||
if (['PATH', 'GCS', 'R2'].includes(failure)) {
|
||||
return { data: { type: failure, path: '/project.tar.gz', bucketKey: 'project' } };
|
||||
}
|
||||
if (failure === 'missing-url') {
|
||||
return {
|
||||
data: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash: randomBytes(20).toString('hex'),
|
||||
},
|
||||
};
|
||||
}
|
||||
return { data: { repository_url: 'https://github.com/expo/eas-cli.git' } };
|
||||
},
|
||||
} as Response;
|
||||
@@ -206,7 +214,6 @@ describe('projectSources', () => {
|
||||
appId: randomUUID(),
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash,
|
||||
},
|
||||
@@ -277,7 +284,6 @@ describe('projectSources', () => {
|
||||
appId: randomUUID(),
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash: randomBytes(20).toString('hex'),
|
||||
},
|
||||
@@ -588,3 +594,56 @@ describe('projectSources', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('local project sources', () => {
|
||||
function createContext(type: ArchiveSourceType): BuildContext<Job> {
|
||||
return new BuildContext(
|
||||
{
|
||||
platform: Platform.IOS,
|
||||
projectArchive: { type, path: '/source.tar.gz' },
|
||||
} as Job,
|
||||
{
|
||||
env: { EAS_BUILD_RUNNER: 'local-build-plugin', __API_SERVER_URL: 'https://api.expo.dev' },
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
it('copies and unpacks a PATH source without fetching from www', async () => {
|
||||
await vol.promises.mkdir('/workingdir', { recursive: true });
|
||||
await vol.promises.writeFile('/source.tar.gz', 'local archive');
|
||||
const ctx = createContext(ArchiveSourceType.PATH);
|
||||
await expect(prepareProjectSourcesAsync(ctx, ctx.buildDirectory)).resolves.toEqual({
|
||||
handled: true,
|
||||
});
|
||||
expect(await vol.promises.readFile('/workingdir/project.tar.gz', 'utf8')).toBe('local archive');
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'tar',
|
||||
['-C', ctx.buildDirectory, '--strip-components', '1', '-zxf', '/workingdir/project.tar.gz'],
|
||||
{ logger: ctx.logger }
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(
|
||||
Object.values(ArchiveSourceType).filter(
|
||||
type => type !== ArchiveSourceType.PATH && type !== ArchiveSourceType.NONE
|
||||
)
|
||||
)('rejects local %s sources before fetching or unpacking', async type => {
|
||||
const ctx = createContext(type);
|
||||
const result = prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
await expect(result).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(result).rejects.toMatchObject({
|
||||
errorCode: 'SERVER_ERROR',
|
||||
trackingCode: 'INVALID_LOCAL_PROJECT_SOURCE',
|
||||
message: `Expected a PATH project source for a local build, received ${type}.`,
|
||||
});
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ export async function shallowCloneRepositoryAsync({
|
||||
destinationDirectory,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
archiveSource: ArchiveSource & { type: ArchiveSourceType.GIT };
|
||||
archiveSource: ArchiveSource & { type: ArchiveSourceType.GIT; repositoryUrl: string };
|
||||
destinationDirectory: string;
|
||||
}): Promise<void> {
|
||||
const { repositoryUrl } = archiveSource;
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import {
|
||||
ArchiveSource,
|
||||
ArchiveSourceSchemaZ,
|
||||
ArchiveSourceType,
|
||||
Job,
|
||||
SystemError,
|
||||
} from '@expo/eas-build-job';
|
||||
import { ArchiveSourceType, Job, SystemError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
@@ -25,32 +19,30 @@ export async function prepareProjectSourcesAsync<TJob extends Job>(
|
||||
destinationDirectory: string
|
||||
): // Return type required to make switch exhaustive.
|
||||
Promise<{ handled: boolean }> {
|
||||
let projectArchive: ArchiveSource = ctx.job.projectArchive;
|
||||
if (ctx.isLocal) {
|
||||
console.warn('Local build, skipping project archive refresh');
|
||||
} else {
|
||||
const projectArchiveResult = await asyncResult(fetchProjectArchiveSourceAsync(ctx));
|
||||
|
||||
if (!projectArchiveResult.ok) {
|
||||
throw new SystemError('Failed to fetch project sources. Re-run the job.', {
|
||||
cause: projectArchiveResult.reason,
|
||||
});
|
||||
}
|
||||
|
||||
projectArchive = projectArchiveResult.value;
|
||||
if (ctx.job.projectArchive.type === ArchiveSourceType.NONE) {
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
if (ctx.isLocal) {
|
||||
if (ctx.job.projectArchive.type !== ArchiveSourceType.PATH) {
|
||||
throw new SystemError(
|
||||
`Expected a PATH project source for a local build, received ${ctx.job.projectArchive.type}.`,
|
||||
{ trackingCode: 'INVALID_LOCAL_PROJECT_SOURCE' }
|
||||
);
|
||||
}
|
||||
await prepareProjectSourcesLocallyAsync(ctx, ctx.job.projectArchive.path, destinationDirectory);
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
const projectArchiveResult = await asyncResult(fetchProjectArchiveSourceAsync(ctx));
|
||||
if (!projectArchiveResult.ok) {
|
||||
throw new SystemError('Failed to fetch project sources. Re-run the job.', {
|
||||
cause: projectArchiveResult.reason,
|
||||
});
|
||||
}
|
||||
const projectArchive = projectArchiveResult.value;
|
||||
|
||||
switch (projectArchive.type) {
|
||||
case ArchiveSourceType.R2:
|
||||
case ArchiveSourceType.GCS: {
|
||||
throw new Error('Remote project sources should be resolved earlier to URL');
|
||||
}
|
||||
|
||||
case ArchiveSourceType.PATH: {
|
||||
await prepareProjectSourcesLocallyAsync(ctx, projectArchive.path, destinationDirectory); // used in eas build --local
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
case ArchiveSourceType.NONE: {
|
||||
// May be used in no-sources jobs like submission jobs.
|
||||
return { handled: true };
|
||||
@@ -254,7 +246,7 @@ async function uploadProjectMetadataAsync(
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>): Promise<ArchiveSource> {
|
||||
async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>) {
|
||||
const taskId = nullthrows(ctx.env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const expoApiServerURL = nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
const robotAccessToken = nullthrows(
|
||||
@@ -292,7 +284,20 @@ async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>): Promise<A
|
||||
);
|
||||
}
|
||||
|
||||
const dataResult = z.object({ data: ArchiveSourceSchemaZ }).safeParse(jsonResult.value);
|
||||
const dataResult = z
|
||||
.object({
|
||||
data: z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal(ArchiveSourceType.GIT),
|
||||
repositoryUrl: z.string().url(),
|
||||
gitRef: z.string().nullable(),
|
||||
gitCommitHash: z.string(),
|
||||
}),
|
||||
z.object({ type: z.literal(ArchiveSourceType.URL), url: z.string().url() }),
|
||||
z.object({ type: z.literal(ArchiveSourceType.NONE) }),
|
||||
]),
|
||||
})
|
||||
.safeParse(jsonResult.value);
|
||||
if (!dataResult.success) {
|
||||
throw new Error(
|
||||
`Unexpected data from server (${response.status}): ${z.prettifyError(dataResult.error)}`
|
||||
|
||||
+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'
|
||||
|
||||
@@ -54,6 +54,7 @@ import { createStartIosSimulatorRecordingsBuildFunction } from './functions/star
|
||||
import { createStartLocalEgressBuildFunction } from './functions/startLocalEgress';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/startWebPreviewRemoteSession';
|
||||
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
|
||||
import { createStartSandboxBuildFunction } from './functions/startSandbox';
|
||||
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
|
||||
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
|
||||
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings';
|
||||
@@ -110,6 +111,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
|
||||
createStartWebPreviewRemoteSessionBuildFunction(ctx),
|
||||
createStartServeSimMetricsBuildFunction(),
|
||||
createStartSandboxBuildFunction(ctx),
|
||||
createCollectServeSimMetricsBuildFunction(ctx),
|
||||
createInstallMaestroBuildFunction(),
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ const projectTargetDirectory = '/workingdir/project';
|
||||
|
||||
const GIT_PROJECT_ARCHIVE = {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/app.git',
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash: '0123456789abcdef0123456789abcdef01234567',
|
||||
};
|
||||
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
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 { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
|
||||
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');
|
||||
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() };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// The daemon package is external. No installation or daemon process runs in these tests.
|
||||
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
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', createStartAgentDeviceRemoteSessionBuildFunction],
|
||||
] 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: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { 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);
|
||||
expect(stopTool).toHaveBeenCalledTimes(1);
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(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' }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,10 +266,9 @@ 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 () => {
|
||||
|
||||
+22
-9
@@ -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,7 +15,6 @@ import {
|
||||
stopLocalEgressResourcesAsync,
|
||||
} from '../../utils/localEgress';
|
||||
import {
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
@@ -27,6 +27,7 @@ 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');
|
||||
jest.mock('../../utils/argentArtifacts');
|
||||
jest.mock('../../utils/agentDeviceEvents', () => ({
|
||||
@@ -45,6 +46,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,8 +54,11 @@ 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(),
|
||||
}));
|
||||
@@ -77,6 +82,7 @@ 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> {
|
||||
@@ -114,10 +120,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 +174,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 +193,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
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { 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 { createStartAgentDeviceRemoteSessionBuildFunction } 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();
|
||||
|
||||
async function runAsync(
|
||||
logger: { info: jest.Mock; warn: jest.Mock },
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
launchInputs: Record<string, { value: unknown }> = {}
|
||||
): Promise<void> {
|
||||
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
|
||||
await buildFunction.fn!(
|
||||
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
max_duration_seconds: { value: undefined },
|
||||
...launchInputs,
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
}
|
||||
|
||||
describe('createStartAgentDeviceRemoteSessionBuildFunction 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() };
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.LINUX);
|
||||
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
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 the launch inputs to serve-sim and announces them on an iOS session', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
|
||||
await runAsync(logger, 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({
|
||||
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 the daemon when a launch is asked for on Android', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
|
||||
await expect(
|
||||
runAsync(logger, BuildRuntimePlatform.LINUX, {
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
})
|
||||
).rejects.toThrow('runs on linux');
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('declares the launch inputs', () => {
|
||||
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
|
||||
const globalCtx = createGlobalContextMock();
|
||||
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
|
||||
});
|
||||
});
|
||||
+199
-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,172 @@ 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('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',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
+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 => {
|
||||
|
||||
@@ -3,6 +3,12 @@ import spawn from '@expo/turtle-spawn';
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { configureSimulatorProxyEnvironmentAsync } from '../../utils/localEgress';
|
||||
import {
|
||||
installLocalEgressGuardAsync,
|
||||
resolveLocalEgressBootEnvironmentAsync,
|
||||
verifyLocalEgressGuardAsync,
|
||||
} from '../../utils/localEgressGuard';
|
||||
import { createStartIosSimulatorBuildFunction } from '../startIosSimulator';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
@@ -16,14 +22,36 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
getDeviceAsync: jest.fn(),
|
||||
cloneAsync: jest.fn(),
|
||||
enableAccessibilitySettingsAsync: jest.fn(),
|
||||
resolveUdidAsync: jest.fn(),
|
||||
bootAsync: jest.fn(),
|
||||
startAsync: jest.fn(),
|
||||
waitForReadyAsync: jest.fn(),
|
||||
disableApsdAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/localEgress', () => ({
|
||||
configureSimulatorProxyEnvironmentAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/localEgressGuard', () => ({
|
||||
installLocalEgressGuardAsync: jest.fn(),
|
||||
resolveLocalEgressBootEnvironmentAsync: jest.fn(),
|
||||
verifyLocalEgressGuardAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedUtils = jest.mocked(IosSimulatorUtils);
|
||||
const mockedConfigureProxyEnvironment = jest.mocked(configureSimulatorProxyEnvironmentAsync);
|
||||
const mockedInstallGuard = jest.mocked(installLocalEgressGuardAsync);
|
||||
const mockedVerifyGuard = jest.mocked(verifyLocalEgressGuardAsync);
|
||||
const mockedResolveBootEnvironment = jest.mocked(resolveLocalEgressBootEnvironmentAsync);
|
||||
|
||||
// Names resolve to udids the way the step expects; udids pass through.
|
||||
const UDIDS: Record<string, string> = {
|
||||
'iPhone 15': 'base',
|
||||
'eas-simulator-1': 'clone-1',
|
||||
'eas-simulator-2': 'clone-2',
|
||||
};
|
||||
|
||||
function createStep(callInputs?: Record<string, unknown>) {
|
||||
const logger = createMockLogger();
|
||||
@@ -41,27 +69,41 @@ describe(createStartIosSimulatorBuildFunction, () => {
|
||||
mockedUtils.getDeviceAsync.mockResolvedValue(null);
|
||||
mockedUtils.cloneAsync.mockResolvedValue(undefined);
|
||||
mockedUtils.enableAccessibilitySettingsAsync.mockResolvedValue(undefined);
|
||||
mockedUtils.startAsync.mockResolvedValue({ udid: 'test-udid' as any });
|
||||
mockedUtils.resolveUdidAsync.mockImplementation(
|
||||
async ({ deviceIdentifier }) => (UDIDS[deviceIdentifier] ?? deviceIdentifier) as any
|
||||
);
|
||||
mockedUtils.bootAsync.mockResolvedValue(undefined);
|
||||
mockedUtils.startAsync.mockImplementation(async ({ deviceIdentifier }) => ({
|
||||
udid: deviceIdentifier as any,
|
||||
}));
|
||||
mockedUtils.waitForReadyAsync.mockResolvedValue(undefined);
|
||||
mockedUtils.disableApsdAsync.mockResolvedValue(undefined);
|
||||
mockedConfigureProxyEnvironment.mockResolvedValue(false);
|
||||
mockedInstallGuard.mockResolvedValue(false);
|
||||
mockedVerifyGuard.mockResolvedValue(undefined);
|
||||
mockedResolveBootEnvironment.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('does not enable accessibility settings by default', async () => {
|
||||
await createStep({ device_identifier: 'iPhone 15' }).executeAsync();
|
||||
|
||||
expect(mockedUtils.enableAccessibilitySettingsAsync).not.toHaveBeenCalled();
|
||||
expect(mockedUtils.startAsync).toHaveBeenCalledWith({
|
||||
expect(mockedUtils.resolveUdidAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'iPhone 15',
|
||||
env: expect.any(Object),
|
||||
});
|
||||
expect(mockedUtils.bootAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'base',
|
||||
env: expect.any(Object),
|
||||
launchdEnvironment: {},
|
||||
});
|
||||
expect(mockedUtils.startAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'base',
|
||||
env: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it('enables accessibility settings before starting the main device and every clone when requested', async () => {
|
||||
mockedUtils.startAsync
|
||||
.mockResolvedValueOnce({ udid: 'base' as any })
|
||||
.mockResolvedValueOnce({ udid: 'clone-1' as any })
|
||||
.mockResolvedValueOnce({ udid: 'clone-2' as any });
|
||||
|
||||
await createStep({
|
||||
device_identifier: 'iPhone 15',
|
||||
count: 2,
|
||||
@@ -89,11 +131,6 @@ describe(createStartIosSimulatorBuildFunction, () => {
|
||||
});
|
||||
|
||||
it('disables apsd on the main device and every clone', async () => {
|
||||
mockedUtils.startAsync
|
||||
.mockResolvedValueOnce({ udid: 'base' as any })
|
||||
.mockResolvedValueOnce({ udid: 'clone-1' as any })
|
||||
.mockResolvedValueOnce({ udid: 'clone-2' as any });
|
||||
|
||||
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
|
||||
|
||||
expect(mockedUtils.disableApsdAsync).toHaveBeenCalledWith({
|
||||
@@ -110,6 +147,84 @@ describe(createStartIosSimulatorBuildFunction, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('installs the proxy environment and guard between boot and boot completion, then verifies the guard', async () => {
|
||||
mockedInstallGuard.mockResolvedValue(true);
|
||||
|
||||
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
|
||||
|
||||
const udids = ['base', 'clone-1', 'clone-2'];
|
||||
expect(
|
||||
mockedUtils.bootAsync.mock.calls.map(([{ deviceIdentifier }]) => deviceIdentifier)
|
||||
).toEqual(udids);
|
||||
expect(mockedConfigureProxyEnvironment.mock.calls.map(([{ udid }]) => udid)).toEqual(udids);
|
||||
expect(mockedInstallGuard.mock.calls.map(([{ udid }]) => udid)).toEqual(udids);
|
||||
expect(mockedVerifyGuard.mock.calls.map(([{ udid }]) => udid)).toEqual(udids);
|
||||
for (const [callIndex] of udids.entries()) {
|
||||
const bootOrder = mockedUtils.bootAsync.mock.invocationCallOrder[callIndex];
|
||||
const configureOrder = mockedConfigureProxyEnvironment.mock.invocationCallOrder[callIndex];
|
||||
const guardOrder = mockedInstallGuard.mock.invocationCallOrder[callIndex];
|
||||
const bootCompleteOrder = mockedUtils.startAsync.mock.invocationCallOrder[callIndex];
|
||||
const verifyOrder = mockedVerifyGuard.mock.invocationCallOrder[callIndex];
|
||||
const readyOrder = mockedUtils.waitForReadyAsync.mock.invocationCallOrder[callIndex];
|
||||
// launchd is up when boot returns and has spawned nothing yet: that is the
|
||||
// only moment at which its environment reaches every process of the boot.
|
||||
expect(guardOrder).toBeGreaterThan(bootOrder);
|
||||
expect(configureOrder).toBeGreaterThan(guardOrder);
|
||||
expect(configureOrder).toBeLessThan(bootCompleteOrder);
|
||||
// The self-check needs a completed boot and runs before anything else.
|
||||
expect(verifyOrder).toBeGreaterThan(bootCompleteOrder);
|
||||
expect(verifyOrder).toBeLessThan(readyOrder);
|
||||
}
|
||||
});
|
||||
|
||||
it('boots with the local egress environment so the first processes inherit it', async () => {
|
||||
mockedResolveBootEnvironment.mockResolvedValue({
|
||||
DYLD_INSERT_LIBRARIES: '/w/bin/egress-guard.dylib',
|
||||
https_proxy: 'http://127.0.0.1:8899',
|
||||
});
|
||||
|
||||
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
|
||||
|
||||
for (const [{ deviceIdentifier, launchdEnvironment }] of mockedUtils.bootAsync.mock.calls) {
|
||||
expect(['base', 'clone-1', 'clone-2']).toContain(deviceIdentifier);
|
||||
expect(launchdEnvironment).toEqual({
|
||||
DYLD_INSERT_LIBRARIES: '/w/bin/egress-guard.dylib',
|
||||
https_proxy: 'http://127.0.0.1:8899',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('boots with an empty launchd environment when no local egress session is active', async () => {
|
||||
await createStep({ device_identifier: 'iPhone 15' }).executeAsync();
|
||||
|
||||
expect(mockedUtils.bootAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'base',
|
||||
env: expect.any(Object),
|
||||
launchdEnvironment: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('skips the guard verification when no local egress session is active', async () => {
|
||||
mockedInstallGuard.mockResolvedValue(false);
|
||||
|
||||
await createStep({ device_identifier: 'iPhone 15' }).executeAsync();
|
||||
|
||||
expect(mockedVerifyGuard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails the step when the guard cannot be installed or verified', async () => {
|
||||
mockedInstallGuard.mockRejectedValueOnce(new Error('guard library missing'));
|
||||
await expect(createStep({ device_identifier: 'iPhone 15' }).executeAsync()).rejects.toThrow(
|
||||
'guard library missing'
|
||||
);
|
||||
|
||||
mockedInstallGuard.mockResolvedValue(true);
|
||||
mockedVerifyGuard.mockRejectedValueOnce(new Error('self-check failed'));
|
||||
await expect(createStep({ device_identifier: 'iPhone 15' }).executeAsync()).rejects.toThrow(
|
||||
'self-check failed'
|
||||
);
|
||||
});
|
||||
|
||||
it('continues when disabling apsd fails', async () => {
|
||||
mockedUtils.disableApsdAsync.mockRejectedValue(new Error('apsd disable failed'));
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ jest.mock('../../utils/localEgress', () => ({
|
||||
startChiselServerAsync: jest.fn(),
|
||||
writeLocalEgressHandoffAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/localEgressGuard', () => ({
|
||||
stopLocalEgressGuardRelaysAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
findAvailablePortAsync: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
@@ -28,8 +31,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, {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
|
||||
import * as sandboxDaemon from '../../utils/sandboxDaemon';
|
||||
|
||||
import { createStartSandboxBuildFunction, markSandboxReadyAsync } from '../startSandbox';
|
||||
|
||||
describe('sandbox build functions', () => {
|
||||
it('aborts an active ready request and keeps its authentication headers', async () => {
|
||||
const controller = new AbortController();
|
||||
let requestStarted!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
requestStarted = resolve;
|
||||
});
|
||||
const fetchMock = jest.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => {
|
||||
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer test-token');
|
||||
return await new Promise<Response>((_resolve, reject) => {
|
||||
init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason), { once: true });
|
||||
requestStarted();
|
||||
});
|
||||
});
|
||||
const graphqlClient = new Client({
|
||||
url: 'https://api.expo.test/graphql',
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer test-token' } },
|
||||
});
|
||||
try {
|
||||
const pending = markSandboxReadyAsync(
|
||||
{ graphqlClient } as any,
|
||||
'sandbox-id',
|
||||
controller.signal
|
||||
);
|
||||
const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' });
|
||||
await started;
|
||||
controller.abort();
|
||||
await rejected;
|
||||
} finally {
|
||||
fetchMock.mockRestore();
|
||||
}
|
||||
});
|
||||
it('does not mark the sandbox ready if startup was canceled', async () => {
|
||||
const controller = new AbortController();
|
||||
const stopAsync = jest.fn(async () => {});
|
||||
const start = jest
|
||||
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
|
||||
.mockImplementation(async options => {
|
||||
expect(options.signal).toBe(controller.signal);
|
||||
controller.abort();
|
||||
return { ready: Promise.resolve(), stopAsync };
|
||||
});
|
||||
const mutation = jest.fn();
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
graphqlClient: { mutation },
|
||||
} as any);
|
||||
try {
|
||||
await expect(
|
||||
fn.fn!({ logger: {} } as any, {
|
||||
inputs: { sandbox_id: { value: 'sandbox-id' } },
|
||||
outputs: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
start.mockRestore();
|
||||
}
|
||||
});
|
||||
it('excludes the MCP token from the command environment', async () => {
|
||||
const env = {
|
||||
PREPARED_BY_EARLIER_STEP: 'value',
|
||||
EXPO_TOKEN: 'expo-token',
|
||||
__EAS_SANDBOX_MCP_TOKEN: 'token',
|
||||
};
|
||||
const startupError = new Error('stop after checking options');
|
||||
const start = jest
|
||||
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
|
||||
.mockRejectedValue(startupError);
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
} as any);
|
||||
try {
|
||||
await expect(
|
||||
fn.fn!({ logger: {} } as any, {
|
||||
inputs: { sandbox_id: { value: 'sandbox-id' } },
|
||||
outputs: {},
|
||||
env,
|
||||
})
|
||||
).rejects.toBe(startupError);
|
||||
expect(start.mock.calls[0][0]).toMatchObject({ credential: 'token' });
|
||||
expect(start.mock.calls[0][0].env).toEqual({
|
||||
PREPARED_BY_EARLIER_STEP: 'value',
|
||||
EXPO_TOKEN: 'expo-token',
|
||||
});
|
||||
expect(env.__EAS_SANDBOX_MCP_TOKEN).toBe('token');
|
||||
} finally {
|
||||
start.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('throws a system error when the sandbox token is missing', async () => {
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
job: {},
|
||||
env: {},
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
fn.fn!({} as any, {
|
||||
inputs: { sandbox_id: { value: 'sandbox-id' } },
|
||||
outputs: {},
|
||||
env: {},
|
||||
})
|
||||
).rejects.toBeInstanceOf(SystemError);
|
||||
});
|
||||
|
||||
it('throws a system error when the MCP server URL is missing', async () => {
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
job: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'sandbox-token' },
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
fn.fn!({} as any, {
|
||||
inputs: { sandbox_id: { value: 'sandbox-id' } },
|
||||
outputs: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'sandbox-token' },
|
||||
})
|
||||
).rejects.toThrow('MCP server URL is required to start the sandbox daemon.');
|
||||
});
|
||||
|
||||
it('marks the sandbox as ready', async () => {
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({ data: { sandbox: { markSandboxReady: { id: 'sandbox-id' } } } }),
|
||||
}));
|
||||
|
||||
await markSandboxReadyAsync({ graphqlClient: { mutation } } as any, 'sandbox-id');
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sandboxId: 'sandbox-id' },
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a system error when marking the sandbox as ready fails', async () => {
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({ error: new Error('request failed') }),
|
||||
}));
|
||||
|
||||
await expect(
|
||||
markSandboxReadyAsync({ graphqlClient: { mutation } } as any, 'sandbox-id')
|
||||
).rejects.toThrow('Failed to mark sandbox sandbox-id as ready.');
|
||||
});
|
||||
});
|
||||
+102
-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,30 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: {
|
||||
previewUrl: 'https://web-preview.example.test',
|
||||
previewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewApiUrl: 'https://web-preview.example.test',
|
||||
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 +116,23 @@ 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,
|
||||
});
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
remoteConfig: { previewUrl: 'https://web-preview.example.test' },
|
||||
remoteConfig: {
|
||||
previewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewApiUrl: 'https://web-preview.example.test',
|
||||
},
|
||||
logger,
|
||||
});
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith({
|
||||
@@ -101,4 +144,49 @@ 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',
|
||||
'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('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();
|
||||
});
|
||||
});
|
||||
+22
-9
@@ -29,7 +29,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 +58,7 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
|
||||
try {
|
||||
const globalContext = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
runtimePlatform: platform,
|
||||
});
|
||||
const buildStep = createUploadDeviceRunSessionScreenRecordingsBuildFunction(
|
||||
{} as CustomBuildContext
|
||||
@@ -54,9 +67,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 +81,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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -108,6 +108,9 @@ function buildMaestroArgs({
|
||||
}
|
||||
case 'maestro-runner': {
|
||||
const args = [`--platform=${platform}`, 'test', `--output=${output}`, '--flatten'];
|
||||
if (shards !== undefined && shards > 1) {
|
||||
args.push(`--parallel=${shards}`);
|
||||
}
|
||||
if (includeTags) {
|
||||
args.push(`--include-tags=${includeTags}`);
|
||||
}
|
||||
@@ -259,12 +262,6 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
undefined,
|
||||
'android_connection_mode and EAS_MAESTRO_ANDROID_CONNECTION_MODE must be either "adb" or "dadb".'
|
||||
);
|
||||
if (backend === 'maestro-runner' && shards !== undefined && shards > 1) {
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_INVALID_INPUT',
|
||||
'maestro-runner does not support EAS Maestro test sharding. Remove shards or set it to 1.'
|
||||
);
|
||||
}
|
||||
if (backend === 'maestro-runner' && outputFormat !== undefined && outputFormat !== 'junit') {
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_INVALID_INPUT',
|
||||
|
||||
@@ -17,17 +17,27 @@ import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} 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 { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
@@ -42,6 +52,13 @@ 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(
|
||||
@@ -53,6 +70,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
name: 'Start agent device remote session',
|
||||
__metricsId: 'eas/start_agent_device_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -83,6 +101,14 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | 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 }
|
||||
);
|
||||
logger.info(
|
||||
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
@@ -110,19 +136,29 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
const agentDeviceRemoteSessionUrl = agentDeviceTunnel.url;
|
||||
logger.info(`Tunnel is ready at ${agentDeviceRemoteSessionUrl}.`);
|
||||
|
||||
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let eventCollection:
|
||||
| Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>>
|
||||
| undefined;
|
||||
let sessionFailed = false;
|
||||
try {
|
||||
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,
|
||||
});
|
||||
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,
|
||||
@@ -132,7 +168,8 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
remoteConfig: {
|
||||
agentDeviceRemoteSessionUrl,
|
||||
agentDeviceRemoteSessionToken: daemonToken,
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
@@ -165,24 +202,107 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
sessionFailed = true;
|
||||
throw error;
|
||||
} finally {
|
||||
if (webPreview) {
|
||||
await webPreview.stopAsync();
|
||||
}
|
||||
await agentDeviceTunnel.stopAsync();
|
||||
if (eventCollection) {
|
||||
await stopAgentDeviceEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
await daemonProcess.stopAsync();
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
['agent-device tunnel', agentDeviceTunnel.stopAsync()],
|
||||
[
|
||||
'agent-device daemon',
|
||||
(async () => {
|
||||
try {
|
||||
if (eventCollection) {
|
||||
await stopAgentDeviceEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await daemonProcess.stopAsync();
|
||||
}
|
||||
})(),
|
||||
],
|
||||
['session host', sessionHost?.finishAsync()],
|
||||
],
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function startAgentDeviceDaemonAsync({
|
||||
packageVersion,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): 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 });
|
||||
|
||||
const daemonPath = getInstalledAgentDeviceDaemonPath(installDir);
|
||||
if (!fs.existsSync(daemonPath)) {
|
||||
throw new SystemError(`Expected agent-device daemon entry at ${daemonPath}.`);
|
||||
}
|
||||
|
||||
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 });
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopAgentDeviceEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
@@ -205,61 +325,6 @@ 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,
|
||||
@@ -276,14 +341,17 @@ async function startAgentDeviceDaemonFromGitAsync({
|
||||
);
|
||||
await cloneAgentDeviceAsync({ packageVersion, env, logger });
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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'],
|
||||
@@ -346,11 +414,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 +426,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 (
|
||||
|
||||
@@ -19,18 +19,27 @@ import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
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,
|
||||
finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
@@ -54,6 +63,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
name: 'Start Appium remote session',
|
||||
__metricsId: 'eas/start_appium_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -72,6 +82,14 @@ 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 versionSpec = resolveAppium3VersionSpec(packageVersion);
|
||||
|
||||
logger.info(
|
||||
@@ -121,7 +139,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 +152,20 @@ 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,
|
||||
});
|
||||
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
@@ -153,7 +179,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 +199,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 +238,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 +287,7 @@ export async function resolveAppiumDeviceAsync({
|
||||
}
|
||||
}
|
||||
|
||||
async function installAppiumAsync({
|
||||
export async function installAppiumAsync({
|
||||
versionSpec,
|
||||
driverName,
|
||||
env,
|
||||
@@ -265,9 +305,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,28 @@ import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
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 +75,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
name: 'Start argent remote session',
|
||||
__metricsId: 'eas/start_argent_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -98,6 +108,14 @@ 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 }
|
||||
);
|
||||
logger.info(
|
||||
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
@@ -113,42 +131,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 +185,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 +218,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 +232,23 @@ 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,
|
||||
});
|
||||
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 +258,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 +279,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 +362,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 +397,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 +427,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 +439,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,17 @@ import {
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { minBy } from 'lodash';
|
||||
|
||||
import { configureSimulatorProxyEnvironmentAsync } from '../utils/localEgress';
|
||||
import {
|
||||
installLocalEgressGuardAsync,
|
||||
resolveLocalEgressBootEnvironmentAsync,
|
||||
verifyLocalEgressGuardAsync,
|
||||
} from '../utils/localEgressGuard';
|
||||
|
||||
import {
|
||||
IosSimulatorName,
|
||||
IosSimulatorUtils,
|
||||
@@ -73,9 +81,10 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
|
||||
env,
|
||||
});
|
||||
}
|
||||
const { udid } = await IosSimulatorUtils.startAsync({
|
||||
const udid = await bootWithLocalEgressAsync({
|
||||
deviceIdentifier: originalDeviceIdentifier,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -116,9 +125,10 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
|
||||
env,
|
||||
});
|
||||
}
|
||||
const { udid: cloneUdid } = await IosSimulatorUtils.startAsync({
|
||||
const cloneUdid = await bootWithLocalEgressAsync({
|
||||
deviceIdentifier: cloneDeviceName,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -140,6 +150,43 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* and before it has spawned anything else, which is the only moment at which
|
||||
* launchd environment reaches every process of the boot. The proxy variables
|
||||
* and the guard are set in that gap; then the boot is waited for and the
|
||||
* guard is verified in a fresh process. Nothing here applies when no local
|
||||
* egress session is active.
|
||||
*/
|
||||
async function bootWithLocalEgressAsync({
|
||||
deviceIdentifier,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<IosSimulatorUuid> {
|
||||
const udid = await IosSimulatorUtils.resolveUdidAsync({ deviceIdentifier, env });
|
||||
// The guard and proxy variables ride the boot itself, so launchd has them
|
||||
// before it spawns its first process.
|
||||
const launchdEnvironment = await resolveLocalEgressBootEnvironmentAsync();
|
||||
await IosSimulatorUtils.bootAsync({
|
||||
deviceIdentifier: udid,
|
||||
env,
|
||||
launchdEnvironment: launchdEnvironment ?? {},
|
||||
});
|
||||
// Also set them through launchctl, for a device that was already booted.
|
||||
const guardInstalled = await installLocalEgressGuardAsync({ udid, env, logger });
|
||||
await configureSimulatorProxyEnvironmentAsync({ udid, env, logger });
|
||||
await IosSimulatorUtils.startAsync({ deviceIdentifier: udid, env });
|
||||
if (guardInstalled) {
|
||||
await verifyLocalEgressGuardAsync({ udid, env, logger });
|
||||
}
|
||||
return udid;
|
||||
}
|
||||
|
||||
async function findMostGenericIphoneUuidAsync({
|
||||
env,
|
||||
}: {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
stopLocalEgressResourcesAsync,
|
||||
writeLocalEgressHandoffAsync,
|
||||
} from '../utils/localEgress';
|
||||
import { stopLocalEgressGuardRelaysAsync } from '../utils/localEgressGuard';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
type NgrokTunnelHandle,
|
||||
@@ -75,9 +76,10 @@ function awaitLocalEgressAcquisitionAsync<T>(
|
||||
/**
|
||||
* Points the device host's system HTTP(S) proxy at the EAS CLI's egress client.
|
||||
* Must run before `eas/start_ios_simulator`: the simulator reads the system proxy
|
||||
* at boot. Nothing else on the host changes, so requests from libraries that
|
||||
* bypass the system proxy are not covered; the shared session monitor reports
|
||||
* them. The shared session cleanup releases the resources started here when
|
||||
* 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.
|
||||
*/
|
||||
export function createStartLocalEgressBuildFunction(): BuildFunction {
|
||||
@@ -102,6 +104,7 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
|
||||
const lifetimeSignal = registerLocalEgressResources(async () => {
|
||||
await setupFinished;
|
||||
const results = await Promise.allSettled([
|
||||
Promise.resolve().then(() => stopLocalEgressGuardRelaysAsync(logger)),
|
||||
Promise.resolve().then(() => tunnel?.stopAsync()),
|
||||
Promise.resolve().then(() => server?.stopAsync()),
|
||||
]);
|
||||
@@ -183,9 +186,10 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
|
||||
logger.info(
|
||||
`Local egress is configured on network service "${service}". HTTP(S) and WebSocket ` +
|
||||
'requests that honor the system proxy (WebKit, URLSession and other CFNetwork clients) ' +
|
||||
'fail until the EAS CLI egress client connects, then exit from that machine. Requests ' +
|
||||
'from libraries that bypass the system proxy are not covered and exit from this worker. ' +
|
||||
'Connection sampling may report these bypasses, but can miss short connections and unconnected UDP traffic.'
|
||||
'fail until the EAS CLI egress client connects, then exit from that machine. Once the ' +
|
||||
'Simulator boots, proxy environment variables are set inside it for clients that read ' +
|
||||
'them (gRPC, libcurl), and the local egress guard is installed so that connections which ' +
|
||||
'ignore both are refused in the process that makes them and reported here.'
|
||||
);
|
||||
} catch (error) {
|
||||
finishSetup();
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import { graphql } from 'gql.tada';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import { withLogPhaseAsync } from '../../utils/logPhase';
|
||||
import { startSandboxDaemonAsync } from '../utils/sandboxDaemon';
|
||||
|
||||
const MARK_SANDBOX_READY_MUTATION = graphql(`
|
||||
mutation MarkSandboxReady($sandboxId: ID!) {
|
||||
sandbox {
|
||||
markSandboxReady(sandboxId: $sandboxId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const RECONNECT_DELAY_MS = 1_000;
|
||||
const AWAIT_SANDBOX_STEP_NAME = 'Await sandbox completion';
|
||||
|
||||
export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_sandbox',
|
||||
name: 'Start sandbox daemon',
|
||||
__metricsId: 'eas/start_sandbox',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'sandbox_id',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, signal, env }) => {
|
||||
// The daemon needs this credential, but shell commands do not.
|
||||
const { __EAS_SANDBOX_MCP_TOKEN: sandboxToken, ...commandEnv } = env;
|
||||
if (!sandboxToken) {
|
||||
throw new SystemError('__EAS_SANDBOX_MCP_TOKEN is required to start the sandbox daemon.');
|
||||
}
|
||||
const mcpServerUrl = ctx.mcpServerUrl;
|
||||
if (!mcpServerUrl) {
|
||||
throw new SystemError('MCP server URL is required to start the sandbox daemon.');
|
||||
}
|
||||
const sandboxId = String(inputs.sandbox_id.value);
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential: sandboxToken,
|
||||
serverUrl: mcpServerUrl,
|
||||
reconnectDelayMs: RECONNECT_DELAY_MS,
|
||||
logger: stepCtx.logger,
|
||||
signal,
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
env: commandEnv,
|
||||
});
|
||||
try {
|
||||
await daemon.ready;
|
||||
signal?.throwIfAborted();
|
||||
await markSandboxReadyAsync(ctx, sandboxId, signal);
|
||||
signal?.throwIfAborted();
|
||||
stepCtx.logger.info('Sandbox daemon started.');
|
||||
|
||||
await withLogPhaseAsync(stepCtx.logger, AWAIT_SANDBOX_STEP_NAME, async () => {
|
||||
await waitForCancellationAsync(signal);
|
||||
});
|
||||
} finally {
|
||||
await daemon.stopAsync();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function markSandboxReadyAsync(
|
||||
ctx: CustomBuildContext,
|
||||
sandboxId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(MARK_SANDBOX_READY_MUTATION, { sandboxId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
signal?.throwIfAborted();
|
||||
if (result.error) {
|
||||
throw new SystemError(`Failed to mark sandbox ${sandboxId} as ready.`, {
|
||||
cause: result.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCancellationAsync(signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>(resolve => {
|
||||
signal?.addEventListener('abort', () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
@@ -6,15 +6,18 @@ import {
|
||||
} from '@expo/steps';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import {
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
@@ -29,6 +32,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
name: 'Start web preview remote session',
|
||||
__metricsId: 'eas/start_serve_sim_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -46,31 +50,47 @@ 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 }
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
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,
|
||||
remoteConfig: {
|
||||
previewUrl: webPreview.previewUrl,
|
||||
previewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken ? { previewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
@@ -84,7 +104,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
signal,
|
||||
});
|
||||
} finally {
|
||||
await webPreview.stopAsync();
|
||||
await sessionHost.finishAsync();
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
+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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
DeviceRunSessionTimeoutError,
|
||||
withDeviceRunSessionTimeoutAsync,
|
||||
} from '../deviceRunSessionTimeout';
|
||||
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('aborts a stalled operation when its deadline expires', async () => {
|
||||
jest.useFakeTimers();
|
||||
let signal: AbortSignal | undefined;
|
||||
const operation = withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'test operation', timeoutMs: 100 },
|
||||
async value => {
|
||||
signal = value;
|
||||
return await new Promise<void>(() => {});
|
||||
}
|
||||
);
|
||||
const rejected = expect(operation).rejects.toThrow('test operation timed out after 100ms');
|
||||
const typed = expect(operation).rejects.toBeInstanceOf(DeviceRunSessionTimeoutError);
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
await rejected;
|
||||
await typed;
|
||||
expect(signal?.aborted).toBe(true);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('does not start an operation with an already aborted parent', async () => {
|
||||
const operation = jest.fn();
|
||||
await expect(
|
||||
withDeviceRunSessionTimeoutAsync(
|
||||
{
|
||||
name: 'test',
|
||||
timeoutMs: 100,
|
||||
signal: AbortSignal.abort(new Error('parent ended')),
|
||||
},
|
||||
operation
|
||||
)
|
||||
).rejects.toThrow('parent ended');
|
||||
expect(operation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears the deadline when an operation succeeds', async () => {
|
||||
jest.useFakeTimers();
|
||||
await expect(
|
||||
withDeviceRunSessionTimeoutAsync({ name: 'test', timeoutMs: 100 }, async () => 42)
|
||||
).resolves.toBe(42);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores a deadline reset that arrives after the operation settled', async () => {
|
||||
jest.useFakeTimers();
|
||||
let resetDeadline!: () => void;
|
||||
await expect(
|
||||
withDeviceRunSessionTimeoutAsync({ name: 'test', timeoutMs: 100 }, async (_signal, reset) => {
|
||||
resetDeadline = reset;
|
||||
return 42;
|
||||
})
|
||||
).resolves.toBe(42);
|
||||
resetDeadline();
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps an operation alive while it reports progress', async () => {
|
||||
jest.useFakeTimers();
|
||||
const operation = withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'test operation', timeoutMs: 100 },
|
||||
async (signal, resetDeadline) => {
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 80));
|
||||
resetDeadline();
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 80));
|
||||
resetDeadline();
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 80));
|
||||
return signal.aborted;
|
||||
}
|
||||
);
|
||||
const settled = expect(operation).resolves.toBe(false);
|
||||
await jest.advanceTimersByTimeAsync(240);
|
||||
await settled;
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,463 @@
|
||||
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 { createReadStream } from 'node:fs';
|
||||
import { copyFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
|
||||
import type { Socket } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import {
|
||||
findUnlistedDeviceScreenRecordingsAsync,
|
||||
uploadDeviceRunSessionScreenRecordingsAsync,
|
||||
} from '../deviceRunSessionScreenRecordings';
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>(r => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
artifactId: 'recording-id',
|
||||
name: 'Recording',
|
||||
filename: 'recording.mp4',
|
||||
kind: 'screen-recording',
|
||||
size: 16,
|
||||
};
|
||||
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[];
|
||||
|
||||
beforeEach(async () => {
|
||||
creates = 0;
|
||||
authorization = undefined;
|
||||
stallCreation = false;
|
||||
creationBodies = [];
|
||||
creationStarted = deferred();
|
||||
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
|
||||
server = createServer((request, response) => {
|
||||
if (request.url === '/graphql') {
|
||||
creates++;
|
||||
authorization = request.headers.authorization;
|
||||
creationStarted.resolve(request);
|
||||
request.setEncoding('utf8');
|
||||
let body = '';
|
||||
request.on('data', chunk => (body += chunk));
|
||||
request.on('end', () => creationBodies.push(body));
|
||||
request.on('end', () => {
|
||||
if (stallCreation) {
|
||||
return;
|
||||
}
|
||||
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('replays the full file after a transient PUT failure without recreating the upload session', async () => {
|
||||
const bodies: string[] = [];
|
||||
handle = (request, response) => {
|
||||
const chunks: string[] = [];
|
||||
request.setEncoding('utf8');
|
||||
request.on('data', chunk => chunks.push(chunk));
|
||||
request.on('end', () => {
|
||||
bodies.push(chunks.join(''));
|
||||
response.statusCode = bodies.length === 1 ? 503 : 200;
|
||||
response.end();
|
||||
});
|
||||
};
|
||||
const file = path.join(directory, 'recording.mp4');
|
||||
const payload = '*'.repeat(128 * 1024);
|
||||
await writeFile(file, payload);
|
||||
const streams: Readable[] = [];
|
||||
const open = () => {
|
||||
const stream = createReadStream(file);
|
||||
streams.push(stream);
|
||||
return stream;
|
||||
};
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
size: payload.length,
|
||||
stream: open(),
|
||||
reopenStream: open,
|
||||
});
|
||||
expect(creates).toBe(1);
|
||||
expect(authorization).toBe('Bearer test-auth');
|
||||
expect(bodies).toEqual([payload, payload]);
|
||||
expect(streams).toHaveLength(2);
|
||||
expect(streams.every(stream => stream.destroyed)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a slow PUT alive while its body is still flowing', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
||||
let received = 0;
|
||||
let onChunk = () => {};
|
||||
handle = (request, response) => {
|
||||
request.on('data', chunk => {
|
||||
received += chunk.length;
|
||||
onChunk();
|
||||
});
|
||||
request.on('end', () => {
|
||||
response.statusCode = 200;
|
||||
response.end();
|
||||
});
|
||||
};
|
||||
const waitForBytes = (total: number) =>
|
||||
new Promise<void>(resolve => {
|
||||
onChunk = () => {
|
||||
if (received >= total) {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
onChunk();
|
||||
});
|
||||
const stream = new Readable({ read() {} });
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, { ...metadata, size: 3, stream });
|
||||
const settled = expect(upload).resolves.toBeUndefined();
|
||||
stream.push('a');
|
||||
await waitForBytes(1);
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
stream.push('b');
|
||||
await waitForBytes(2);
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
stream.push('c');
|
||||
stream.push(null);
|
||||
await settled;
|
||||
expect(received).toBe(3);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('retries a PUT that stalled past the idle deadline with a fresh stream', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
||||
let puts = 0;
|
||||
const firstBodyRead = deferred<void>();
|
||||
handle = (request, response) => {
|
||||
puts++;
|
||||
request.resume();
|
||||
request.on('end', () => {
|
||||
if (puts === 1) {
|
||||
firstBodyRead.resolve();
|
||||
return;
|
||||
}
|
||||
response.statusCode = 200;
|
||||
response.end();
|
||||
});
|
||||
};
|
||||
const streams: Readable[] = [];
|
||||
const open = () => {
|
||||
const stream = Readable.from('recording');
|
||||
streams.push(stream);
|
||||
return stream;
|
||||
};
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
size: 9,
|
||||
stream: open(),
|
||||
reopenStream: open,
|
||||
});
|
||||
const settled = expect(upload).resolves.toBeUndefined();
|
||||
await firstBodyRead.promise;
|
||||
await jest.advanceTimersByTimeAsync(90_000);
|
||||
await jest.advanceTimersByTimeAsync(500);
|
||||
await settled;
|
||||
expect(puts).toBe(2);
|
||||
expect(streams).toHaveLength(2);
|
||||
expect(streams.every(stream => stream.destroyed)).toBe(true);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('cancels an active PUT socket and destroys its source without retrying', async () => {
|
||||
const started = deferred<void>();
|
||||
const closed = deferred<void>();
|
||||
let puts = 0;
|
||||
handle = request => {
|
||||
puts++;
|
||||
request.socket.once('close', () => closed.resolve());
|
||||
request.on('data', () => started.resolve());
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const stream = new Readable({ read() {} });
|
||||
stream.push(Buffer.alloc(1024));
|
||||
const reopenStream = jest.fn(() => Readable.from('unexpected retry'));
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
stream,
|
||||
reopenStream,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const rejected = expect(upload).rejects.toThrow('test cancellation');
|
||||
await started.promise;
|
||||
controller.abort(new Error('test cancellation'));
|
||||
await rejected;
|
||||
await closed.promise;
|
||||
expect(stream.destroyed).toBe(true);
|
||||
expect(reopenStream).not.toHaveBeenCalled();
|
||||
expect(puts).toBe(1);
|
||||
});
|
||||
|
||||
it.each([200, 400, 503])(
|
||||
'closes a stalled HTTP %s response before leaving or retrying the PUT',
|
||||
async status => {
|
||||
const sockets = new Set<Socket>();
|
||||
let puts = 0;
|
||||
let maxOpenSockets = 0;
|
||||
handle = (request, response) => {
|
||||
puts++;
|
||||
sockets.add(request.socket);
|
||||
maxOpenSockets = Math.max(maxOpenSockets, sockets.size);
|
||||
request.socket.once('close', () => sockets.delete(request.socket));
|
||||
request.resume();
|
||||
request.on('end', () => {
|
||||
response.writeHead(status, { 'Content-Length': '1000' });
|
||||
response.flushHeaders();
|
||||
response.write('partial response');
|
||||
});
|
||||
};
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
stream: Readable.from('recording'),
|
||||
reopenStream: () => Readable.from('recording'),
|
||||
});
|
||||
if (status === 200) {
|
||||
await expect(upload).resolves.toBeUndefined();
|
||||
} else {
|
||||
await expect(upload).rejects.toThrow(`HTTP ${status}`);
|
||||
}
|
||||
for (let i = 0; i < 100 && sockets.size > 0; i++) {
|
||||
await delay(10);
|
||||
}
|
||||
expect(creates).toBe(1);
|
||||
expect(puts).toBe(status === 503 ? 3 : 1);
|
||||
expect(maxOpenSockets).toBe(1);
|
||||
expect(sockets.size).toBe(0);
|
||||
}
|
||||
);
|
||||
|
||||
it('cancels urql session creation with auth intact and never starts a PUT', async () => {
|
||||
stallCreation = true;
|
||||
const puts = jest.fn();
|
||||
handle = puts;
|
||||
const controller = new AbortController();
|
||||
const stream = Readable.from('recording');
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
stream,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const rejected = expect(upload).rejects.toThrow('test cancellation');
|
||||
const request = await creationStarted.promise;
|
||||
const closed = new Promise<void>(resolve => request.socket.once('close', resolve));
|
||||
controller.abort(new Error('test cancellation'));
|
||||
await rejected;
|
||||
await closed;
|
||||
expect(authorization).toBe('Bearer test-auth');
|
||||
expect(creates).toBe(1);
|
||||
expect(puts).not.toHaveBeenCalled();
|
||||
expect(stream.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it('says ffprobe is missing instead of calling an unlisted recording undecodable', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const session = path.join(directory, 'session');
|
||||
await mkdir(session);
|
||||
await writeFile(path.join(session, 'recording.mp4.partial'), new Uint8Array(28));
|
||||
await writeFile(
|
||||
path.join(session, 'session.json'),
|
||||
JSON.stringify({
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
status: 'recording',
|
||||
recording: 'recording.mp4.partial',
|
||||
})
|
||||
);
|
||||
|
||||
const recordings = await findUnlistedDeviceScreenRecordingsAsync({
|
||||
root: directory,
|
||||
env: { ...process.env, PATH: path.join(directory, 'no-tools') } as BuildStepEnv,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(recordings).toEqual([]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'ffprobe is not available; cannot recover the unlisted recording session/recording.mp4.partial.'
|
||||
);
|
||||
});
|
||||
|
||||
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.',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,446 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import { access, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import {
|
||||
findUnlistedDeviceScreenRecordingsAsync,
|
||||
uploadDeviceRunSessionScreenRecordingsAsync,
|
||||
} from '../deviceRunSessionScreenRecordings';
|
||||
import { startDeviceSessionHostAsync } from '../deviceSessionHost';
|
||||
import { spawnDetached } from '../remoteDeviceRunSession';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../serveSimMetricsRecorder', () => ({
|
||||
readServeSimServersAsync: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ udid: 'emulator-5554', token: 'preview-token' }]),
|
||||
}));
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../remoteDeviceRunSession'),
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
fetchWebPreviewTurnArgsAsync: jest.fn().mockResolvedValue([]),
|
||||
spawnDetached: jest.fn(),
|
||||
}));
|
||||
jest.mock('../deviceRunSessionScreenRecordings', () => ({
|
||||
...jest.requireActual('../deviceRunSessionScreenRecordings'),
|
||||
findUnlistedDeviceScreenRecordingsAsync: jest.fn(),
|
||||
uploadDeviceRunSessionScreenRecordingsAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>(r => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const env = {
|
||||
DEVICE_RUN_SESSION_ID: 'drs-id',
|
||||
NGROK_AUTHTOKEN: 'token',
|
||||
} as BuildStepEnv;
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const stopServer = jest.fn();
|
||||
const closeTunnel = jest.fn();
|
||||
const directories: string[] = [];
|
||||
const baseDomain = 'preview.example.test';
|
||||
|
||||
async function startHostAsync() {
|
||||
return await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
stopServer.mockResolvedValue(undefined);
|
||||
closeTunnel.mockResolvedValue(undefined);
|
||||
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockReset().mockResolvedValue(false);
|
||||
jest.mocked(findUnlistedDeviceScreenRecordingsAsync).mockReset().mockResolvedValue([]);
|
||||
jest.mocked(spawnDetached).mockImplementation(options => {
|
||||
const flag = options.args.indexOf('--android-recording-directory');
|
||||
if (flag >= 0) {
|
||||
directories.push(options.args[flag + 1]);
|
||||
}
|
||||
return {
|
||||
pid: undefined,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopServer,
|
||||
};
|
||||
});
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://public.example.test',
|
||||
close: closeTunnel,
|
||||
} as never);
|
||||
jest.mocked(turtleFetch).mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
ok: true,
|
||||
json: async () => ({ status: 'ready', device: 'emulator-5554' }),
|
||||
}) as Awaited<ReturnType<typeof turtleFetch>>
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
jest.useRealTimers();
|
||||
await Promise.all(
|
||||
directories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))
|
||||
);
|
||||
});
|
||||
|
||||
it('records by default with no preview and finalizes before process stop, uploading exactly once', async () => {
|
||||
const host = await startHostAsync();
|
||||
expect(ngrok.forward).not.toHaveBeenCalled();
|
||||
const directory = directories[0];
|
||||
const recordings = [
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
directory: path.join(directory, 'session'),
|
||||
},
|
||||
];
|
||||
await writeFile(path.join(directory, 'recordings.json'), JSON.stringify(recordings));
|
||||
await mkdir(recordings[0].directory);
|
||||
const started = deferred<void>();
|
||||
const response = deferred<Awaited<ReturnType<typeof turtleFetch>>>();
|
||||
jest.mocked(turtleFetch).mockImplementationOnce(async () => {
|
||||
started.resolve();
|
||||
return await response.promise;
|
||||
});
|
||||
const finishing = host.finishAsync();
|
||||
expect(host.finishAsync()).toBe(finishing);
|
||||
await started.promise;
|
||||
expect(stopServer).not.toHaveBeenCalled();
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
|
||||
const token =
|
||||
jest.mocked(spawnDetached).mock.calls[0][0].env.EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN;
|
||||
expect(token).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(turtleFetch).toHaveBeenLastCalledWith(
|
||||
expect.stringMatching(/\/_eas\/android-recording\/stop$/),
|
||||
'POST',
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
retries: 0,
|
||||
shouldThrowOnNotOk: false,
|
||||
signal: expect.any(AbortSignal),
|
||||
}
|
||||
);
|
||||
response.resolve({ ok: true } as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
await finishing;
|
||||
await host.finishAsync();
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).toHaveBeenCalledWith(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
recordings,
|
||||
});
|
||||
expect(stopServer.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
await expect(host.openPreviewAsync({ baseDomain })).rejects.toThrow(
|
||||
'after session host finalization'
|
||||
);
|
||||
});
|
||||
|
||||
it('coalesces preview opens and allows close/reopen without ending capture', async () => {
|
||||
const host = await startHostAsync();
|
||||
const opening = host.openPreviewAsync({ baseDomain });
|
||||
expect(host.openPreviewAsync({ baseDomain })).toBe(opening);
|
||||
const first = await opening;
|
||||
const closing = first.closeAsync();
|
||||
expect(first.closeAsync()).toBe(closing);
|
||||
await closing;
|
||||
const secondClose = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(ngrok.forward).mockResolvedValueOnce({
|
||||
url: () => 'https://replacement.example.test',
|
||||
close: secondClose,
|
||||
} as never);
|
||||
const second = await host.openPreviewAsync({ baseDomain });
|
||||
await first.closeAsync();
|
||||
expect(await host.openPreviewAsync({ baseDomain })).toBe(second);
|
||||
expect(secondClose).not.toHaveBeenCalled();
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(1);
|
||||
expect(stopServer).not.toHaveBeenCalled();
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(turtleFetch).mock.calls.some(([, method]) => method === 'POST')).toBe(false);
|
||||
await host.finishAsync();
|
||||
expect(closeTunnel).toHaveBeenCalledTimes(1);
|
||||
expect(secondClose).toHaveBeenCalledTimes(1);
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries a failed tunnel without restarting the host', async () => {
|
||||
const host = await startHostAsync();
|
||||
jest.mocked(ngrok.forward).mockRejectedValueOnce(new Error('tunnel unavailable'));
|
||||
await expect(host.openPreviewAsync({ baseDomain })).rejects.toThrow('tunnel unavailable');
|
||||
expect(stopServer).not.toHaveBeenCalled();
|
||||
await host.openPreviewAsync({ baseDomain });
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(1);
|
||||
await host.finishAsync();
|
||||
expect(closeTunnel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drains an in-flight tunnel when finishing and rejects new opens', async () => {
|
||||
const host = await startHostAsync();
|
||||
const tunnel = deferred<Awaited<ReturnType<typeof ngrok.forward>>>();
|
||||
jest.mocked(ngrok.forward).mockReturnValueOnce(tunnel.promise);
|
||||
const opening = host.openPreviewAsync({ baseDomain });
|
||||
const finishing = host.finishAsync();
|
||||
await expect(host.openPreviewAsync({ baseDomain })).rejects.toThrow(
|
||||
'after session host finalization'
|
||||
);
|
||||
tunnel.resolve({ url: () => 'https://late.example.test', close: closeTunnel } as never);
|
||||
await expect(opening).rejects.toThrow('finalized while the preview was opening');
|
||||
await finishing;
|
||||
expect(closeTunnel).toHaveBeenCalledTimes(1);
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps cleanup and upload best-effort when tunnel close and finalization fail', async () => {
|
||||
jest.mocked(spawnDetached).mockImplementationOnce(options => {
|
||||
directories.push(options.args[options.args.indexOf('--android-recording-directory') + 1]);
|
||||
return {
|
||||
pid: undefined,
|
||||
getOutput: () => '[serve-emu] emulator-5554 capture error: scrcpy exited with code 255',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopServer,
|
||||
};
|
||||
});
|
||||
const host = await startHostAsync();
|
||||
await writeFile(path.join(directories[0], 'recordings.json'), '[]');
|
||||
await host.openPreviewAsync({ baseDomain });
|
||||
closeTunnel.mockRejectedValueOnce(new Error('tunnel close failure'));
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => 'Error: scrcpy exited with code 255',
|
||||
} as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
await host.finishAsync();
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{
|
||||
err: expect.objectContaining({
|
||||
message: expect.stringContaining('HTTP 500: Error: scrcpy exited with code 255'),
|
||||
}),
|
||||
},
|
||||
'Could not finalize Android recording before shutdown.'
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not finalize Android recording before shutdown',
|
||||
expect.any(Error),
|
||||
{ level: 'warning' }
|
||||
);
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ hostOutput: '[serve-emu] emulator-5554 capture error: scrcpy exited with code 255' },
|
||||
'Session host output around the recording failure.'
|
||||
);
|
||||
});
|
||||
|
||||
it('logs the reason and skips upload and output dump when the Hub never recorded', async () => {
|
||||
jest.mocked(spawnDetached).mockImplementationOnce(options => {
|
||||
directories.push(options.args[options.args.indexOf('--android-recording-directory') + 1]);
|
||||
return {
|
||||
pid: undefined,
|
||||
getOutput: () => '[serve-emu] Android recording skipped',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopServer,
|
||||
};
|
||||
});
|
||||
const host = await startHostAsync();
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 409,
|
||||
text: async () => 'Android recording requires exactly one booted emulator; found 0.',
|
||||
} as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
await host.finishAsync();
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Android recording was not captured: Android recording requires exactly one booted emulator; found 0.'
|
||||
);
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'Session host output around the recording failure.'
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back failed host startup without replacing the original error', async () => {
|
||||
stopServer.mockRejectedValueOnce(new Error('stop failure'));
|
||||
await expect(
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 0,
|
||||
})
|
||||
).rejects.toThrow('Timed out waiting');
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
expect(ngrok.forward).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(turtleFetch).mock.calls.some(([, method]) => method === 'POST')).toBe(false);
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.stringMatching(/finalize|upload/)
|
||||
);
|
||||
});
|
||||
|
||||
async function writeRecordingDescriptorAsync(directory: string) {
|
||||
const child = path.join(directory, 'session');
|
||||
await mkdir(child);
|
||||
await writeFile(
|
||||
path.join(directory, 'recordings.json'),
|
||||
JSON.stringify([
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android',
|
||||
directory: child,
|
||||
},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
it.each([true, false])(
|
||||
'removes its recording root only when every upload succeeds (%s)',
|
||||
async uploaded => {
|
||||
const host = await startHostAsync();
|
||||
const directory = directories[0];
|
||||
await writeRecordingDescriptorAsync(directory);
|
||||
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockResolvedValueOnce(uploaded);
|
||||
await host.finishAsync();
|
||||
if (uploaded) {
|
||||
await expect(access(directory)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
} else {
|
||||
await expect(access(directory)).resolves.toBeUndefined();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('finishes despite a stalled tunnel close', async () => {
|
||||
const host = await startHostAsync();
|
||||
await host.openPreviewAsync({ baseDomain });
|
||||
const closing = deferred<void>();
|
||||
closeTunnel.mockReturnValueOnce(closing.promise);
|
||||
jest.useFakeTimers();
|
||||
const finishing = host.finishAsync();
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
await finishing;
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('deadline'));
|
||||
closing.resolve();
|
||||
});
|
||||
|
||||
it('aborts a stalled finalization request before stopping the host', async () => {
|
||||
const host = await startHostAsync();
|
||||
const response = deferred<Awaited<ReturnType<typeof turtleFetch>>>();
|
||||
jest.mocked(turtleFetch).mockReturnValueOnce(response.promise);
|
||||
jest.useFakeTimers();
|
||||
const finishing = host.finishAsync();
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
await finishing;
|
||||
const options = jest.mocked(turtleFetch).mock.calls.at(-1)?.[2];
|
||||
expect(options?.signal?.aborted).toBe(true);
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
response.resolve({ ok: true } as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
});
|
||||
|
||||
it('uploads the partial recording a killed host left behind and then removes the root', async () => {
|
||||
const host = await startHostAsync();
|
||||
const directory = directories[0];
|
||||
const child = path.join(directory, 'session');
|
||||
await mkdir(child);
|
||||
await writeFile(path.join(directory, 'recordings.json'), '[]');
|
||||
const partial = [
|
||||
{ udid: 'emulator-5554', deviceName: 'Pixel', runtimeDisplayName: 'Android', directory: child },
|
||||
];
|
||||
jest.mocked(findUnlistedDeviceScreenRecordingsAsync).mockResolvedValueOnce(partial);
|
||||
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockResolvedValueOnce(true);
|
||||
await host.finishAsync();
|
||||
expect(findUnlistedDeviceScreenRecordingsAsync).toHaveBeenCalledWith({
|
||||
root: directory,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).toHaveBeenCalledWith(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
recordings: partial,
|
||||
});
|
||||
await expect(access(directory)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
it('rejects descriptors outside its recording root', async () => {
|
||||
const host = await startHostAsync();
|
||||
const directory = directories[0];
|
||||
await writeFile(
|
||||
path.join(directory, 'recordings.json'),
|
||||
JSON.stringify([
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android',
|
||||
directory: path.dirname(directory),
|
||||
},
|
||||
])
|
||||
);
|
||||
await host.finishAsync();
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
|
||||
await expect(access(directory)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('retains recordings without uploading if the host cannot be stopped', async () => {
|
||||
const host = await startHostAsync();
|
||||
const directory = directories[0];
|
||||
await writeRecordingDescriptorAsync(directory);
|
||||
stopServer.mockRejectedValueOnce(new Error('process stop failed'));
|
||||
await host.finishAsync();
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
|
||||
await expect(access(directory)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('closes a listener that arrives after finish has already timed out waiting for it', async () => {
|
||||
const host = await startHostAsync();
|
||||
const tunnel = deferred<Awaited<ReturnType<typeof ngrok.forward>>>();
|
||||
jest.mocked(ngrok.forward).mockReturnValueOnce(tunnel.promise);
|
||||
const opening = host.openPreviewAsync({ baseDomain });
|
||||
const rejected = expect(opening).rejects.toThrow('finalized while the preview was opening');
|
||||
jest.useFakeTimers();
|
||||
const finishing = host.finishAsync();
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
await finishing;
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
tunnel.resolve({ url: () => 'https://late.example.test', close: closeTunnel } as never);
|
||||
await rejected;
|
||||
expect(closeTunnel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('leaves iOS recording to its existing build steps', async () => {
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const options = jest.mocked(spawnDetached).mock.calls[0][0];
|
||||
expect(options.args).not.toContain('--android-recording-directory');
|
||||
expect(options.env.EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN).toBeUndefined();
|
||||
await host.finishAsync();
|
||||
expect(jest.mocked(turtleFetch).mock.calls.some(([, method]) => method === 'POST')).toBe(false);
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -7,10 +8,13 @@ import { spawnDetached } from '../remoteDeviceRunSession';
|
||||
|
||||
import {
|
||||
CHISEL_VERSION,
|
||||
LOCAL_EGRESS_NO_PROXY,
|
||||
LOCAL_EGRESS_PROXY_PORT,
|
||||
buildEgressRemoteConfigFields,
|
||||
buildLocalEgressSimulatorEnvironment,
|
||||
buildNetworksetupProxyArgs,
|
||||
collectSimulatorProcessIds,
|
||||
configureSimulatorProxyEnvironmentAsync,
|
||||
createChiselAuthfileContents,
|
||||
getChiselAssetName,
|
||||
getChiselDownloadUrl,
|
||||
@@ -313,6 +317,125 @@ describe('local egress handoff', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildLocalEgressSimulatorEnvironment, () => {
|
||||
it('points every proxy variable at the loopback port and excludes loopback', () => {
|
||||
expect(buildLocalEgressSimulatorEnvironment(8899)).toEqual({
|
||||
http_proxy: 'http://127.0.0.1:8899',
|
||||
https_proxy: 'http://127.0.0.1:8899',
|
||||
HTTP_PROXY: 'http://127.0.0.1:8899',
|
||||
HTTPS_PROXY: 'http://127.0.0.1:8899',
|
||||
grpc_proxy: 'http://127.0.0.1:8899',
|
||||
no_proxy: LOCAL_EGRESS_NO_PROXY,
|
||||
NO_PROXY: LOCAL_EGRESS_NO_PROXY,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(configureSimulatorProxyEnvironmentAsync, () => {
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
let tempDir: string;
|
||||
let logger: bunyan;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockedSpawn.mockReset();
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'local-egress-env-test-'));
|
||||
logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('does nothing when no local egress session is active', async () => {
|
||||
await expect(
|
||||
configureSimulatorProxyEnvironmentAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath: path.join(tempDir, 'missing.json'),
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets the proxy environment in the simulator when a handoff exists', async () => {
|
||||
const handoffPath = path.join(tempDir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(
|
||||
{ url: 'https://egress.example', token: 'pw', fingerprint: 'fp=', port: 8899 },
|
||||
handoffPath
|
||||
);
|
||||
|
||||
await expect(
|
||||
configureSimulatorProxyEnvironmentAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(mockedSpawn.mock.calls.map(([, args]) => args)).toEqual([
|
||||
[
|
||||
'simctl',
|
||||
'spawn',
|
||||
'test-udid',
|
||||
'launchctl',
|
||||
'setenv',
|
||||
...Object.entries(buildLocalEgressSimulatorEnvironment(8899)).flat(),
|
||||
],
|
||||
]);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('proxy environment variables set')
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and continues when launchctl fails', async () => {
|
||||
const handoffPath = path.join(tempDir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(
|
||||
{ url: 'https://egress.example', token: 'pw', fingerprint: 'fp=', port: 8899 },
|
||||
handoffPath
|
||||
);
|
||||
mockedSpawn.mockRejectedValue(new Error('launchctl failed'));
|
||||
|
||||
await expect(
|
||||
configureSimulatorProxyEnvironmentAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
expect.stringContaining('will bypass local egress and exit from this worker')
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and continues when the handoff is malformed', async () => {
|
||||
const handoffPath = path.join(tempDir, 'handoff.json');
|
||||
await fs.promises.writeFile(handoffPath, JSON.stringify({ url: 'x' }), 'utf8');
|
||||
|
||||
await expect(
|
||||
configureSimulatorProxyEnvironmentAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
expect.stringContaining('could not read the local egress handoff')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(startChiselServerAsync, () => {
|
||||
const options = {
|
||||
chiselPath: '/tmp/chisel',
|
||||
@@ -331,6 +454,7 @@ describe(startChiselServerAsync, () => {
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 12345,
|
||||
getOutput: () => output,
|
||||
getExitError: () => undefined,
|
||||
stopAsync,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* End-to-end test of the local egress guard against a real iOS Simulator.
|
||||
* Needs Xcode with a simulator runtime, a shut-down iPhone simulator, and
|
||||
* network access. Opt in with EAS_LOCAL_EGRESS_E2E=1; the test-egress-guard
|
||||
* EAS workflow runs it on macOS.
|
||||
*
|
||||
* Variables handed to launchd at boot cannot be changed afterwards with
|
||||
* `launchctl setenv`, so each configuration gets its own boot.
|
||||
*/
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import dgram from 'node:dgram';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../../utils/IosSimulatorUtils';
|
||||
import { writeLocalEgressHandoffAsync } from '../localEgress';
|
||||
import {
|
||||
type GuardEvent,
|
||||
installLocalEgressGuardAsync,
|
||||
parseGuardLogLine,
|
||||
reportLocalEgressGuardCoverageAsync,
|
||||
resolveEgressGuardCheckAsync,
|
||||
resolveEgressGuardLibraryAsync,
|
||||
resolveLocalEgressBootEnvironmentAsync,
|
||||
stopLocalEgressGuardRelaysAsync,
|
||||
verifyLocalEgressGuardAsync,
|
||||
} from '../localEgressGuard';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
jest.setTimeout(15 * 60 * 1000);
|
||||
|
||||
const enabled = process.platform === 'darwin' && process.env.EAS_LOCAL_EGRESS_E2E === '1';
|
||||
const describeE2E = enabled ? describe : describe.skip;
|
||||
|
||||
const GUARD_DIR = path.join(__dirname, '..', '..', '..', '..', 'resources', 'egress-guard');
|
||||
const env = process.env;
|
||||
|
||||
function createLogger(): bunyan & { lines: string[] } {
|
||||
const lines: string[] = [];
|
||||
const record = (a: unknown, b?: unknown) => {
|
||||
lines.push(typeof a === 'string' ? a : String(b));
|
||||
};
|
||||
return { info: record, warn: record, debug: record, lines } as any;
|
||||
}
|
||||
|
||||
async function readEventsAsync(logPath: string): Promise<GuardEvent[]> {
|
||||
let raw = '';
|
||||
try {
|
||||
raw = await fs.promises.readFile(logPath, 'utf8');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return raw
|
||||
.split('\n')
|
||||
.map(parseGuardLogLine)
|
||||
.filter((e): e is GuardEvent => e !== null);
|
||||
}
|
||||
|
||||
async function waitForAsync<T>(
|
||||
probe: () => Promise<T | undefined>,
|
||||
{ timeoutMs, intervalMs = 1000 }: { timeoutMs: number; intervalMs?: number }
|
||||
): Promise<T | undefined> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const value = await probe();
|
||||
if (value !== undefined) {
|
||||
return value;
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describeE2E('local egress guard in a simulator', () => {
|
||||
let workDir: string;
|
||||
let udid: IosSimulatorUuid;
|
||||
let libraryPath: string;
|
||||
let checkPath: string;
|
||||
let nettestPath: string;
|
||||
let handoffPath: string;
|
||||
let proxyServer: http.Server;
|
||||
let proxyPort: number;
|
||||
let udpServer: dgram.Socket;
|
||||
let udpPort: number;
|
||||
const logger = createLogger();
|
||||
|
||||
/** Shut the device down and boot it again with the given launchd environment. */
|
||||
async function rebootAsync(launchdEnvironment: Record<string, string>): Promise<void> {
|
||||
await spawn('xcrun', ['simctl', 'shutdown', udid], { env, stdio: 'pipe' }).catch(() => {});
|
||||
await IosSimulatorUtils.bootAsync({ deviceIdentifier: udid, env, launchdEnvironment });
|
||||
await IosSimulatorUtils.startAsync({ deviceIdentifier: udid, env });
|
||||
}
|
||||
|
||||
async function bootEnvironmentAsync(
|
||||
mode: 'block' | 'log',
|
||||
logPath: string
|
||||
): Promise<Record<string, string>> {
|
||||
const variables = await resolveLocalEgressBootEnvironmentAsync({
|
||||
handoffPath,
|
||||
libraryPath,
|
||||
logPath,
|
||||
mode,
|
||||
});
|
||||
if (!variables) {
|
||||
throw new Error('The handoff was not written.');
|
||||
}
|
||||
return variables;
|
||||
}
|
||||
|
||||
async function runNettestAsync(): Promise<Record<string, string>> {
|
||||
const result = await spawn(
|
||||
'xcrun',
|
||||
[
|
||||
'simctl',
|
||||
'spawn',
|
||||
udid,
|
||||
nettestPath,
|
||||
'--proxy-port',
|
||||
String(proxyPort),
|
||||
'--udp-port',
|
||||
String(udpPort),
|
||||
],
|
||||
{ env, stdio: 'pipe' }
|
||||
);
|
||||
const last = result.stdout.trim().split('\n').at(-1) ?? '{}';
|
||||
return JSON.parse(last);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'egress-guard-e2e-'));
|
||||
|
||||
// The guard library and self-check, built on demand.
|
||||
let resolved = await resolveEgressGuardLibraryAsync();
|
||||
if (!resolved) {
|
||||
await spawn('bash', [path.join(GUARD_DIR, 'build.sh')], { env, stdio: 'pipe' });
|
||||
resolved = await resolveEgressGuardLibraryAsync();
|
||||
}
|
||||
if (!resolved) {
|
||||
throw new Error('egress-guard.dylib is not available and could not be built.');
|
||||
}
|
||||
libraryPath = resolved;
|
||||
const resolvedCheck = await resolveEgressGuardCheckAsync();
|
||||
if (!resolvedCheck) {
|
||||
throw new Error('egress-guard-check is not available and could not be built.');
|
||||
}
|
||||
checkPath = resolvedCheck;
|
||||
|
||||
// The probe binary, compiled for the simulator running on this host.
|
||||
nettestPath = path.join(workDir, 'nettest');
|
||||
await spawn(
|
||||
'xcrun',
|
||||
[
|
||||
'-sdk',
|
||||
'iphonesimulator',
|
||||
'swiftc',
|
||||
'-target',
|
||||
`${process.arch === 'arm64' ? 'arm64' : 'x86_64'}-apple-ios15.0-simulator`,
|
||||
path.join(GUARD_DIR, 'tests', 'nettest.swift'),
|
||||
'-o',
|
||||
nettestPath,
|
||||
],
|
||||
{ env, stdio: 'pipe' }
|
||||
);
|
||||
|
||||
handoffPath = path.join(workDir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(
|
||||
{ url: 'https://egress.example', token: 'pw', fingerprint: 'fp=', port: 8899 },
|
||||
handoffPath
|
||||
);
|
||||
|
||||
// A shut-down iPhone the test can boot as many times as it needs. A device
|
||||
// someone else booted is left alone. Nothing is ever deleted.
|
||||
const booted = new Set(
|
||||
(await IosSimulatorUtils.getAvailableDevicesAsync({ env, filter: 'booted' })).map(d => d.udid)
|
||||
);
|
||||
const available = await IosSimulatorUtils.getAvailableDevicesAsync({
|
||||
env,
|
||||
filter: 'available',
|
||||
});
|
||||
const iphone =
|
||||
available.find(d => /^iPhone \d/.test(d.name) && !booted.has(d.udid)) ??
|
||||
available.find(d => /^iPhone/.test(d.name) && !booted.has(d.udid));
|
||||
if (!iphone) {
|
||||
throw new Error('No shut-down iPhone simulator is available for the test to boot.');
|
||||
}
|
||||
udid = iphone.udid;
|
||||
|
||||
// Loopback stand-ins for the egress proxy and for a local UDP service.
|
||||
proxyServer = http.createServer((_req, res) => {
|
||||
res.writeHead(502);
|
||||
res.end();
|
||||
});
|
||||
proxyServer.on('connect', (req, socket, head) => {
|
||||
const [host, port] = String(req.url).split(':');
|
||||
const upstream = net.connect(Number(port) || 443, host, () => {
|
||||
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
||||
if (head.length) {
|
||||
upstream.write(new Uint8Array(head));
|
||||
}
|
||||
upstream.pipe(socket);
|
||||
socket.pipe(upstream);
|
||||
});
|
||||
upstream.on('error', () => socket.destroy());
|
||||
socket.on('error', () => upstream.destroy());
|
||||
});
|
||||
await new Promise<void>(resolve => proxyServer.listen(0, '127.0.0.1', () => resolve()));
|
||||
proxyPort = (proxyServer.address() as net.AddressInfo).port;
|
||||
|
||||
udpServer = dgram.createSocket('udp4');
|
||||
udpServer.on('message', (msg, rinfo) =>
|
||||
udpServer.send(new Uint8Array(msg), rinfo.port, rinfo.address)
|
||||
);
|
||||
await new Promise<void>(resolve => udpServer.bind(0, '127.0.0.1', () => resolve()));
|
||||
udpPort = udpServer.address().port;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await stopLocalEgressGuardRelaysAsync(logger);
|
||||
proxyServer?.close();
|
||||
udpServer?.close();
|
||||
if (udid) {
|
||||
await spawn('xcrun', ['simctl', 'shutdown', udid], { env, stdio: 'pipe' }).catch(() => {});
|
||||
}
|
||||
if (workDir) {
|
||||
await fs.promises.rm(workDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('booted with the guard in launchd, as the worker boots', () => {
|
||||
let logPath: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
logPath = path.join(workDir, 'boot.log');
|
||||
await rebootAsync(await bootEnvironmentAsync('block', logPath));
|
||||
await verifyLocalEgressGuardAsync({ udid, env, logger, mode: 'block', checkPath });
|
||||
});
|
||||
|
||||
it('covers every process of the boot', async () => {
|
||||
const coverage = await reportLocalEgressGuardCoverageAsync({ env, logger });
|
||||
expect(coverage).not.toBeNull();
|
||||
expect(coverage!.covered.length).toBeGreaterThan(50);
|
||||
expect(coverage!.uncovered).toEqual([]);
|
||||
// Refusals were recorded by processes that started during the boot, before
|
||||
// any launchctl call could have reached them.
|
||||
expect((await readEventsAsync(logPath)).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('refuses every direct path and leaves loopback and DNS alone', async () => {
|
||||
const results = await runNettestAsync();
|
||||
|
||||
expect(results).toMatchObject({
|
||||
urlsessionDirect: 'refused',
|
||||
urlsessionProxied: 'ok(200)',
|
||||
bsdConnectDirect: 'refused',
|
||||
dns: 'ok',
|
||||
udpDirect: 'refused',
|
||||
udpLoopback: 'ok',
|
||||
literalFirst: 'refused',
|
||||
literalSecond: 'refused',
|
||||
unspecConnect: 'refused',
|
||||
udpDisconnect: 'ok',
|
||||
nocancelSendto: 'refused',
|
||||
});
|
||||
expect(results.nwconnectionDirect).not.toMatch(/^ok|^timeout/);
|
||||
|
||||
const events = (await readEventsAsync(logPath)).filter(e => e.process === 'nettest');
|
||||
expect(events.length).toBeGreaterThanOrEqual(4);
|
||||
expect(events.every(e => e.action === 'blocked')).toBe(true);
|
||||
// URLSession and Network.framework connects are caught inside Apple's frameworks.
|
||||
expect(
|
||||
events.some(e => e.function === 'connect' && e.callers.some(c => /CFNetwork/.test(c)))
|
||||
).toBe(true);
|
||||
expect(
|
||||
events.some(
|
||||
e =>
|
||||
e.function === 'connect' &&
|
||||
e.callers.some(c => /Network/.test(c) && !/CFNetwork/.test(c))
|
||||
)
|
||||
).toBe(true);
|
||||
// UDP is covered, through the cancelable and non-cancelable entry points.
|
||||
expect(events.some(e => e.function === 'sendto' && e.peer === '8.8.8.8:53')).toBe(true);
|
||||
// An AF_UNSPEC sockaddr is recorded as what the kernel would have used.
|
||||
expect(events.some(e => e.function === 'connect' && e.peer === '1.0.0.1:443')).toBe(true);
|
||||
// The callers name the process and frameworks, never the guard itself.
|
||||
expect(events.every(e => !e.callers.some(c => /egress-guard/.test(c)))).toBe(true);
|
||||
// One line per destination per process, even when the process retries.
|
||||
expect(events.filter(e => e.function === 'connect' && e.peer === '1.1.1.1:443')).toHaveLength(
|
||||
1
|
||||
);
|
||||
// Nothing on loopback is ever reported.
|
||||
expect(events.some(e => /^127\./.test(e.peer))).toBe(false);
|
||||
});
|
||||
|
||||
it('guards processes the simulator launches on its own', async () => {
|
||||
await spawn('xcrun', ['simctl', 'openurl', udid, 'https://example.net/'], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const daemonEvent = await waitForAsync(
|
||||
async () =>
|
||||
(await readEventsAsync(logPath)).find(
|
||||
e => e.process !== 'nettest' && e.action === 'blocked'
|
||||
),
|
||||
{ timeoutMs: 60_000 }
|
||||
);
|
||||
expect(daemonEvent).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not interfere with the simulator tooling the worker relies on', async () => {
|
||||
const screenshot = path.join(workDir, 'screenshot.png');
|
||||
for (const args of [
|
||||
['simctl', 'spawn', udid, 'launchctl', 'list'],
|
||||
[
|
||||
'simctl',
|
||||
'spawn',
|
||||
udid,
|
||||
'defaults',
|
||||
'write',
|
||||
'dev.expo.egress-guard-test',
|
||||
'key',
|
||||
'-string',
|
||||
'value',
|
||||
],
|
||||
['simctl', 'spawn', udid, '/usr/bin/env'],
|
||||
['simctl', 'launch', udid, 'com.apple.Preferences'],
|
||||
['simctl', 'io', udid, 'screenshot', screenshot],
|
||||
]) {
|
||||
await expect(spawn('xcrun', args, { env, stdio: 'pipe' })).resolves.toBeDefined();
|
||||
}
|
||||
expect(fs.existsSync(screenshot)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('booted in log mode', () => {
|
||||
let logPath: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
logPath = path.join(workDir, 'log-mode.log');
|
||||
await rebootAsync(await bootEnvironmentAsync('log', logPath));
|
||||
await verifyLocalEgressGuardAsync({ udid, env, logger, mode: 'log', checkPath });
|
||||
});
|
||||
|
||||
it('observes without refusing', async () => {
|
||||
const results = await runNettestAsync();
|
||||
|
||||
expect(results.urlsessionDirect).toBe('ok(200)');
|
||||
expect(results.bsdConnectDirect).toBe('ok');
|
||||
expect(results.udpDirect).toBe('sent');
|
||||
const events = (await readEventsAsync(logPath)).filter(e => e.process === 'nettest');
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events.every(e => e.action === 'logged')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('booted without the guard, as a device that was already running', () => {
|
||||
beforeAll(async () => {
|
||||
await rebootAsync({});
|
||||
});
|
||||
|
||||
it('is reported as not in effect by the self-check', async () => {
|
||||
await expect(
|
||||
verifyLocalEgressGuardAsync({ udid, env, logger, mode: 'block', checkPath })
|
||||
).rejects.toThrow(/not in effect/);
|
||||
});
|
||||
|
||||
it('can be installed after boot through launchctl for processes started from then on', async () => {
|
||||
const logPath = path.join(workDir, 'install.log');
|
||||
expect(
|
||||
await installLocalEgressGuardAsync({
|
||||
udid,
|
||||
env,
|
||||
logger,
|
||||
handoffPath,
|
||||
libraryPath,
|
||||
logPath,
|
||||
mode: 'block',
|
||||
tailIntervalMs: 100,
|
||||
})
|
||||
).toBe(true);
|
||||
await verifyLocalEgressGuardAsync({ udid, env, logger, mode: 'block', checkPath });
|
||||
|
||||
const results = await runNettestAsync();
|
||||
expect(results.urlsessionDirect).toBe('refused');
|
||||
expect(results.bsdConnectDirect).toBe('refused');
|
||||
|
||||
// The relay turns the guard's events into session log lines.
|
||||
const relayed = await waitForAsync(
|
||||
async () =>
|
||||
logger.lines.find(l =>
|
||||
/refused connect from nettest \(pid \d+\) to 1\.1\.1\.1:443/.test(l)
|
||||
),
|
||||
{ timeoutMs: 5_000, intervalMs: 200 }
|
||||
);
|
||||
expect(relayed).toBeDefined();
|
||||
|
||||
// Processes from before the install are the uncovered set the coverage
|
||||
// report names; nettest itself was covered.
|
||||
const coverage = await reportLocalEgressGuardCoverageAsync({ env, logger });
|
||||
expect(coverage!.uncovered.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('booted with an unwritable event log', () => {
|
||||
beforeAll(async () => {
|
||||
await rebootAsync(
|
||||
await bootEnvironmentAsync('block', path.join(workDir, 'no-such-dir', 'guard.log'))
|
||||
);
|
||||
});
|
||||
|
||||
it('still refuses', async () => {
|
||||
await verifyLocalEgressGuardAsync({ udid, env, logger, mode: 'block', checkPath });
|
||||
const results = await runNettestAsync();
|
||||
expect(results.urlsessionDirect).toBe('refused');
|
||||
expect(results.bsdConnectDirect).toBe('refused');
|
||||
expect(results.urlsessionProxied).toBe('ok(200)');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,565 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { writeLocalEgressHandoffAsync } from '../localEgress';
|
||||
import {
|
||||
EGRESS_GUARD_LOG_ENV,
|
||||
EGRESS_GUARD_MODE_ENV,
|
||||
GuardEventRelay,
|
||||
GuardLogTailer,
|
||||
buildGuardLaunchdEnvironment,
|
||||
installLocalEgressGuardAsync,
|
||||
mergeGuardCoverageSamples,
|
||||
parseGuardCoverage,
|
||||
parseGuardLogLine,
|
||||
rebindLocalEgressGuardRelays,
|
||||
resolveEgressGuardLibraryAsync,
|
||||
resolveLocalEgressBootEnvironmentAsync,
|
||||
stopLocalEgressGuardRelaysAsync,
|
||||
verifyLocalEgressGuardAsync,
|
||||
} from '../localEgressGuard';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
IosSimulatorUtils: { setLaunchdEnvironmentAsync: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockedSetEnv = jest.mocked(IosSimulatorUtils.setLaunchdEnvironmentAsync);
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
function createLogger(): bunyan & { lines: { level: string; msg: string }[] } {
|
||||
const lines: { level: string; msg: string }[] = [];
|
||||
const record = (level: string) => (a: unknown, b?: unknown) =>
|
||||
lines.push({ level, msg: typeof a === 'string' ? a : String(b) });
|
||||
return { info: record('info'), warn: record('warn'), debug: record('debug'), lines } as any;
|
||||
}
|
||||
|
||||
const handoff = { url: 'https://egress.example', token: 'pw', fingerprint: 'fp=', port: 8899 };
|
||||
|
||||
describe(buildGuardLaunchdEnvironment, () => {
|
||||
it('inserts the library and passes the log path and mode', () => {
|
||||
expect(
|
||||
buildGuardLaunchdEnvironment({
|
||||
libraryPath: '/w/bin/egress-guard.dylib',
|
||||
logPath: '/tmp/guard.log',
|
||||
mode: 'block',
|
||||
})
|
||||
).toEqual({
|
||||
DYLD_INSERT_LIBRARIES: '/w/bin/egress-guard.dylib',
|
||||
[EGRESS_GUARD_LOG_ENV]: '/tmp/guard.log',
|
||||
[EGRESS_GUARD_MODE_ENV]: 'block',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseGuardLogLine, () => {
|
||||
it('parses a blocked event with callers', () => {
|
||||
expect(
|
||||
parseGuardLogLine(
|
||||
'eas-egress-guard\tMyApp\t4242\tconnect\tblocked\t93.184.216.34:443\tNetwork,CFNetwork,MyApp'
|
||||
)
|
||||
).toEqual({
|
||||
process: 'MyApp',
|
||||
pid: 4242,
|
||||
function: 'connect',
|
||||
action: 'blocked',
|
||||
peer: '93.184.216.34:443',
|
||||
callers: ['Network', 'CFNetwork', 'MyApp'],
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a logged event without callers and IPv6 peers', () => {
|
||||
expect(
|
||||
parseGuardLogLine('eas-egress-guard\tdaemon\t7\tsendto\tlogged\t[2606:4700::1]:53\t')
|
||||
).toEqual({
|
||||
process: 'daemon',
|
||||
pid: 7,
|
||||
function: 'sendto',
|
||||
action: 'logged',
|
||||
peer: '[2606:4700::1]:53',
|
||||
callers: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores lines that are not guard events', () => {
|
||||
expect(parseGuardLogLine('')).toBeNull();
|
||||
expect(parseGuardLogLine('something else\ta\tb')).toBeNull();
|
||||
expect(
|
||||
parseGuardLogLine('eas-egress-guard\tMyApp\tnot-a-pid\tconnect\tblocked\t1.1.1.1:443\t')
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseGuardLogLine('eas-egress-guard\tMyApp\t1\tconnect\tmaybe\t1.1.1.1:443\t')
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(GuardEventRelay, () => {
|
||||
const blocked = (process: string, peer: string, pid = 1) => ({
|
||||
process,
|
||||
pid,
|
||||
function: 'connect',
|
||||
action: 'blocked' as const,
|
||||
peer,
|
||||
callers: ['Network', 'CFNetwork'],
|
||||
});
|
||||
|
||||
it('logs the first sighting of a process and destination, then only counts', () => {
|
||||
const logger = createLogger();
|
||||
const relay = new GuardEventRelay(logger);
|
||||
relay.handle(blocked('MyApp', '1.1.1.1:443'));
|
||||
relay.handle(blocked('MyApp', '1.1.1.1:443', 2));
|
||||
relay.handle(blocked('MyApp', '2.2.2.2:443'));
|
||||
expect(logger.lines.map(l => l.msg)).toEqual([
|
||||
expect.stringContaining(
|
||||
'refused connect from MyApp (pid 1) to 1.1.1.1:443; callers: Network, CFNetwork'
|
||||
),
|
||||
expect.stringContaining('refused connect from MyApp (pid 1) to 2.2.2.2:443'),
|
||||
]);
|
||||
expect(relay.summary()).toEqual({ blocked: 3, logged: 0, distinct: 2, suppressed: 0 });
|
||||
});
|
||||
|
||||
it('words observed events differently and stops logging past the limit', () => {
|
||||
const logger = createLogger();
|
||||
const relay = new GuardEventRelay(logger, /* limit */ 1);
|
||||
relay.handle({ ...blocked('MyApp', '1.1.1.1:443'), action: 'logged' });
|
||||
relay.handle({ ...blocked('MyApp', '2.2.2.2:443'), action: 'logged' });
|
||||
expect(logger.lines).toHaveLength(1);
|
||||
expect(logger.lines[0].msg).toContain('observed connect from MyApp');
|
||||
expect(relay.summary()).toEqual({ blocked: 0, logged: 2, distinct: 2, suppressed: 1 });
|
||||
});
|
||||
|
||||
it('explains a process that filled its table instead of counting it as a destination', () => {
|
||||
const logger = createLogger();
|
||||
const relay = new GuardEventRelay(logger);
|
||||
relay.handle(blocked('MyApp', '1.1.1.1:443'));
|
||||
relay.handle({
|
||||
...blocked('MyApp', '128 distinct destinations'),
|
||||
function: 'overflow',
|
||||
callers: [],
|
||||
});
|
||||
expect(logger.lines.at(-1)?.msg).toContain(
|
||||
'MyApp (pid 1) reached the limit of 128 distinct destinations listed per process; further distinct destinations from it are refused but not listed'
|
||||
);
|
||||
expect(relay.summary()).toEqual({ blocked: 1, logged: 0, distinct: 1, suppressed: 0 });
|
||||
});
|
||||
|
||||
it('ignores the self-check probe, which trips the guard on purpose', () => {
|
||||
const logger = createLogger();
|
||||
const relay = new GuardEventRelay(logger);
|
||||
relay.handle(blocked('egress-guard-check', '192.0.2.1:9'));
|
||||
expect(logger.lines).toHaveLength(0);
|
||||
expect(relay.summary()).toEqual({ blocked: 0, logged: 0, distinct: 0, suppressed: 0 });
|
||||
});
|
||||
|
||||
it('writes a summary line', () => {
|
||||
const logger = createLogger();
|
||||
const relay = new GuardEventRelay(logger);
|
||||
relay.handle(blocked('MyApp', '1.1.1.1:443'));
|
||||
relay.handle(blocked('MyApp', '1.1.1.1:443'));
|
||||
relay.handle(blocked('daemon', '3.3.3.3:443', 9));
|
||||
relay.logSummary();
|
||||
expect(logger.lines.at(-1)?.msg).toContain(
|
||||
'refused 3 connection attempt(s) to 2 distinct destination(s) from 2 process(es)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(GuardLogTailer, () => {
|
||||
let dir: string;
|
||||
beforeEach(async () => {
|
||||
dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'guard-tailer-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('delivers whole lines as they are appended, keeps partial lines, and survives truncation', async () => {
|
||||
const file = path.join(dir, 'guard.log');
|
||||
const lines: string[] = [];
|
||||
const tailer = new GuardLogTailer({ path: file, onLine: l => lines.push(l), intervalMs: 20 });
|
||||
tailer.start();
|
||||
await sleep(50); // file does not exist yet; must not throw
|
||||
await fs.promises.writeFile(file, 'first\nsecond\npart', 'utf8');
|
||||
await sleep(80);
|
||||
expect(lines).toEqual(['first', 'second']);
|
||||
await fs.promises.appendFile(file, 'ial\n', 'utf8');
|
||||
await sleep(80);
|
||||
expect(lines).toEqual(['first', 'second', 'partial']);
|
||||
await fs.promises.writeFile(file, 'after-truncate\n', 'utf8');
|
||||
await sleep(80);
|
||||
expect(lines).toEqual(['first', 'second', 'partial', 'after-truncate']);
|
||||
await tailer.stopAsync();
|
||||
await fs.promises.appendFile(file, 'late\n', 'utf8');
|
||||
await sleep(60);
|
||||
expect(lines).not.toContain('late');
|
||||
});
|
||||
|
||||
it('flushes what is already in the file when stopped', async () => {
|
||||
const file = path.join(dir, 'guard.log');
|
||||
const lines: string[] = [];
|
||||
const tailer = new GuardLogTailer({
|
||||
path: file,
|
||||
onLine: l => lines.push(l),
|
||||
intervalMs: 10_000,
|
||||
});
|
||||
tailer.start();
|
||||
await fs.promises.writeFile(file, 'only\n', 'utf8');
|
||||
await tailer.stopAsync();
|
||||
expect(lines).toEqual(['only']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveEgressGuardLibraryAsync, () => {
|
||||
it('returns null when the library is not built', async () => {
|
||||
await expect(
|
||||
resolveEgressGuardLibraryAsync(path.join(os.tmpdir(), 'no-such-dir'))
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('returns the packaged path when present', async () => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'guard-bin-'));
|
||||
const lib = path.join(dir, 'egress-guard.dylib');
|
||||
await fs.promises.writeFile(lib, '');
|
||||
await expect(resolveEgressGuardLibraryAsync(dir)).resolves.toBe(lib);
|
||||
await fs.promises.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe(installLocalEgressGuardAsync, () => {
|
||||
let dir: string;
|
||||
let logger: ReturnType<typeof createLogger>;
|
||||
beforeEach(async () => {
|
||||
mockedSetEnv.mockReset();
|
||||
mockedSetEnv.mockResolvedValue(undefined);
|
||||
mockedSpawn.mockReset();
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'guard-install-'));
|
||||
logger = createLogger();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await stopLocalEgressGuardRelaysAsync(logger);
|
||||
await fs.promises.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('does nothing without a local egress session', async () => {
|
||||
await expect(
|
||||
installLocalEgressGuardAsync({
|
||||
udid: 'u' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath: path.join(dir, 'missing.json'),
|
||||
libraryPath: path.join(dir, 'egress-guard.dylib'),
|
||||
logPath: path.join(dir, 'guard.log'),
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
expect(mockedSetEnv).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails the session when the library is not available', async () => {
|
||||
const handoffPath = path.join(dir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
await expect(
|
||||
installLocalEgressGuardAsync({
|
||||
udid: 'u' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
libraryPath: null,
|
||||
logPath: path.join(dir, 'guard.log'),
|
||||
})
|
||||
).rejects.toThrow(SystemError);
|
||||
expect(mockedSetEnv).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs the simulator processes that were already running and are not covered', async () => {
|
||||
const handoffPath = path.join(dir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
const libraryPath = path.join(dir, 'egress-guard.dylib');
|
||||
await fs.promises.writeFile(libraryPath, '');
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: [
|
||||
' 1 0 /sbin/launchd',
|
||||
' 4000 1 /Library/Developer/CoreSimulator/Volumes/iOS/Runtimes/x/sbin/launchd_sim',
|
||||
' 4001 4000 /Library/Developer/CoreSimulator/Volumes/iOS/Runtimes/x/System/Library/CoreServices/SpringBoard.app/SpringBoard',
|
||||
' 4002 4000 /Library/Developer/CoreSimulator/Volumes/iOS/Runtimes/x/usr/libexec/backboardd',
|
||||
' 3758 1 node',
|
||||
].join('\n'),
|
||||
stderr: '',
|
||||
} as any);
|
||||
|
||||
await installLocalEgressGuardAsync({
|
||||
udid: 'u' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
libraryPath,
|
||||
logPath: path.join(dir, 'guard.log'),
|
||||
});
|
||||
|
||||
expect(
|
||||
logger.lines.some(l =>
|
||||
/2 simulator process\(es\) were already running .* not covered by it: SpringBoard, backboardd\./.test(
|
||||
l.msg
|
||||
)
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('sets the launchd environment, creates the log file, and relays events into the session log', async () => {
|
||||
const handoffPath = path.join(dir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
const libraryPath = path.join(dir, 'egress-guard.dylib');
|
||||
await fs.promises.writeFile(libraryPath, '');
|
||||
const logPath = path.join(dir, 'guard.log');
|
||||
|
||||
await expect(
|
||||
installLocalEgressGuardAsync({
|
||||
udid: 'u' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
libraryPath,
|
||||
logPath,
|
||||
mode: 'block',
|
||||
tailIntervalMs: 20,
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(mockedSetEnv).toHaveBeenCalledWith({
|
||||
udid: 'u',
|
||||
env: process.env,
|
||||
variables: buildGuardLaunchdEnvironment({ libraryPath, logPath, mode: 'block' }),
|
||||
});
|
||||
expect(fs.existsSync(logPath)).toBe(true);
|
||||
expect(logger.lines.some(l => /guard installed/.test(l.msg) && /block/.test(l.msg))).toBe(true);
|
||||
|
||||
await fs.promises.appendFile(
|
||||
logPath,
|
||||
'eas-egress-guard\tMyApp\t4242\tconnect\tblocked\t1.1.1.1:443\tNetwork\n'
|
||||
);
|
||||
await sleep(80);
|
||||
expect(
|
||||
logger.lines.some(l =>
|
||||
/refused connect from MyApp \(pid 4242\) to 1\.1\.1\.1:443/.test(l.msg)
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
await stopLocalEgressGuardRelaysAsync(logger);
|
||||
expect(logger.lines.at(-1)?.msg).toContain('refused 1 connection attempt(s)');
|
||||
});
|
||||
|
||||
it('attributes later events and the summary to a rebound logger', async () => {
|
||||
const handoffPath = path.join(dir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
const libraryPath = path.join(dir, 'egress-guard.dylib');
|
||||
await fs.promises.writeFile(libraryPath, '');
|
||||
const logPath = path.join(dir, 'guard.log');
|
||||
await installLocalEgressGuardAsync({
|
||||
udid: 'u' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
libraryPath,
|
||||
logPath,
|
||||
tailIntervalMs: 20,
|
||||
});
|
||||
|
||||
const sessionLogger = createLogger();
|
||||
rebindLocalEgressGuardRelays(sessionLogger);
|
||||
await fs.promises.appendFile(
|
||||
logPath,
|
||||
'eas-egress-guard\tMyApp\t4242\tconnect\tblocked\t1.1.1.1:443\tNetwork\n'
|
||||
);
|
||||
await sleep(80);
|
||||
expect(sessionLogger.lines.some(l => /refused connect from MyApp/.test(l.msg))).toBe(true);
|
||||
expect(logger.lines.some(l => /refused connect from MyApp/.test(l.msg))).toBe(false);
|
||||
|
||||
await stopLocalEgressGuardRelaysAsync(logger);
|
||||
expect(sessionLogger.lines.at(-1)?.msg).toContain('refused 1 connection attempt(s)');
|
||||
});
|
||||
|
||||
it('fails the session when launchctl fails', async () => {
|
||||
const handoffPath = path.join(dir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
const libraryPath = path.join(dir, 'egress-guard.dylib');
|
||||
await fs.promises.writeFile(libraryPath, '');
|
||||
mockedSetEnv.mockRejectedValue(new Error('launchctl failed'));
|
||||
|
||||
await expect(
|
||||
installLocalEgressGuardAsync({
|
||||
udid: 'u' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
libraryPath,
|
||||
logPath: path.join(dir, 'guard.log'),
|
||||
})
|
||||
).rejects.toThrow(/Could not install the local egress guard/);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveLocalEgressBootEnvironmentAsync, () => {
|
||||
let dir: string;
|
||||
beforeEach(async () => {
|
||||
dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'guard-bootenv-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns null without a local egress session', async () => {
|
||||
await expect(
|
||||
resolveLocalEgressBootEnvironmentAsync({ handoffPath: path.join(dir, 'missing.json') })
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('fails when the guard library is not available', async () => {
|
||||
const handoffPath = path.join(dir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
await expect(
|
||||
resolveLocalEgressBootEnvironmentAsync({ handoffPath, libraryPath: null })
|
||||
).rejects.toThrow(SystemError);
|
||||
});
|
||||
|
||||
it('combines the guard variables with the proxy variables for the handoff port', async () => {
|
||||
const handoffPath = path.join(dir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync({ ...handoff, port: 8899 }, handoffPath);
|
||||
await expect(
|
||||
resolveLocalEgressBootEnvironmentAsync({
|
||||
handoffPath,
|
||||
libraryPath: '/w/bin/egress-guard.dylib',
|
||||
logPath: '/tmp/guard.log',
|
||||
mode: 'block',
|
||||
})
|
||||
).resolves.toEqual({
|
||||
DYLD_INSERT_LIBRARIES: '/w/bin/egress-guard.dylib',
|
||||
[EGRESS_GUARD_LOG_ENV]: '/tmp/guard.log',
|
||||
[EGRESS_GUARD_MODE_ENV]: 'block',
|
||||
http_proxy: 'http://127.0.0.1:8899',
|
||||
https_proxy: 'http://127.0.0.1:8899',
|
||||
HTTP_PROXY: 'http://127.0.0.1:8899',
|
||||
HTTPS_PROXY: 'http://127.0.0.1:8899',
|
||||
grpc_proxy: 'http://127.0.0.1:8899',
|
||||
no_proxy: 'localhost,127.0.0.1,::1',
|
||||
NO_PROXY: 'localhost,127.0.0.1,::1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseGuardCoverage, () => {
|
||||
const ps = [
|
||||
' 1 0 /sbin/launchd',
|
||||
' 4000 1 /Runtimes/x/sbin/launchd_sim',
|
||||
' 4001 4000 /Runtimes/x/System/Library/CoreServices/SpringBoard.app/SpringBoard',
|
||||
' 4002 4000 /Runtimes/x/usr/libexec/backboardd',
|
||||
' 4003 4001 /Containers/Bundle/Application/1/Expo Go.app/Expo Go',
|
||||
' 3758 1 node',
|
||||
].join('\n');
|
||||
const lsof = [
|
||||
'p4001',
|
||||
'n/Runtimes/x/System/Library/CoreServices/SpringBoard.app/SpringBoard',
|
||||
'n/usr/lib/dyld',
|
||||
'p4003',
|
||||
'n/Containers/Bundle/Application/1/Expo Go.app/Expo Go',
|
||||
'n/Users/expo/build-tools/bin/egress-guard.dylib',
|
||||
'p3758',
|
||||
'n/usr/local/bin/node',
|
||||
].join('\n');
|
||||
|
||||
it('splits simulator processes by whether the guard library is mapped', () => {
|
||||
expect(parseGuardCoverage(ps, lsof)).toEqual({
|
||||
covered: ['Expo Go'],
|
||||
uncovered: ['SpringBoard', 'backboardd'],
|
||||
uncoveredPids: [4001, 4002],
|
||||
});
|
||||
});
|
||||
|
||||
it('only keeps processes uncovered in both samples, so exec-in-progress ones drop out', () => {
|
||||
const first = parseGuardCoverage(ps, lsof);
|
||||
const secondPs = ps + '\n 4009 4000 /Runtimes/x/usr/libexec/newlystarted';
|
||||
const second = parseGuardCoverage(
|
||||
secondPs,
|
||||
lsof + '\np4002\nn/Users/expo/bin/egress-guard.dylib'
|
||||
);
|
||||
expect(mergeGuardCoverageSamples(first, second)).toEqual({
|
||||
covered: ['Expo Go', 'backboardd'],
|
||||
uncovered: ['SpringBoard'],
|
||||
uncoveredPids: [4001],
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores launchd's exec trampoline, which has nothing mapped yet", () => {
|
||||
const withTrampoline = ps + '\n 4004 4000 /Runtimes/x/usr/libexec/xpcproxy_sim';
|
||||
expect(parseGuardCoverage(withTrampoline, lsof).uncovered).toEqual([
|
||||
'SpringBoard',
|
||||
'backboardd',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(verifyLocalEgressGuardAsync, () => {
|
||||
let logger: ReturnType<typeof createLogger>;
|
||||
beforeEach(() => {
|
||||
mockedSpawn.mockReset();
|
||||
logger = createLogger();
|
||||
});
|
||||
|
||||
it('runs the packaged self-check inside the simulator and logs its verdict', async () => {
|
||||
mockedSpawn
|
||||
.mockResolvedValueOnce({
|
||||
stdout: 'egress-guard-check: guard loaded; non-loopback connections are refused\n',
|
||||
stderr: '',
|
||||
} as any)
|
||||
.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await verifyLocalEgressGuardAsync({
|
||||
udid: 'u' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
checkPath: '/w/bin/egress-guard-check',
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'spawn', 'u', '/w/bin/egress-guard-check', '--mode', 'block'],
|
||||
expect.objectContaining({ stdio: 'pipe' })
|
||||
);
|
||||
expect(
|
||||
logger.lines.some(l =>
|
||||
/verified in the Simulator: egress-guard-check: guard loaded/.test(l.msg)
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('fails the session with the self-check output when the check fails', async () => {
|
||||
mockedSpawn.mockRejectedValue(
|
||||
Object.assign(new Error('exited with non-zero code: 2'), {
|
||||
status: 2,
|
||||
stdout: 'egress-guard-check: the guard library is not loaded in this process\n',
|
||||
stderr: '',
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
verifyLocalEgressGuardAsync({
|
||||
udid: 'u' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
checkPath: '/w/bin/egress-guard-check',
|
||||
})
|
||||
).rejects.toThrow(/not in effect .*\(exit 2\).*guard library is not loaded/);
|
||||
});
|
||||
|
||||
it('fails the session when the self-check binary is missing', async () => {
|
||||
await expect(
|
||||
verifyLocalEgressGuardAsync({ udid: 'u' as any, env: process.env, logger, checkPath: null })
|
||||
).rejects.toThrow(/self-check is not available/);
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,32 +1,46 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, BuildStepEnv } from '@expo/steps';
|
||||
import { BuildRuntimePlatform, BuildStepEnv, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
clearTimeout as clearTimeoutCallback,
|
||||
setTimeout as setTimeoutCallback,
|
||||
} from 'node:timers';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
|
||||
import { sleepAsync } from '../../../utils/retry';
|
||||
import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
|
||||
import {
|
||||
createExpoDeviceHubArgs,
|
||||
createServeSimArgs,
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
metricsCorsOriginToServeSimArgs,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startExpoDeviceHubWithTunnelAsync,
|
||||
parseServeSimLaunchInputs,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
turnIceServersToWebPreviewArgs,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForWebPreviewReadyAsync,
|
||||
} from '../remoteDeviceRunSession';
|
||||
|
||||
import {
|
||||
createExpoDeviceHubArgs,
|
||||
createServeSimArgs,
|
||||
simulatorPreviewPageUrl,
|
||||
startDeviceSessionHostAsync,
|
||||
waitForWebPreviewReadyAsync,
|
||||
websiteOrigin,
|
||||
websiteOriginServeSimArgs,
|
||||
} from '../deviceSessionHost';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
jest.mock('node:timers');
|
||||
jest.mock('node:timers/promises');
|
||||
@@ -34,6 +48,10 @@ jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../../utils/retry', () => ({ sleepAsync: jest.fn() }));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../deviceRunSessionScreenRecordings', () => ({
|
||||
...jest.requireActual('../deviceRunSessionScreenRecordings'),
|
||||
uploadDeviceRunSessionScreenRecordingsAsync: jest.fn(),
|
||||
}));
|
||||
// Spyable so a test can stand in for the serve-sim state directory, which a local serve-sim owns.
|
||||
jest.mock('../serveSimMetricsRecorder', () => {
|
||||
const actual = jest.requireActual('../serveSimMetricsRecorder');
|
||||
@@ -49,6 +67,69 @@ function createLoggerMock(): bunyan {
|
||||
} as unknown as bunyan;
|
||||
}
|
||||
|
||||
describe(spawnDetached, () => {
|
||||
function mockProcess(promise: Promise<unknown>): void {
|
||||
jest
|
||||
.mocked(spawn)
|
||||
.mockReturnValue(
|
||||
Object.assign(promise, { child: { pid: 1234, unref: jest.fn(), once: jest.fn() } }) as never
|
||||
);
|
||||
}
|
||||
|
||||
it('observes exit without waiting for inherited output pipes to close', () => {
|
||||
const child = Object.assign(new EventEmitter(), { pid: 1234, unref: jest.fn() });
|
||||
jest.mocked(spawn).mockReturnValue(Object.assign(new Promise(() => {}), { child }) as never);
|
||||
const handle = spawnDetached({ command: 'server', args: [], env: {} });
|
||||
child.emit('exit', 1, null);
|
||||
expect(handle.getExitError()?.message).toContain('code 1');
|
||||
expect(child.listenerCount('exit')).toBe(0);
|
||||
});
|
||||
|
||||
it('does not report an exit while the process is running', () => {
|
||||
mockProcess(new Promise(() => {}));
|
||||
const handle = spawnDetached({ command: 'server', args: [], env: {} });
|
||||
expect(handle.getExitError()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reports a clean exit so startup does not keep waiting', async () => {
|
||||
const completion = Promise.resolve();
|
||||
mockProcess(completion);
|
||||
const handle = spawnDetached({ command: 'server', args: [], env: {} });
|
||||
await completion;
|
||||
expect(handle.getExitError()?.message).toContain('code 0');
|
||||
});
|
||||
|
||||
it.each([0, 1])('observes a real subprocess exiting with code %s', async exitCode => {
|
||||
const actualSpawn =
|
||||
jest.requireActual<typeof import('@expo/turtle-spawn')>('@expo/turtle-spawn').default;
|
||||
let completion: ReturnType<typeof actualSpawn>;
|
||||
jest.mocked(spawn).mockImplementationOnce((...args) => {
|
||||
completion = actualSpawn(...args);
|
||||
return completion;
|
||||
});
|
||||
const handle = spawnDetached({
|
||||
command: process.execPath,
|
||||
args: ['-e', `console.error('startup-marker'); process.exit(${exitCode});`],
|
||||
env: {},
|
||||
});
|
||||
await completion!.catch(() => {});
|
||||
expect(handle.getExitError()).toBeInstanceOf(Error);
|
||||
expect(handle.getOutput()).toContain('startup-marker');
|
||||
});
|
||||
|
||||
it.each(['spawn ENOENT', 'server exited with code 1', 'server terminated by SIGTERM'])(
|
||||
'preserves the process failure: %s',
|
||||
async message => {
|
||||
const error = new Error(message);
|
||||
const completion = Promise.reject(error);
|
||||
mockProcess(completion);
|
||||
const handle = spawnDetached({ command: 'server', args: [], env: {} });
|
||||
await completion.catch(() => {});
|
||||
expect(handle.getExitError()).toBe(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
function createCtxMock(): CustomBuildContext {
|
||||
return {
|
||||
env: {
|
||||
@@ -122,6 +203,161 @@ function createEnvMock(): BuildStepEnv {
|
||||
return { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv;
|
||||
}
|
||||
|
||||
describe(createServeSimLaunchInputProviders, () => {
|
||||
it('declares the launch inputs as optional', () => {
|
||||
const globalCtx = createGlobalContextMock();
|
||||
const inputs = createServeSimLaunchInputProviders().map(provider =>
|
||||
provider(globalCtx, 'Test step')
|
||||
);
|
||||
|
||||
expect(
|
||||
inputs.map(({ id, required, allowedValueTypeName }) => ({
|
||||
id,
|
||||
required,
|
||||
allowedValueTypeName,
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
id: 'launch_app_identifier',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
},
|
||||
{
|
||||
id: 'launch_args',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
},
|
||||
{
|
||||
id: 'open_url',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseServeSimLaunchInputs, () => {
|
||||
const darwin = { runtimePlatform: BuildRuntimePlatform.DARWIN };
|
||||
|
||||
it('reads the launch identifier, arguments and URL', () => {
|
||||
expect(
|
||||
parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
},
|
||||
darwin
|
||||
)
|
||||
).toEqual({
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to no launch when the step declares nothing', () => {
|
||||
expect(parseServeSimLaunchInputs({}, { runtimePlatform: BuildRuntimePlatform.LINUX })).toEqual({
|
||||
launchAppIdentifier: undefined,
|
||||
launchArgs: [],
|
||||
openUrl: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects launch arguments that are not a list', () => {
|
||||
expect(() =>
|
||||
parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: 'oops',
|
||||
},
|
||||
darwin
|
||||
)
|
||||
).toThrow('must be an array of strings');
|
||||
});
|
||||
|
||||
it('rejects launch arguments that are not all strings', () => {
|
||||
expect(() =>
|
||||
parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-flag', 1],
|
||||
},
|
||||
darwin
|
||||
)
|
||||
).toThrow('must be an array of strings');
|
||||
});
|
||||
|
||||
it('rejects launch arguments with no application to launch', () => {
|
||||
expect(() => parseServeSimLaunchInputs({ launchArgs: ['-flag'] }, darwin)).toThrow(
|
||||
'Pass "launch_app_identifier"'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a URL with no application to open it in', () => {
|
||||
expect(() => parseServeSimLaunchInputs({ openUrl: 'exp://127.0.0.1:8081' }, darwin)).toThrow(
|
||||
'Pass "launch_app_identifier"'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an identifier that resolved to an empty string', () => {
|
||||
expect(() => parseServeSimLaunchInputs({ launchAppIdentifier: '' }, darwin)).toThrow(
|
||||
'must be a non-empty string'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a URL that is not a URL', () => {
|
||||
expect(() =>
|
||||
parseServeSimLaunchInputs(
|
||||
{ launchAppIdentifier: 'host.exp.Exponent', openUrl: 'not a url' },
|
||||
darwin
|
||||
)
|
||||
).toThrow('must be a valid URL');
|
||||
});
|
||||
|
||||
it('raises a user error, not a platform error', () => {
|
||||
expect(() => parseServeSimLaunchInputs({ launchAppIdentifier: '' }, darwin)).toThrow(UserError);
|
||||
try {
|
||||
parseServeSimLaunchInputs({ launchAppIdentifier: '' }, darwin);
|
||||
} catch (error) {
|
||||
expect((error as UserError).errorCode).toBe('EAS_LAUNCH_APPLICATION_INVALID_INPUT');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a launch on a session that does not run an iOS simulator', () => {
|
||||
expect(() =>
|
||||
parseServeSimLaunchInputs(
|
||||
{ launchAppIdentifier: 'host.exp.Exponent' },
|
||||
{ runtimePlatform: BuildRuntimePlatform.LINUX }
|
||||
)
|
||||
).toThrow('runs on linux');
|
||||
});
|
||||
});
|
||||
|
||||
describe(describeServeSimLaunch, () => {
|
||||
it('says nothing when there is no application to launch', () => {
|
||||
expect(describeServeSimLaunch({ launchArgs: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it('names only the application when there are no arguments or URL', () => {
|
||||
expect(describeServeSimLaunch({ launchAppIdentifier: 'host.exp.Exponent' })).toBe(
|
||||
'serve-sim will launch host.exp.Exponent.'
|
||||
);
|
||||
});
|
||||
|
||||
it('names the application, its arguments and the URL', () => {
|
||||
expect(
|
||||
describeServeSimLaunch({
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-flag', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
})
|
||||
).toBe(
|
||||
'serve-sim will launch host.exp.Exponent with arguments ["-flag","1"], then open exp://127.0.0.1:8081.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(createServeSimArgs, () => {
|
||||
it('uses the latest Expo package and applies the EAS streaming policy', () => {
|
||||
expect(
|
||||
@@ -130,7 +366,6 @@ describe(createServeSimArgs, () => {
|
||||
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
|
||||
})
|
||||
).toEqual([
|
||||
'--yes',
|
||||
'@expo/serve-sim@latest',
|
||||
'--port',
|
||||
'4321',
|
||||
@@ -140,13 +375,13 @@ describe(createServeSimArgs, () => {
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'vp8',
|
||||
'h264',
|
||||
'--max-dimension',
|
||||
'960',
|
||||
'1600',
|
||||
'--mjpeg-quality',
|
||||
'0.55',
|
||||
'--video-bitrate',
|
||||
'6000000',
|
||||
'10000000',
|
||||
'--video-fps',
|
||||
'60',
|
||||
'--turn-url',
|
||||
@@ -154,36 +389,79 @@ describe(createServeSimArgs, () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends metrics CORS args after the TURN args when provided', () => {
|
||||
it('appends the website args after the TURN args when provided', () => {
|
||||
const args = createServeSimArgs({
|
||||
port: 4321,
|
||||
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
|
||||
metricsCorsArgs: ['--metrics-cors-origin', 'https://expo.dev'],
|
||||
websiteArgs: ['--cors-origin', 'https://expo.dev'],
|
||||
});
|
||||
expect(args.slice(-4)).toEqual([
|
||||
'--turn-url',
|
||||
'turns:turn.example.test:443',
|
||||
'--metrics-cors-origin',
|
||||
'--cors-origin',
|
||||
'https://expo.dev',
|
||||
]);
|
||||
});
|
||||
|
||||
it('pins the requested package version', () => {
|
||||
expect(createServeSimArgs({ port: 4321, packageVersion: '0.1.38' }).slice(0, 2)).toEqual([
|
||||
'--yes',
|
||||
'@expo/[email protected]',
|
||||
]);
|
||||
expect(createServeSimArgs({ port: 4321, packageVersion: '0.1.38' })[0]).toBe(
|
||||
'@expo/[email protected]'
|
||||
);
|
||||
});
|
||||
|
||||
it('pins a dist-tag', () => {
|
||||
expect(createServeSimArgs({ port: 4321, packageVersion: 'next' }).slice(0, 2)).toEqual([
|
||||
'--yes',
|
||||
'@expo/serve-sim@next',
|
||||
expect(createServeSimArgs({ port: 4321, packageVersion: 'next' })[0]).toBe(
|
||||
'@expo/serve-sim@next'
|
||||
);
|
||||
});
|
||||
|
||||
it('appends --share-url after the website args', () => {
|
||||
const args = createServeSimArgs({
|
||||
port: 4321,
|
||||
websiteArgs: ['--cors-origin', 'https://expo.dev', '--frame-ancestor', 'https://expo.dev'],
|
||||
shareUrl: 'https://expo.dev/simulator-preview/abc',
|
||||
});
|
||||
expect(args.slice(-4)).toEqual([
|
||||
'--frame-ancestor',
|
||||
'https://expo.dev',
|
||||
'--share-url',
|
||||
'https://expo.dev/simulator-preview/abc',
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends the launch flags after the streaming policy', () => {
|
||||
const args = createServeSimArgs({
|
||||
port: 4321,
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
});
|
||||
expect(args.slice(-8)).toEqual([
|
||||
'--launch-app-identifier',
|
||||
'host.exp.Exponent',
|
||||
'--launch-arg',
|
||||
'-EXDevMenuIsOnboardingFinished',
|
||||
'--launch-arg',
|
||||
'1',
|
||||
'--open-url',
|
||||
'exp://127.0.0.1:8081',
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits the launch flags when there is no application to launch', () => {
|
||||
const args = createServeSimArgs({ port: 4321 });
|
||||
expect(args.some(argument => argument.startsWith('--launch'))).toBe(false);
|
||||
expect(args).not.toContain('--open-url');
|
||||
});
|
||||
});
|
||||
|
||||
describe(createExpoDeviceHubArgs, () => {
|
||||
it('opts in to recording only when a directory is provided', () => {
|
||||
expect(createExpoDeviceHubArgs({ port: 4321 })).not.toContain('--android-recording-directory');
|
||||
expect(
|
||||
createExpoDeviceHubArgs({ port: 4321, recordingDirectory: '/tmp/recordings' }).slice(-2)
|
||||
).toEqual(['--android-recording-directory', '/tmp/recordings']);
|
||||
});
|
||||
it('uses the latest Expo package and applies the EAS Android streaming policy', () => {
|
||||
expect(
|
||||
createExpoDeviceHubArgs({
|
||||
@@ -191,7 +469,6 @@ describe(createExpoDeviceHubArgs, () => {
|
||||
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
|
||||
})
|
||||
).toEqual([
|
||||
'--yes',
|
||||
'expo-device-hub@latest',
|
||||
'--port',
|
||||
'4321',
|
||||
@@ -219,37 +496,51 @@ describe(createExpoDeviceHubArgs, () => {
|
||||
});
|
||||
|
||||
it('pins the requested package version', () => {
|
||||
expect(createExpoDeviceHubArgs({ port: 4321, packageVersion: '0.7.0' }).slice(0, 2)).toEqual([
|
||||
'--yes',
|
||||
'[email protected]',
|
||||
]);
|
||||
expect(createExpoDeviceHubArgs({ port: 4321, packageVersion: '0.7.0' })[0]).toBe(
|
||||
'[email protected]'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(metricsCorsOriginToServeSimArgs, () => {
|
||||
it('returns no args when the origin is unset or empty', () => {
|
||||
expect(metricsCorsOriginToServeSimArgs({} as BuildStepEnv)).toEqual([]);
|
||||
expect(
|
||||
metricsCorsOriginToServeSimArgs({ EAS_SIMULATOR_METRICS_CORS_ORIGIN: '' } as BuildStepEnv)
|
||||
).toEqual([]);
|
||||
describe(websiteOriginServeSimArgs, () => {
|
||||
it('names only the production website by default', () => {
|
||||
expect(websiteOriginServeSimArgs({} as BuildStepEnv)).toEqual([
|
||||
'--cors-origin',
|
||||
'https://expo.dev',
|
||||
'--frame-ancestor',
|
||||
'https://expo.dev',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds one flag per comma-separated origin', () => {
|
||||
expect(
|
||||
metricsCorsOriginToServeSimArgs({
|
||||
EAS_SIMULATOR_METRICS_CORS_ORIGIN: 'https://expo.dev',
|
||||
} as BuildStepEnv)
|
||||
).toEqual(['--metrics-cors-origin', 'https://expo.dev']);
|
||||
expect(
|
||||
metricsCorsOriginToServeSimArgs({
|
||||
EAS_SIMULATOR_METRICS_CORS_ORIGIN: 'https://expo.dev, https://staging.expo.dev',
|
||||
} as BuildStepEnv)
|
||||
).toEqual([
|
||||
'--metrics-cors-origin',
|
||||
'https://expo.dev',
|
||||
'--metrics-cors-origin',
|
||||
it('names staging, its deploy previews and each website dev port on staging', () => {
|
||||
const args = websiteOriginServeSimArgs({ EXPO_STAGING: '1' } as BuildStepEnv);
|
||||
expect(args.slice(0, 4)).toEqual([
|
||||
'--cors-origin',
|
||||
'https://staging.expo.dev',
|
||||
'--frame-ancestor',
|
||||
'https://staging.expo.dev',
|
||||
]);
|
||||
expect(args).toContain('https://*.expo.dev');
|
||||
expect(args).toContain('https://expo.test:13001');
|
||||
expect(args).toContain('https://expo.test:13215');
|
||||
expect(args).not.toContain('https://expo.test:13216');
|
||||
expect(args).not.toContain('https://expo.dev');
|
||||
});
|
||||
|
||||
it('names only https origins', () => {
|
||||
for (const env of [{}, { EXPO_STAGING: '1' }, { EXPO_LOCAL: '1' }]) {
|
||||
const args = websiteOriginServeSimArgs(env as BuildStepEnv);
|
||||
expect(args.filter(value => value.startsWith('http://'))).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('names the website dev ports on local, without the deploy-preview wildcard', () => {
|
||||
for (const env of [{ EXPO_LOCAL: '1' }, { EXPO_LOCAL: '1', EXPO_STAGING: '1' }]) {
|
||||
const args = websiteOriginServeSimArgs(env as BuildStepEnv);
|
||||
expect(args).toContain('https://expo.test:13001');
|
||||
expect(args).not.toContain('https://*.expo.dev');
|
||||
expect(args.filter(value => value === 'https://expo.test')).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -309,13 +600,18 @@ describe(startNgrokTunnelAsync, () => {
|
||||
})
|
||||
);
|
||||
expect(tunnel.url).toBe('https://web-preview.example.test');
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
domain: `web-preview-${tunnel.subdomainId}.eas-simulator.ngrok.dev`,
|
||||
})
|
||||
);
|
||||
await tunnel.stopAsync();
|
||||
await tunnel.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
describe(startDeviceSessionHostAsync, () => {
|
||||
const baseDomain = 'eas-simulator.ngrok.dev';
|
||||
const turnArgs = [
|
||||
'--turn-url',
|
||||
@@ -325,14 +621,14 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
'--turn-credential',
|
||||
'turn-credential',
|
||||
];
|
||||
const metricsCorsArgs = ['--metrics-cors-origin', 'https://metrics.expo.test'];
|
||||
const websiteArgs = ['--cors-origin', 'https://expo.dev', '--frame-ancestor', 'https://expo.dev'];
|
||||
const env = {
|
||||
DEVICE_RUN_SESSION_ID: 'drs-id',
|
||||
EAS_SIMULATOR_METRICS_CORS_ORIGIN: 'https://metrics.expo.test',
|
||||
NGROK_AUTHTOKEN: 'ngrok-token',
|
||||
} as unknown as BuildStepEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockReset();
|
||||
jest.mocked(spawn).mockReset();
|
||||
jest.mocked(ngrok.forward).mockReset();
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
@@ -344,6 +640,7 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
child: {
|
||||
pid: undefined,
|
||||
unref: jest.fn(),
|
||||
once: jest.fn(),
|
||||
},
|
||||
});
|
||||
jest.mocked(spawn).mockReturnValue(spawnPromise as never);
|
||||
@@ -385,14 +682,14 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>)
|
||||
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
const spawnCalls = jest.mocked(spawn).mock.calls;
|
||||
expect(spawnCalls[0]).toEqual(['ffmpeg', ['-version'], { env }]);
|
||||
@@ -419,11 +716,17 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
expect(port).toBeGreaterThan(0);
|
||||
expect(command).toBe('npx');
|
||||
expect(args).toEqual(createExpoDeviceHubArgs({ port, turnArgs, packageVersion }));
|
||||
expect(args).toContain('--android-recording-directory');
|
||||
const recordingDirectory = args[args.indexOf('--android-recording-directory') + 1];
|
||||
expect(args).toEqual([
|
||||
'--yes',
|
||||
...createExpoDeviceHubArgs({ port, turnArgs, packageVersion, recordingDirectory }),
|
||||
]);
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(preview.previewUrl).toBe('https://android-preview.example.test');
|
||||
expect(preview.apiUrl).toBe('https://android-preview.example.test');
|
||||
|
||||
await preview.stopAsync();
|
||||
await host.finishAsync();
|
||||
await fs.rm(recordingDirectory, { recursive: true, force: true });
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -433,16 +736,50 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
expect(preview.previewToken).toBe('tok-1');
|
||||
expect(preview.previewUrl).toBe('https://preview.example.test');
|
||||
expect(preview.apiUrl).toBe('https://preview.example.test');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{}, 'https://expo.dev'],
|
||||
[{ EXPO_STAGING: '1' }, 'https://staging.expo.dev'],
|
||||
[{ EXPO_LOCAL: '1' }, 'https://expo.test'],
|
||||
])('points the preview page at the website for the stage the worker runs in', (stage, origin) => {
|
||||
expect(websiteOrigin({ ...env, ...stage })).toBe(origin);
|
||||
expect(simulatorPreviewPageUrl({ ...env, ...stage }, 'abc')).toBe(
|
||||
`${origin}/simulator-preview/abc`
|
||||
);
|
||||
});
|
||||
|
||||
it('points the preview URL at the website page for the tunnel', async () => {
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://preview.example.test',
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: { ...env, EXPO_STAGING: '1' },
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
expect(preview.previewPageUrl).toMatch(
|
||||
/^https:\/\/staging\.expo\.dev\/simulator-preview\/[a-f0-9]{32}$/
|
||||
);
|
||||
const previewId = preview.previewPageUrl.split('/').at(-1);
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ domain: `web-preview-${previewId}.${baseDomain}` })
|
||||
);
|
||||
});
|
||||
|
||||
// serve-sim is always launched with --require-token, so a missing token means it is running
|
||||
@@ -453,9 +790,8 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
.mockResolvedValue([{ udid: 'device-id', url: 'http://127.0.0.1:1' }]);
|
||||
|
||||
await expect(
|
||||
startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
@@ -471,13 +807,13 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
expect(preview.previewToken).toBeUndefined();
|
||||
const [, args] = jest.mocked(spawn).mock.calls[0];
|
||||
@@ -492,52 +828,145 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
close,
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
expect(port).toBeGreaterThan(0);
|
||||
expect(command).toBe('npx');
|
||||
expect(args).toEqual(createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }));
|
||||
expect(args).toEqual([
|
||||
'--yes',
|
||||
...createServeSimArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
websiteArgs,
|
||||
shareUrl: preview.previewPageUrl,
|
||||
packageVersion,
|
||||
}),
|
||||
]);
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(preview.previewUrl).toBe('https://ios-preview.example.test');
|
||||
expect(preview.apiUrl).toBe('https://ios-preview.example.test');
|
||||
|
||||
await preview.stopAsync();
|
||||
await host.finishAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not install ffmpeg before starting expo-device-hub outside Linux', async () => {
|
||||
it('launches serve-sim with bun x when EAS_OVERRIDE_PACKAGE_MANAGER is bun', async () => {
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://android-preview.example.test',
|
||||
url: () => 'https://ios-preview.example.test',
|
||||
close,
|
||||
} as never);
|
||||
|
||||
const preview = await startExpoDeviceHubWithTunnelAsync(createCtxMock(), {
|
||||
const bunEnv = { ...env, EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' };
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
env: bunEnv,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion: '4.5.6',
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
expect(command).toBe('bun');
|
||||
expect(args).toEqual([
|
||||
'x',
|
||||
...createServeSimArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
websiteArgs,
|
||||
shareUrl: preview.previewPageUrl,
|
||||
packageVersion: '4.5.6',
|
||||
}),
|
||||
]);
|
||||
|
||||
await host.finishAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('launches serve-sim with bun x when EAS_FALLBACK_PACKAGE_MANAGER is bun', async () => {
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://ios-preview.example.test',
|
||||
close,
|
||||
} as never);
|
||||
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: { ...env, EAS_FALLBACK_PACKAGE_MANAGER: 'bun' },
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
expect(jest.mocked(spawn).mock.calls[0][0]).toBe('npx');
|
||||
expect(jest.mocked(spawn)).not.toHaveBeenCalledWith(
|
||||
'ffmpeg',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
expect(command).toBe('bun');
|
||||
expect(args).toEqual([
|
||||
'x',
|
||||
...createServeSimArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
websiteArgs,
|
||||
shareUrl: preview.previewPageUrl,
|
||||
}),
|
||||
]);
|
||||
|
||||
await preview.stopAsync();
|
||||
await host.finishAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hands the launch options to serve-sim on Darwin', async () => {
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://ios-preview.example.test',
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
});
|
||||
await host.finishAsync();
|
||||
|
||||
const [, args] = jest.mocked(spawn).mock.calls[0];
|
||||
expect(args.slice(-8)).toEqual([
|
||||
'--launch-app-identifier',
|
||||
'host.exp.Exponent',
|
||||
'--launch-arg',
|
||||
'-EXDevMenuIsOnboardingFinished',
|
||||
'--launch-arg',
|
||||
'1',
|
||||
'--open-url',
|
||||
'exp://127.0.0.1:8081',
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses to launch an application on Linux, where expo-device-hub cannot', async () => {
|
||||
await expect(
|
||||
startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
})
|
||||
).rejects.toThrow('Cannot launch host.exp.Exponent');
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(turnIceServersToWebPreviewArgs, () => {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { BuildStepEnv } from '@expo/steps';
|
||||
|
||||
import { isProcessRunning, spawnDetached } from '../remoteDeviceRunSession';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
|
||||
it('resolves stopAsync only after a process that ignores SIGTERM is gone', async () => {
|
||||
const handle = spawnDetached({
|
||||
command: 'sh',
|
||||
args: ['-c', 'trap "" TERM; echo ready; sleep 30 & wait'],
|
||||
env: process.env as BuildStepEnv,
|
||||
stopGracePeriodMs: 300,
|
||||
});
|
||||
const pid = handle.pid;
|
||||
if (pid === undefined) {
|
||||
throw new Error('spawn failed');
|
||||
}
|
||||
while (!handle.getOutput().includes('ready')) {
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
await handle.stopAsync();
|
||||
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(300);
|
||||
expect(isProcessRunning(pid)).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
import { SandboxDaemonResponseZ, SystemError } from '@expo/eas-build-job';
|
||||
import Log from '@expo/logger';
|
||||
import http from 'node:http';
|
||||
import net, { AddressInfo } from 'node:net';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
|
||||
import { startSandboxDaemonAsync } from '../sandboxDaemon';
|
||||
|
||||
jest.unmock('@expo/logger');
|
||||
jest.unmock('fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
describe(startSandboxDaemonAsync.name, () => {
|
||||
const logger = Log.child({ buildStepId: 'sandbox-test' });
|
||||
it.each(['ftp://localhost', 'not a URL'])('reports invalid server URL %s', async serverUrl => {
|
||||
const errorLog = jest.spyOn(logger, 'error').mockImplementation(() => {});
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential: 'secret-token',
|
||||
serverUrl,
|
||||
reconnectDelayMs: 10,
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
});
|
||||
try {
|
||||
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
|
||||
expect(errorLog).toHaveBeenCalledWith(
|
||||
{ err: expect.objectContaining({ message: expect.any(String) }) },
|
||||
expect.any(String)
|
||||
);
|
||||
} finally {
|
||||
await daemon.stopAsync();
|
||||
errorLog.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('logs errors after the connection opens', async () => {
|
||||
const server = new WebSocketServer({ port: 0, host: '127.0.0.1' });
|
||||
await new Promise<void>(resolve => server.once('listening', resolve));
|
||||
const connection = new Promise<WebSocket>(resolve => server.once('connection', resolve));
|
||||
const warn = jest.spyOn(logger, 'warn').mockImplementation(() => {});
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential: 'secret-token',
|
||||
serverUrl: `ws://127.0.0.1:${(server.address() as AddressInfo).port}`,
|
||||
reconnectDelayMs: 60_000,
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
});
|
||||
try {
|
||||
const socket = await connection;
|
||||
await daemon.ready;
|
||||
// An invalid opcode makes the client emit a protocol error.
|
||||
const closed = new Promise<void>(resolve => socket.once('close', () => resolve()));
|
||||
(socket as any)._socket.write(Buffer.from([0x83, 0x00]));
|
||||
await closed;
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
{ err: expect.objectContaining({ message: expect.stringContaining('invalid opcode') }) },
|
||||
expect.stringContaining('invalid opcode')
|
||||
);
|
||||
} finally {
|
||||
await daemon.stopAsync();
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
it('fails if the server does not complete the handshake', async () => {
|
||||
const sockets = new Set<net.Socket>();
|
||||
const tcpServer = net.createServer(socket => {
|
||||
sockets.add(socket);
|
||||
socket.on('close', () => sockets.delete(socket));
|
||||
});
|
||||
await new Promise<void>(resolve => tcpServer.listen(0, '127.0.0.1', resolve));
|
||||
const address = tcpServer.address() as AddressInfo;
|
||||
const errorLog = jest.spyOn(logger, 'error').mockImplementation(() => {});
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential: 'secret-token',
|
||||
serverUrl: `ws://127.0.0.1:${address.port}`,
|
||||
reconnectDelayMs: 10,
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
});
|
||||
try {
|
||||
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
|
||||
expect(errorLog).toHaveBeenCalledWith(
|
||||
{ err: expect.objectContaining({ message: 'Opening handshake has timed out' }) },
|
||||
expect.stringContaining('Opening handshake has timed out')
|
||||
);
|
||||
} finally {
|
||||
await daemon.stopAsync();
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
await new Promise<void>(resolve => tcpServer.close(() => resolve()));
|
||||
errorLog.mockRestore();
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it.each(['stop', 'abort'])('can %s while the initial connection is pending', async action => {
|
||||
const controller = new AbortController();
|
||||
const tcpServer = net.createServer();
|
||||
await new Promise<void>(resolve => tcpServer.listen(0, '127.0.0.1', resolve));
|
||||
const address = tcpServer.address() as AddressInfo;
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential: 'secret-token',
|
||||
serverUrl: `ws://127.0.0.1:${address.port}`,
|
||||
reconnectDelayMs: 60_000,
|
||||
logger,
|
||||
signal: controller.signal,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
});
|
||||
const connectionError = daemon.ready.catch(error => error);
|
||||
|
||||
if (action === 'abort') {
|
||||
controller.abort();
|
||||
} else {
|
||||
await daemon.stopAsync();
|
||||
}
|
||||
|
||||
await expect(connectionError).resolves.toBeInstanceOf(Error);
|
||||
await daemon.stopAsync();
|
||||
await new Promise<void>(resolve => tcpServer.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('fails when the initial connection cannot be opened', async () => {
|
||||
const errorLog = jest.spyOn(logger, 'error').mockImplementation(() => {});
|
||||
const httpServer = http.createServer();
|
||||
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
|
||||
const address = httpServer.address() as AddressInfo;
|
||||
await new Promise<void>(resolve => httpServer.close(() => resolve()));
|
||||
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential: 'secret-token',
|
||||
serverUrl: `ws://127.0.0.1:${address.port}`,
|
||||
reconnectDelayMs: 60_000,
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
await expect(daemon.ready).rejects.toThrow('Sandbox MCP server connection failed');
|
||||
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
|
||||
expect(errorLog).toHaveBeenCalledWith(
|
||||
{ err: expect.objectContaining({ message: expect.stringContaining('ECONNREFUSED') }) },
|
||||
expect.stringContaining('ECONNREFUSED')
|
||||
);
|
||||
await daemon.stopAsync();
|
||||
errorLog.mockRestore();
|
||||
});
|
||||
|
||||
it('connects with the credential and closes on stop', async () => {
|
||||
const httpServer = http.createServer();
|
||||
const mcpServer = new WebSocketServer({ noServer: true });
|
||||
httpServer.on('upgrade', (request, socket, head) => {
|
||||
expect(request.url).toBe('/sandbox/connect');
|
||||
expect(request.headers.authorization).toBe('Bearer secret-token');
|
||||
mcpServer.handleUpgrade(request, socket, head, client =>
|
||||
mcpServer.emit('connection', client)
|
||||
);
|
||||
});
|
||||
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
|
||||
const address = httpServer.address() as AddressInfo;
|
||||
const connection = new Promise<WebSocket>(resolve => mcpServer.once('connection', resolve));
|
||||
|
||||
const daemonPromise = startSandboxDaemonAsync({
|
||||
credential: 'secret-token',
|
||||
serverUrl: `ws://127.0.0.1:${address.port}`,
|
||||
reconnectDelayMs: 10,
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
});
|
||||
const socket = await connection;
|
||||
const daemon = await daemonPromise;
|
||||
await daemon.ready;
|
||||
const socketClosed = new Promise<void>(resolve => socket.once('close', () => resolve()));
|
||||
|
||||
await daemon.stopAsync();
|
||||
await socketClosed;
|
||||
|
||||
mcpServer.close();
|
||||
await new Promise<void>(resolve => httpServer.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('executes command messages', async () => {
|
||||
const httpServer = http.createServer();
|
||||
const mcpServer = new WebSocketServer({ noServer: true });
|
||||
httpServer.on('upgrade', (request, socket, head) => {
|
||||
mcpServer.handleUpgrade(request, socket, head, client =>
|
||||
mcpServer.emit('connection', client)
|
||||
);
|
||||
});
|
||||
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
|
||||
const address = httpServer.address() as AddressInfo;
|
||||
const connection = new Promise<WebSocket>(resolve => mcpServer.once('connection', resolve));
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential: 'secret-token',
|
||||
serverUrl: `ws://127.0.0.1:${address.port}`,
|
||||
reconnectDelayMs: 10,
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
});
|
||||
const socket = await connection;
|
||||
await daemon.ready;
|
||||
const commandResponse = new Promise<string>(resolve =>
|
||||
socket.once('message', data => resolve(`${data}`))
|
||||
);
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 'command-1',
|
||||
method: 'execCommand',
|
||||
params: { cmd: 'printf hello' },
|
||||
})
|
||||
);
|
||||
|
||||
expect(JSON.parse(await commandResponse)).toMatchObject({
|
||||
jsonrpc: '2.0',
|
||||
id: 'command-1',
|
||||
result: { output: 'hello', exitCode: 0 },
|
||||
});
|
||||
await daemon.stopAsync();
|
||||
mcpServer.close();
|
||||
await new Promise<void>(resolve => httpServer.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('returns JSON-RPC errors for invalid messages', async () => {
|
||||
const httpServer = http.createServer();
|
||||
const mcpServer = new WebSocketServer({ noServer: true });
|
||||
httpServer.on('upgrade', (request, socket, head) => {
|
||||
mcpServer.handleUpgrade(request, socket, head, client =>
|
||||
mcpServer.emit('connection', client)
|
||||
);
|
||||
});
|
||||
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
|
||||
const address = httpServer.address() as AddressInfo;
|
||||
const connection = new Promise<WebSocket>(resolve => mcpServer.once('connection', resolve));
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential: 'secret-token',
|
||||
serverUrl: `ws://127.0.0.1:${address.port}`,
|
||||
reconnectDelayMs: 10,
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
});
|
||||
const socket = await connection;
|
||||
await daemon.ready;
|
||||
const sendAsync = async (message: string): Promise<unknown> => {
|
||||
const response = new Promise<string>(resolve =>
|
||||
socket.once('message', data => resolve(`${data}`))
|
||||
);
|
||||
socket.send(message);
|
||||
return SandboxDaemonResponseZ.parse(JSON.parse(await response));
|
||||
};
|
||||
|
||||
await expect(sendAsync('{')).resolves.toMatchObject({
|
||||
id: null,
|
||||
error: { code: -32700, message: 'Parse error' },
|
||||
});
|
||||
await expect(sendAsync('{}')).resolves.toMatchObject({
|
||||
id: null,
|
||||
error: { code: -32600, message: 'Invalid request' },
|
||||
});
|
||||
await expect(
|
||||
sendAsync(JSON.stringify({ jsonrpc: '2.0', id: '1', method: 'unknown' }))
|
||||
).resolves.toMatchObject({
|
||||
id: '1',
|
||||
error: { code: -32601, message: 'Method not found' },
|
||||
});
|
||||
await expect(
|
||||
sendAsync(JSON.stringify({ jsonrpc: '2.0', id: '2', method: 'execCommand', params: {} }))
|
||||
).resolves.toMatchObject({
|
||||
id: '2',
|
||||
error: { code: -32602, message: 'Invalid params' },
|
||||
});
|
||||
await expect(
|
||||
sendAsync(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: '3',
|
||||
method: 'writeStdin',
|
||||
params: { sessionId: 999 },
|
||||
})
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
id: '3',
|
||||
error: { code: -32603, message: 'Command session 999 does not exist.' },
|
||||
});
|
||||
|
||||
await daemon.stopAsync();
|
||||
mcpServer.close();
|
||||
await new Promise<void>(resolve => httpServer.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('cancels the reconnect delay when stopped', async () => {
|
||||
const httpServer = http.createServer();
|
||||
const mcpServer = new WebSocketServer({ noServer: true });
|
||||
httpServer.on('upgrade', (request, socket, head) => {
|
||||
mcpServer.handleUpgrade(request, socket, head, client =>
|
||||
mcpServer.emit('connection', client)
|
||||
);
|
||||
});
|
||||
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
|
||||
const address = httpServer.address() as AddressInfo;
|
||||
const connection = new Promise<WebSocket>(resolve => mcpServer.once('connection', resolve));
|
||||
const daemonPromise = startSandboxDaemonAsync({
|
||||
credential: 'secret-token',
|
||||
serverUrl: `ws://127.0.0.1:${address.port}`,
|
||||
reconnectDelayMs: 60_000,
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
});
|
||||
const socket = await connection;
|
||||
const daemon = await daemonPromise;
|
||||
await daemon.ready;
|
||||
const socketClosed = new Promise<void>(resolve => socket.once('close', () => resolve()));
|
||||
socket.close();
|
||||
await socketClosed;
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
|
||||
await daemon.stopAsync();
|
||||
|
||||
mcpServer.close();
|
||||
await new Promise<void>(resolve => httpServer.close(() => resolve()));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
jest.unmock('fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
import { type SandboxDaemonCommandResult, type SandboxDaemonMethod } from '@expo/eas-build-job';
|
||||
import fs from 'node:fs/promises';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
|
||||
import {
|
||||
type SandboxDaemonCommandImplementations,
|
||||
createSandboxCommandImplementations,
|
||||
} from '../sandboxCommandImplementations';
|
||||
|
||||
describe('sandbox daemon commands', () => {
|
||||
let commandImplementations: SandboxDaemonCommandImplementations;
|
||||
let stopAsync: () => Promise<void>;
|
||||
let workingDirectory: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
workingDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'sandbox-command-executor-'));
|
||||
const controller = new AbortController();
|
||||
const commands = createSandboxCommandImplementations({
|
||||
workingDirectory,
|
||||
env: process.env,
|
||||
signal: controller.signal,
|
||||
});
|
||||
commandImplementations = commands.commandImplementations;
|
||||
stopAsync = async () => {
|
||||
controller.abort();
|
||||
await commands.stoppedPromise;
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopAsync();
|
||||
await fs.chmod(workingDirectory, 0o700);
|
||||
await fs.rm(workingDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns output and the exit code for a completed command', async () => {
|
||||
const result = await commandImplementations.execCommand({ cmd: 'printf hello' });
|
||||
|
||||
expect(result).toMatchObject({ output: 'hello', exitCode: 0 });
|
||||
expect('sessionId' in result).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a command canceled during directory validation', async () => {
|
||||
const controller = new AbortController();
|
||||
const commands = createSandboxCommandImplementations({
|
||||
workingDirectory,
|
||||
env: process.env,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const command = commands.commandImplementations.execCommand({ cmd: 'printf should-not-run' });
|
||||
controller.abort();
|
||||
await commands.stoppedPromise;
|
||||
await expect(command).rejects.toMatchObject({ name: 'AbortError' });
|
||||
await expect(
|
||||
commands.commandImplementations.writeStdin({ sessionId: 1 })
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
});
|
||||
|
||||
it('returns a session id and accepts stdin for a running command', async () => {
|
||||
const started = await commandImplementations.execCommand({
|
||||
cmd: `node -e "process.stdin.once('data', data => process.stdout.write(data.toString().toUpperCase(), () => process.exit(0)))"`,
|
||||
yieldTimeMs: 10,
|
||||
});
|
||||
const sessionId = getSessionId(started);
|
||||
|
||||
const completed = await commandImplementations.writeStdin({
|
||||
sessionId,
|
||||
chars: 'hello',
|
||||
yieldTimeMs: 1_000,
|
||||
});
|
||||
|
||||
expect(completed).toMatchObject({ output: 'HELLO', exitCode: 0 });
|
||||
expect('sessionId' in completed).toBe(false);
|
||||
});
|
||||
|
||||
it('returns only output produced since the previous call', async () => {
|
||||
const started = await commandImplementations.execCommand({
|
||||
cmd: `node -e "process.stdin.once('data', () => process.stdout.write('second\\n', () => process.exit(0))); console.log('first')"`,
|
||||
yieldTimeMs: 50,
|
||||
});
|
||||
const sessionId = getSessionId(started);
|
||||
|
||||
expect(await readUntilAsync(started, output => output.includes('first\n'))).toMatchObject({
|
||||
output: 'first\n',
|
||||
});
|
||||
const completed = await readUntilAsync(
|
||||
await commandImplementations.writeStdin({ sessionId, chars: 'continue', yieldTimeMs: 100 }),
|
||||
(_, result) => !('sessionId' in result)
|
||||
);
|
||||
|
||||
expect(completed).toMatchObject({ output: 'second\n', exitCode: 0 });
|
||||
});
|
||||
|
||||
it('provides a TTY when requested', async () => {
|
||||
const result = await commandImplementations.execCommand({
|
||||
cmd: 'test -t 0 && test -t 1 && test -t 2',
|
||||
tty: true,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ exitCode: 0 });
|
||||
});
|
||||
|
||||
it('uses pipes by default', async () => {
|
||||
const result = await commandImplementations.execCommand({ cmd: 'test -t 0' });
|
||||
|
||||
expect(result).toMatchObject({ exitCode: 1 });
|
||||
});
|
||||
|
||||
it('sends Ctrl+C to the foreground PTY process', async () => {
|
||||
const started = await commandImplementations.execCommand({
|
||||
cmd: `exec node -e "process.on('SIGINT', () => process.stdout.write('interrupted\\n', () => process.exit(0))); console.log('ready'); setInterval(() => {}, 1000)"`,
|
||||
tty: true,
|
||||
yieldTimeMs: 50,
|
||||
});
|
||||
|
||||
await readUntilAsync(started, output => output.includes('ready'));
|
||||
const completed = await readUntilAsync(
|
||||
await commandImplementations.writeStdin({
|
||||
sessionId: getSessionId(started),
|
||||
chars: '\u0003',
|
||||
yieldTimeMs: 1_000,
|
||||
}),
|
||||
(_, result) => !('sessionId' in result)
|
||||
);
|
||||
|
||||
expect(completed.output).toContain('interrupted');
|
||||
expect(completed).toMatchObject({ exitCode: 0 });
|
||||
});
|
||||
|
||||
it('writes U+0003 as literal input in pipe mode', async () => {
|
||||
const started = await commandImplementations.execCommand({
|
||||
cmd: `node -e "process.stdin.once('data', data => process.stdout.write(data[0] + '\\n', () => process.exit(0)))"`,
|
||||
yieldTimeMs: 10,
|
||||
});
|
||||
|
||||
const completed = await readUntilAsync(
|
||||
await commandImplementations.writeStdin({
|
||||
sessionId: getSessionId(started),
|
||||
chars: '\u0003',
|
||||
yieldTimeMs: 1_000,
|
||||
}),
|
||||
(_, result) => !('sessionId' in result)
|
||||
);
|
||||
|
||||
expect(completed).toMatchObject({ output: '3\n', exitCode: 0 });
|
||||
});
|
||||
|
||||
it('reports a termination signal without an exit code', async () => {
|
||||
const result = await commandImplementations.execCommand({ cmd: 'kill -TERM $$' });
|
||||
|
||||
expect(result).toMatchObject({ terminationSignal: 'SIGTERM' });
|
||||
expect('exitCode' in result).toBe(false);
|
||||
});
|
||||
|
||||
it('measures each call separately', async () => {
|
||||
const now = jest.spyOn(performance, 'now');
|
||||
try {
|
||||
now.mockReturnValueOnce(1_000).mockReturnValueOnce(1_250);
|
||||
const started = await commandImplementations.execCommand({
|
||||
cmd: 'read value',
|
||||
yieldTimeMs: 0,
|
||||
});
|
||||
expect(started.wallTimeSeconds).toBe(0.25);
|
||||
|
||||
now.mockReturnValueOnce(5_000).mockReturnValueOnce(5_100);
|
||||
const polled = await commandImplementations.writeStdin({
|
||||
sessionId: getSessionId(started),
|
||||
yieldTimeMs: 0,
|
||||
});
|
||||
expect(polled.wallTimeSeconds).toBe(0.1);
|
||||
} finally {
|
||||
now.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the requested working directory', async () => {
|
||||
const childDirectory = path.join(workingDirectory, 'child');
|
||||
await fs.mkdir(childDirectory);
|
||||
|
||||
const result = await commandImplementations.execCommand({ cmd: 'pwd', workdir: 'child' });
|
||||
|
||||
expect(result).toMatchObject({ output: `${await fs.realpath(childDirectory)}\n`, exitCode: 0 });
|
||||
});
|
||||
|
||||
it('reports a missing working directory', async () => {
|
||||
await expect(
|
||||
commandImplementations.execCommand({ cmd: 'true', workdir: 'missing' })
|
||||
).rejects.toThrow(
|
||||
`Working directory does not exist: ${path.join(workingDirectory, 'missing')}`
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a working directory that is not a directory', async () => {
|
||||
const file = path.join(workingDirectory, 'file');
|
||||
await fs.writeFile(file, 'content');
|
||||
|
||||
await expect(
|
||||
commandImplementations.execCommand({ cmd: 'true', workdir: 'file' })
|
||||
).rejects.toThrow(`Working directory is not a directory: ${file}`);
|
||||
});
|
||||
|
||||
it('reports an inaccessible working directory', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
const directory = path.join(workingDirectory, 'inaccessible');
|
||||
await fs.mkdir(directory, 0o000);
|
||||
|
||||
await expect(
|
||||
commandImplementations.execCommand({ cmd: 'true', workdir: 'inaccessible' })
|
||||
).rejects.toThrow(`Working directory is not accessible: ${directory}`);
|
||||
|
||||
await fs.chmod(directory, 0o700);
|
||||
});
|
||||
|
||||
it.each([false, true])('stops command descendants when tty is %s', async tty => {
|
||||
const started = await commandImplementations.execCommand({
|
||||
cmd: `node -e "const { spawn } = require('node:child_process'); const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' }); process.stdout.write(String(child.pid) + '\\n'); setInterval(() => {}, 1000)"`,
|
||||
tty,
|
||||
yieldTimeMs: 100,
|
||||
});
|
||||
const ready = await readUntilAsync(started, output => /\d+\r?\n/.test(output));
|
||||
const childPid = Number(/\d+/.exec(ready.output)?.[0]);
|
||||
expect(childPid).toBeGreaterThan(0);
|
||||
|
||||
await stopAsync();
|
||||
await setTimeoutAsync(50);
|
||||
|
||||
expect(isProcessRunning(childPid)).toBe(false);
|
||||
});
|
||||
async function readUntilAsync(
|
||||
initial: SandboxDaemonCommandResult<SandboxDaemonMethod>,
|
||||
isReady: (output: string, result: SandboxDaemonCommandResult<SandboxDaemonMethod>) => boolean
|
||||
): Promise<SandboxDaemonCommandResult<SandboxDaemonMethod>> {
|
||||
let result = initial;
|
||||
let output = result.output;
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (!isReady(output, result)) {
|
||||
if (!('sessionId' in result) || Date.now() >= deadline) {
|
||||
throw new Error(
|
||||
`Command did not reach the expected state: ${JSON.stringify({ ...result, output })}`
|
||||
);
|
||||
}
|
||||
result = await commandImplementations.writeStdin({
|
||||
sessionId: result.sessionId,
|
||||
yieldTimeMs: 100,
|
||||
});
|
||||
output += result.output;
|
||||
}
|
||||
return { ...result, output };
|
||||
}
|
||||
});
|
||||
|
||||
function getSessionId(result: unknown): number {
|
||||
if (
|
||||
typeof result !== 'object' ||
|
||||
result === null ||
|
||||
!('sessionId' in result) ||
|
||||
typeof result.sessionId !== 'number'
|
||||
) {
|
||||
throw new Error('Expected the command to still be running.');
|
||||
}
|
||||
return result.sessionId;
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
const result = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], { encoding: 'utf8' });
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status === 1 && !result.stdout.trim()) {
|
||||
return false;
|
||||
}
|
||||
expect(result.status).toBe(0);
|
||||
// A zombie has exited, but its parent has not yet collected its exit status.
|
||||
return !result.stdout.trim().startsWith('Z');
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
import { ShellSessionManager } from '../shellSessionManager';
|
||||
import * as processes from '../../../utils/processes';
|
||||
|
||||
describe('ShellSessionManager', () => {
|
||||
it.each([false, true])('uses only the supplied environment (tty: %s)', async tty => {
|
||||
const controller = new AbortController();
|
||||
const previous = process.env.SANDBOX_WORKER_ONLY;
|
||||
process.env.SANDBOX_WORKER_ONLY = 'worker-secret';
|
||||
const manager = new ShellSessionManager({
|
||||
workingDirectory: process.cwd(),
|
||||
env: { SHELL: '/bin/sh', SANDBOX_PREPARED: 'prepared-value' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
try {
|
||||
const sessionId = await manager.startAsync({
|
||||
cmd: 'printf "%s:%s" "$SANDBOX_PREPARED" "${SANDBOX_WORKER_ONLY-unset}"',
|
||||
tty,
|
||||
});
|
||||
expect(await manager.readAsync(sessionId, 10_000)).toEqual({
|
||||
output: 'prepared-value:unset',
|
||||
exitCode: 0,
|
||||
});
|
||||
} finally {
|
||||
controller.abort();
|
||||
await manager.stoppedPromise;
|
||||
if (previous === undefined) {
|
||||
delete process.env.SANDBOX_WORKER_ONLY;
|
||||
} else {
|
||||
process.env.SANDBOX_WORKER_ONLY = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])('does not signal an exited leader (tty: %s)', async tty => {
|
||||
const controller = new AbortController();
|
||||
const manager = new ShellSessionManager({
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const kill = jest.spyOn(processes, 'killProcessGroup');
|
||||
try {
|
||||
const sessionId = await manager.startAsync({ cmd: 'exit 0', tty });
|
||||
expect(await manager.readAsync(sessionId, 10_000)).toMatchObject({ exitCode: 0 });
|
||||
controller.abort();
|
||||
await manager.stoppedPromise;
|
||||
expect(kill).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
controller.abort();
|
||||
await manager.stoppedPromise;
|
||||
kill.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps completed sessions without repeating their output', async () => {
|
||||
const controller = new AbortController();
|
||||
const manager = new ShellSessionManager({
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
signal: controller.signal,
|
||||
});
|
||||
try {
|
||||
const sessionId = await manager.startAsync({ cmd: 'printf hello' });
|
||||
expect(await manager.readAsync(sessionId, 1_000)).toEqual({ output: 'hello', exitCode: 0 });
|
||||
expect(await manager.readAsync(sessionId, 1_000)).toEqual({ output: '', exitCode: 0 });
|
||||
} finally {
|
||||
controller.abort();
|
||||
await manager.stoppedPromise;
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps sessions separate and stops only its own manager', async () => {
|
||||
const firstController = new AbortController();
|
||||
const secondController = new AbortController();
|
||||
const first = new ShellSessionManager({
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
signal: firstController.signal,
|
||||
});
|
||||
const second = new ShellSessionManager({
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
signal: secondController.signal,
|
||||
});
|
||||
try {
|
||||
const firstId = await first.startAsync({ cmd: 'printf first' });
|
||||
const secondId = await second.startAsync({ cmd: 'read value; printf "%s" "$value"' });
|
||||
expect(await first.readAsync(firstId, 1_000)).toEqual({ output: 'first', exitCode: 0 });
|
||||
firstController.abort();
|
||||
await first.stoppedPromise;
|
||||
await expect(first.startAsync({ cmd: 'printf stopped' })).rejects.toMatchObject({
|
||||
name: 'AbortError',
|
||||
});
|
||||
expect(await second.readAsync(secondId, 0)).toEqual({ output: '', sessionId: secondId });
|
||||
second.write(secondId, 'second\n');
|
||||
expect(await second.readAsync(secondId, 1_000)).toEqual({ output: 'second', exitCode: 0 });
|
||||
} finally {
|
||||
firstController.abort();
|
||||
secondController.abort();
|
||||
await Promise.all([first.stoppedPromise, second.stoppedPromise]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,15 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { graphql } from 'gql.tada';
|
||||
import fetch, { Headers } from 'node-fetch';
|
||||
import type { Readable } from 'node:stream';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import {
|
||||
DeviceRunSessionTimeoutError,
|
||||
withDeviceRunSessionTimeoutAsync,
|
||||
} from './deviceRunSessionTimeout';
|
||||
|
||||
const CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
|
||||
mutation CreateDeviceRunSessionArtifactUploadSession(
|
||||
@@ -20,82 +27,157 @@ const CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
|
||||
}
|
||||
`);
|
||||
|
||||
type ArtifactMetadata = {
|
||||
deviceRunSessionId: string;
|
||||
artifactId: string;
|
||||
name: string;
|
||||
filename: string;
|
||||
kind: string | undefined;
|
||||
metadata?: Record<string, unknown>;
|
||||
size: number;
|
||||
};
|
||||
|
||||
type ArtifactSource = { stream: Readable; reopenStream?: () => Readable };
|
||||
type ArtifactUploadSession = Awaited<
|
||||
ReturnType<typeof createDeviceRunSessionArtifactUploadSessionAsync>
|
||||
>;
|
||||
|
||||
export async function uploadDeviceRunSessionArtifactAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
deviceRunSessionId,
|
||||
artifactId,
|
||||
name,
|
||||
filename,
|
||||
kind,
|
||||
metadata,
|
||||
size,
|
||||
stream,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
artifactId: string;
|
||||
name: string;
|
||||
filename: string;
|
||||
kind: string | undefined;
|
||||
metadata?: Record<string, unknown>;
|
||||
size: number;
|
||||
stream: NodeJS.ReadableStream;
|
||||
}
|
||||
options: ArtifactMetadata & ArtifactSource & { signal?: AbortSignal }
|
||||
): Promise<void> {
|
||||
const uploadSession = await createDeviceRunSessionArtifactUploadSessionAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId,
|
||||
name,
|
||||
filename,
|
||||
kind,
|
||||
metadata,
|
||||
size,
|
||||
});
|
||||
const response = await fetch(uploadSession.url, {
|
||||
method: 'PUT',
|
||||
headers: new Headers(uploadSession.headers as Record<string, string>),
|
||||
body: stream,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new SystemError(
|
||||
`Failed to upload device run session artifact ${artifactId}: ${response.status} ${response.statusText}`,
|
||||
{ cause: response }
|
||||
try {
|
||||
const uploadSession = await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Artifact upload session creation', timeoutMs: 15_000, signal: options.signal },
|
||||
async signal => await createDeviceRunSessionArtifactUploadSessionAsync(ctx, options, signal)
|
||||
);
|
||||
options.signal?.throwIfAborted();
|
||||
await putArtifactWithRetriesAsync({
|
||||
uploadSession,
|
||||
source: options,
|
||||
artifactId: options.artifactId,
|
||||
signal: options.signal,
|
||||
});
|
||||
} finally {
|
||||
// One-shot callers may have opened their stream before upload-session creation failed.
|
||||
options.stream.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async function putArtifactWithRetriesAsync({
|
||||
uploadSession,
|
||||
source,
|
||||
artifactId,
|
||||
signal,
|
||||
}: {
|
||||
uploadSession: ArtifactUploadSession;
|
||||
source: ArtifactSource;
|
||||
artifactId: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const attempts = source.reopenStream ? 3 : 1;
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
signal?.throwIfAborted();
|
||||
const stream = attempt > 0 && source.reopenStream ? source.reopenStream() : source.stream;
|
||||
try {
|
||||
// Upload time scales with file size, so the deadline bounds stalls, not the whole request.
|
||||
await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Artifact PUT', timeoutMs: 90_000, signal },
|
||||
async (putSignal, resetDeadline) =>
|
||||
await putArtifactAsync({
|
||||
uploadSession,
|
||||
stream,
|
||||
artifactId,
|
||||
signal: putSignal,
|
||||
onProgress: resetDeadline,
|
||||
})
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
const retryable =
|
||||
error instanceof ArtifactPutError
|
||||
? [408, 429, 500, 502, 503, 504].includes(error.status)
|
||||
: error instanceof DeviceRunSessionTimeoutError ||
|
||||
(error instanceof Error && error.name === 'FetchError');
|
||||
if (!retryable || attempt + 1 === attempts) {
|
||||
throw error;
|
||||
}
|
||||
await delay(500 * (attempt + 1), undefined, { signal });
|
||||
} finally {
|
||||
stream.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function putArtifactAsync({
|
||||
uploadSession,
|
||||
stream,
|
||||
artifactId,
|
||||
signal,
|
||||
onProgress,
|
||||
}: {
|
||||
uploadSession: ArtifactUploadSession;
|
||||
stream: Readable;
|
||||
artifactId: string;
|
||||
signal: AbortSignal;
|
||||
onProgress: () => void;
|
||||
}): Promise<void> {
|
||||
const requestController = new AbortController();
|
||||
try {
|
||||
const responding = fetch(uploadSession.url, {
|
||||
method: 'PUT',
|
||||
headers: new Headers(uploadSession.headers as Record<string, string>),
|
||||
body: stream,
|
||||
signal: AbortSignal.any([signal, requestController.signal]),
|
||||
});
|
||||
// fetch has piped the body already, so this listener observes flow without starting it.
|
||||
stream.on('data', onProgress);
|
||||
const response = await responding;
|
||||
signal.throwIfAborted();
|
||||
if (!response.ok) {
|
||||
throw new ArtifactPutError(artifactId, response);
|
||||
}
|
||||
} finally {
|
||||
// node-fetch destroys the request body on abort, and its response stream is a PassThrough
|
||||
// whose destruction leaves the socket open, so the request itself is aborted here.
|
||||
requestController.abort();
|
||||
}
|
||||
}
|
||||
|
||||
class ArtifactPutError extends SystemError {
|
||||
readonly status: number;
|
||||
|
||||
constructor(artifactId: string, response: { status: number; statusText: string }) {
|
||||
super(
|
||||
`Failed to upload device run session artifact ${artifactId}: HTTP ${response.status} ${response.statusText}.`
|
||||
);
|
||||
this.status = response.status;
|
||||
}
|
||||
}
|
||||
|
||||
async function createDeviceRunSessionArtifactUploadSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
deviceRunSessionId,
|
||||
artifactId,
|
||||
name,
|
||||
filename,
|
||||
kind,
|
||||
metadata,
|
||||
size,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
artifactId: string;
|
||||
name: string;
|
||||
filename: string;
|
||||
kind: string | undefined;
|
||||
metadata?: Record<string, unknown>;
|
||||
size: number;
|
||||
}
|
||||
{ deviceRunSessionId, artifactId, name, filename, kind, metadata, size }: ArtifactMetadata,
|
||||
signal: AbortSignal
|
||||
) {
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION, {
|
||||
deviceRunSessionId,
|
||||
input: {
|
||||
name,
|
||||
filename,
|
||||
...(kind !== undefined ? { kind } : {}),
|
||||
...(metadata !== undefined ? { metadata } : {}),
|
||||
size,
|
||||
.mutation(
|
||||
CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION,
|
||||
{
|
||||
deviceRunSessionId,
|
||||
input: {
|
||||
name,
|
||||
filename,
|
||||
...(kind !== undefined ? { kind } : {}),
|
||||
...(metadata !== undefined ? { metadata } : {}),
|
||||
size,
|
||||
},
|
||||
},
|
||||
})
|
||||
graphqlAbortContext(signal)
|
||||
)
|
||||
.toPromise();
|
||||
signal.throwIfAborted();
|
||||
if (result.error) {
|
||||
throw new SystemError(
|
||||
`Failed to create upload session for device run session artifact ${artifactId}: ${result.error.message}`,
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import type { BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { readFile, readdir, 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 './deviceRunSessionArtifacts';
|
||||
|
||||
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(),
|
||||
status: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const UnlistedRecordingManifestSchema = z.object({
|
||||
udid: z.string(),
|
||||
deviceName: z.string(),
|
||||
runtimeDisplayName: z.string(),
|
||||
status: z.enum(['recording', 'failed', 'complete']),
|
||||
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 parseDeviceScreenRecordings(input: unknown): z.infer<typeof RecordingsSchema> {
|
||||
const result = RecordingsSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
throw new SystemError('Invalid device screen recordings input.', {
|
||||
cause: result.error,
|
||||
});
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Hub that was killed never lists its recording, even one it had already completed at the
|
||||
* duration limit. session.json is at "recording" or "failed" with a fragmented .partial that plays
|
||||
* up to its last keyframe, or at "complete" with the finished file. Return the ones ffprobe can read.
|
||||
*/
|
||||
export async function findUnlistedDeviceScreenRecordingsAsync({
|
||||
root,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
root: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<z.infer<typeof RecordingsSchema>> {
|
||||
const found: z.infer<typeof RecordingsSchema> = [];
|
||||
for (const entry of await readdir(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const directory = path.join(root, entry.name);
|
||||
const manifest = UnlistedRecordingManifestSchema.safeParse(
|
||||
await readFile(path.join(directory, 'session.json'), 'utf-8')
|
||||
.then(text => JSON.parse(text))
|
||||
.catch(() => null)
|
||||
);
|
||||
if (!manifest.success || path.basename(manifest.data.recording) !== manifest.data.recording) {
|
||||
continue;
|
||||
}
|
||||
const file = path.join(directory, manifest.data.recording);
|
||||
const name = path.join(entry.name, manifest.data.recording);
|
||||
let duration = 0;
|
||||
try {
|
||||
const probe = await spawn(
|
||||
'ffprobe',
|
||||
['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', file],
|
||||
{ env, stdio: 'pipe' }
|
||||
);
|
||||
duration = Number(probe.stdout.trim());
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.warn(`ffprobe is not available; cannot recover the unlisted recording ${name}.`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!(duration > 0)) {
|
||||
logger.warn(`Unlisted recording ${name} does not decode; skipping it.`);
|
||||
continue;
|
||||
}
|
||||
const { udid, deviceName, runtimeDisplayName } = manifest.data;
|
||||
found.push({ udid, deviceName, runtimeDisplayName, directory });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
export async function uploadDeviceRunSessionScreenRecordingsAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
logger,
|
||||
deviceRunSessionId,
|
||||
recordings,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
deviceRunSessionId: string;
|
||||
recordings: z.infer<typeof RecordingsSchema>;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
if (recordings.length === 0) {
|
||||
logger.info('No device screen recordings found; skipping uploads.');
|
||||
return true;
|
||||
}
|
||||
|
||||
const limit = limitFactory(5);
|
||||
const uploaded = 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'))
|
||||
);
|
||||
if (path.basename(metadata.recording) !== metadata.recording) {
|
||||
throw new Error('Recording filename must not contain a directory.');
|
||||
}
|
||||
const startedAt = recordingStartTimeFormatter.format(
|
||||
new Date(metadata.firstFrameWallClock.iso8601)
|
||||
);
|
||||
const partial = metadata.status !== undefined && metadata.status !== 'complete';
|
||||
const shortUdid = `${recording.udid.slice(0, 8)}-…`;
|
||||
const displayName = `${recording.deviceName} screen recording (${shortUdid}, started at ${startedAt}${partial ? ', partial' : ''})`;
|
||||
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,
|
||||
...(partial
|
||||
? {
|
||||
partial: true,
|
||||
partialReason:
|
||||
metadata.error ??
|
||||
'The Device Hub stopped before it could finalize the recording.',
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
size,
|
||||
stream: createReadStream(recordingPath),
|
||||
reopenStream: () => createReadStream(recordingPath),
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not upload device screen recording', error);
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
`Could not upload screen recording for ${recording.deviceName}.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
return uploaded.every(Boolean);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export class DeviceRunSessionTimeoutError extends Error {
|
||||
constructor(name: string, timeoutMs: number) {
|
||||
super(`${name} timed out after ${timeoutMs}ms.`);
|
||||
this.name = 'DeviceRunSessionTimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds waiting and signals cancellation. Operations must check the signal before later effects.
|
||||
* Calling resetDeadline restarts the timer, so the bound applies to stalls instead of total time.
|
||||
*/
|
||||
export async function withDeviceRunSessionTimeoutAsync<T>(
|
||||
{ name, timeoutMs, signal: parent }: { name: string; timeoutMs: number; signal?: AbortSignal },
|
||||
operation: (signal: AbortSignal, resetDeadline: () => void) => Promise<T>
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const signal = parent ? AbortSignal.any([parent, controller.signal]) : controller.signal;
|
||||
signal.throwIfAborted();
|
||||
let rejectAbort: (reason: unknown) => void = () => {};
|
||||
const aborted = new Promise<never>((_resolve, reject) => {
|
||||
rejectAbort = reject;
|
||||
});
|
||||
const onAbort = () => rejectAbort(signal.reason);
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
const expire = () => controller.abort(new DeviceRunSessionTimeoutError(name, timeoutMs));
|
||||
let timer = setTimeout(expire, timeoutMs);
|
||||
let settled = false;
|
||||
const resetDeadline = () => {
|
||||
clearTimeout(timer);
|
||||
if (!settled && !signal.aborted) {
|
||||
timer = setTimeout(expire, timeoutMs);
|
||||
}
|
||||
};
|
||||
try {
|
||||
const result = await Promise.race([operation(signal, resetDeadline), aborted]);
|
||||
signal.throwIfAborted();
|
||||
return result;
|
||||
} finally {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageExec,
|
||||
} from '../../utils/packageManager';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import {
|
||||
findUnlistedDeviceScreenRecordingsAsync,
|
||||
parseDeviceScreenRecordings,
|
||||
uploadDeviceRunSessionScreenRecordingsAsync,
|
||||
} from './deviceRunSessionScreenRecordings';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
type ServeSimLaunchOptions,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
findAvailablePortAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
isProcessRunning,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
} from './remoteDeviceRunSession';
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
|
||||
const WEB_PREVIEW_HOST = '127.0.0.1';
|
||||
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
|
||||
const SERVE_SIM_MAX_DIMENSION = '1600';
|
||||
const SERVE_SIM_MJPEG_QUALITY = '0.55';
|
||||
const SERVE_SIM_VIDEO_BITRATE = '10000000';
|
||||
const SERVE_SIM_VIDEO_FPS = '60';
|
||||
const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub';
|
||||
const EXPO_DEVICE_HUB_MAX_DIMENSION = '960';
|
||||
const EXPO_DEVICE_HUB_VIDEO_BITRATE = '6000000';
|
||||
const EXPO_DEVICE_HUB_VIDEO_FPS = '60';
|
||||
// On SIGTERM the Hub finalizes the recording itself, with this deadline before it force-exits,
|
||||
// so a stop request that failed here still gets one more chance to write the MP4.
|
||||
const EXPO_DEVICE_HUB_SIGTERM_FINALIZE_DEADLINE_MS = 60_000;
|
||||
const EXPO_DEVICE_HUB_EXIT_LEEWAY_MS = 10_000;
|
||||
const RECORDING_STOP_GRACE_PERIOD_MS =
|
||||
EXPO_DEVICE_HUB_SIGTERM_FINALIZE_DEADLINE_MS + EXPO_DEVICE_HUB_EXIT_LEEWAY_MS;
|
||||
const HOST_OUTPUT_TAIL_CHARS = 8_000;
|
||||
|
||||
export function websiteOrigin(env: BuildStepEnv): string {
|
||||
return env.EXPO_LOCAL
|
||||
? 'https://expo.test'
|
||||
: env.EXPO_STAGING
|
||||
? 'https://staging.expo.dev'
|
||||
: 'https://expo.dev';
|
||||
}
|
||||
|
||||
export function simulatorPreviewPageUrl(env: BuildStepEnv, subdomainId: string): string {
|
||||
return new URL(`/simulator-preview/${subdomainId}`, websiteOrigin(env)).toString();
|
||||
}
|
||||
|
||||
// Website dev servers on expo.test use staging sessions, and CORS matches ports exactly.
|
||||
const WEBSITE_DEV_ORIGINS = [
|
||||
'https://expo.test',
|
||||
'https://expo.test:13001',
|
||||
...Array.from({ length: 16 }, (_, index) => `https://expo.test:${13200 + index}`),
|
||||
];
|
||||
|
||||
export function websiteOriginServeSimArgs(env: BuildStepEnv): string[] {
|
||||
const origins = new Set([websiteOrigin(env)]);
|
||||
if (!env.EXPO_LOCAL && env.EXPO_STAGING) {
|
||||
origins.add('https://*.expo.dev');
|
||||
}
|
||||
if (env.EXPO_LOCAL || env.EXPO_STAGING) {
|
||||
for (const origin of WEBSITE_DEV_ORIGINS) {
|
||||
origins.add(origin);
|
||||
}
|
||||
}
|
||||
return [...origins].flatMap(origin => ['--cors-origin', origin, '--frame-ancestor', origin]);
|
||||
}
|
||||
|
||||
function createServeSimPackageSpec(packageVersion: string | undefined): string {
|
||||
return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
|
||||
}
|
||||
|
||||
function createExpoDeviceHubPackageSpec(packageVersion: string | undefined): string {
|
||||
return `${EXPO_DEVICE_HUB_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
|
||||
}
|
||||
|
||||
export function createServeSimArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
websiteArgs = [],
|
||||
shareUrl,
|
||||
packageVersion,
|
||||
launchAppIdentifier,
|
||||
launchArgs = [],
|
||||
openUrl,
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
websiteArgs?: string[];
|
||||
shareUrl?: string;
|
||||
packageVersion?: string;
|
||||
} & ServeSimLaunchOptions): string[] {
|
||||
return [
|
||||
createServeSimPackageSpec(packageVersion),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
WEB_PREVIEW_HOST,
|
||||
'--require-token',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'h264',
|
||||
'--max-dimension',
|
||||
SERVE_SIM_MAX_DIMENSION,
|
||||
'--mjpeg-quality',
|
||||
SERVE_SIM_MJPEG_QUALITY,
|
||||
'--video-bitrate',
|
||||
SERVE_SIM_VIDEO_BITRATE,
|
||||
'--video-fps',
|
||||
SERVE_SIM_VIDEO_FPS,
|
||||
...turnArgs,
|
||||
...websiteArgs,
|
||||
...(shareUrl ? ['--share-url', shareUrl] : []),
|
||||
...(launchAppIdentifier ? ['--launch-app-identifier', launchAppIdentifier] : []),
|
||||
...launchArgs.flatMap(argument => ['--launch-arg', argument]),
|
||||
...(openUrl ? ['--open-url', openUrl] : []),
|
||||
];
|
||||
}
|
||||
|
||||
export function createExpoDeviceHubArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
packageVersion,
|
||||
recordingDirectory,
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
packageVersion?: string;
|
||||
recordingDirectory?: string;
|
||||
}): string[] {
|
||||
return [
|
||||
createExpoDeviceHubPackageSpec(packageVersion),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
WEB_PREVIEW_HOST,
|
||||
'--platform',
|
||||
'android',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'h264',
|
||||
'--webrtc-ice-policy',
|
||||
'all',
|
||||
'--max-dimension',
|
||||
EXPO_DEVICE_HUB_MAX_DIMENSION,
|
||||
'--video-bitrate',
|
||||
EXPO_DEVICE_HUB_VIDEO_BITRATE,
|
||||
'--video-fps',
|
||||
EXPO_DEVICE_HUB_VIDEO_FPS,
|
||||
'--hide-sidebar',
|
||||
'--hide-boot-device',
|
||||
...(recordingDirectory ? ['--android-recording-directory', recordingDirectory] : []),
|
||||
...turnArgs,
|
||||
];
|
||||
}
|
||||
|
||||
const WebPreviewReadyResponseSchema = z.object({
|
||||
status: z.literal('ready'),
|
||||
device: z.string(),
|
||||
});
|
||||
|
||||
export async function waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
}: {
|
||||
previewServer: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
|
||||
serverName: string;
|
||||
port: number;
|
||||
timeoutMs: number;
|
||||
}): Promise<string> {
|
||||
const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
if (previewServer.pid !== undefined && !isProcessRunning(previewServer.pid)) {
|
||||
throw new SystemError(
|
||||
`${serverName} exited before becoming ready. Last output:\n${
|
||||
previewServer.getOutput() || '<empty>'
|
||||
}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
const response = await turtleFetch(readyUrl, 'GET', {
|
||||
retries: 0,
|
||||
timeout: 2_000,
|
||||
});
|
||||
const ready = WebPreviewReadyResponseSchema.parse(await response.json());
|
||||
return ready.device;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
throw new SystemError(
|
||||
`Timed out waiting for ${serverName} readiness at ${readyUrl}${
|
||||
lastError instanceof Error ? `: ${lastError.message}` : ''
|
||||
}. Last output:\n${previewServer.getOutput() || '<empty>'}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function readServeSimPreviewTokenAsync(
|
||||
udid: string,
|
||||
stateDir: string = SERVE_SIM_STATE_DIR
|
||||
): Promise<string | undefined> {
|
||||
const servers = await readServeSimServersAsync(stateDir);
|
||||
return servers.find(server => server.udid === udid)?.token;
|
||||
}
|
||||
|
||||
export type DeviceWebPreview = {
|
||||
previewPageUrl: string;
|
||||
apiUrl: string;
|
||||
previewToken?: string;
|
||||
/** Closes this tunnel only. The session host and recording remain running. */
|
||||
closeAsync(): Promise<void>;
|
||||
};
|
||||
|
||||
export type DeviceSessionHost = {
|
||||
openPreviewAsync(options: { baseDomain: string }): Promise<DeviceWebPreview>;
|
||||
/**
|
||||
* Terminal and idempotent. Never rejects: a failed finalization, host stop or upload is logged
|
||||
* and reported, so callers need no error handling around it.
|
||||
*/
|
||||
finishAsync(): Promise<void>;
|
||||
};
|
||||
|
||||
type AndroidSessionRecording = {
|
||||
deviceRunSessionId: string;
|
||||
directory: string;
|
||||
controlToken: string;
|
||||
env: BuildStepEnv;
|
||||
};
|
||||
|
||||
export async function startDeviceSessionHostAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
packageVersion,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
} & ServeSimLaunchOptions
|
||||
): Promise<DeviceSessionHost> {
|
||||
const isAndroid = runtimePlatform === BuildRuntimePlatform.LINUX;
|
||||
// Unreachable from the step functions, which reject a non-Darwin launch while parsing.
|
||||
// Kept because this function is exported and expo-device-hub cannot launch.
|
||||
if (isAndroid && launchAppIdentifier) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
`Cannot launch ${launchAppIdentifier}: an application launch runs through serve-sim on an iOS simulator, and this session runs expo-device-hub on ${runtimePlatform}.`
|
||||
);
|
||||
}
|
||||
if (isAndroid) {
|
||||
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
}
|
||||
const recording: AndroidSessionRecording | null = isAndroid
|
||||
? {
|
||||
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
|
||||
directory: await fs.promises.mkdtemp(path.join(os.tmpdir(), 'android-session-recordings-')),
|
||||
controlToken: randomBytes(32).toString('hex'),
|
||||
env,
|
||||
}
|
||||
: null;
|
||||
const subdomainId = randomBytes(16).toString('hex');
|
||||
const previewPageUrl = simulatorPreviewPageUrl(env, subdomainId);
|
||||
const port = await findAvailablePortAsync();
|
||||
const serverName = isAndroid ? 'expo-device-hub' : 'serve-sim';
|
||||
const packageSpec = isAndroid
|
||||
? createExpoDeviceHubPackageSpec(packageVersion)
|
||||
: createServeSimPackageSpec(packageVersion);
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
|
||||
const previewExec = resolvePackageExec(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
isAndroid
|
||||
? createExpoDeviceHubArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
packageVersion,
|
||||
recordingDirectory: recording?.directory,
|
||||
})
|
||||
: createServeSimArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
packageVersion,
|
||||
websiteArgs: websiteOriginServeSimArgs(env),
|
||||
shareUrl: previewPageUrl,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
})
|
||||
);
|
||||
logger.info(
|
||||
`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port} via ${previewExec.command}.`
|
||||
);
|
||||
const previewServer = spawnDetached({
|
||||
command: previewExec.command,
|
||||
args: previewExec.args,
|
||||
env: recording
|
||||
? { ...env, EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN: recording.controlToken }
|
||||
: env,
|
||||
stopGracePeriodMs: recording ? RECORDING_STOP_GRACE_PERIOD_MS : undefined,
|
||||
});
|
||||
|
||||
let previewToken: string | undefined;
|
||||
let previewTask: Promise<DeviceWebPreview> | null = null;
|
||||
let finishTask: Promise<void> | null = null;
|
||||
let hostReady = false;
|
||||
|
||||
const host: DeviceSessionHost = {
|
||||
openPreviewAsync({ baseDomain }) {
|
||||
if (finishTask) {
|
||||
return Promise.reject(
|
||||
new SystemError('Cannot open a preview after session host finalization.')
|
||||
);
|
||||
}
|
||||
if (previewTask) {
|
||||
return previewTask;
|
||||
}
|
||||
const opening = (async (): Promise<DeviceWebPreview> => {
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix: 'web-preview',
|
||||
subdomainId,
|
||||
baseDomain,
|
||||
authtoken: getNgrokAuthtokenOrThrow(env),
|
||||
logger,
|
||||
});
|
||||
if (finishTask) {
|
||||
await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Late preview tunnel close', timeoutMs: 5_000 },
|
||||
async () => await tunnel.stopAsync()
|
||||
).catch(err => logger.warn({ err }, 'Could not close a late preview tunnel.'));
|
||||
throw new SystemError('Session host finalized while the preview was opening.');
|
||||
}
|
||||
let closeTask: Promise<void> | null = null;
|
||||
return {
|
||||
previewPageUrl,
|
||||
apiUrl: tunnel.url,
|
||||
previewToken,
|
||||
closeAsync() {
|
||||
return (closeTask ??= (async () => {
|
||||
try {
|
||||
await tunnel.stopAsync();
|
||||
} finally {
|
||||
if (previewTask === opening) {
|
||||
previewTask = null;
|
||||
}
|
||||
}
|
||||
})());
|
||||
},
|
||||
};
|
||||
})();
|
||||
previewTask = opening;
|
||||
// A failed tunnel may be retried without restarting capture.
|
||||
void opening.catch(() => {
|
||||
if (previewTask === opening) {
|
||||
previewTask = null;
|
||||
}
|
||||
});
|
||||
return opening;
|
||||
},
|
||||
finishAsync() {
|
||||
return (finishTask ??= finishDeviceSessionHostAsync(ctx, {
|
||||
previewTask,
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
// A host that never answered /readyz has nothing to finalize or upload.
|
||||
recording: hostReady ? recording : null,
|
||||
logger,
|
||||
}));
|
||||
},
|
||||
};
|
||||
try {
|
||||
logger.info(`Waiting for ${serverName} to become ready.`);
|
||||
const device = await waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
});
|
||||
hostReady = true;
|
||||
if (!isAndroid) {
|
||||
previewToken = await readServeSimPreviewTokenAsync(device);
|
||||
if (!previewToken) {
|
||||
throw new SystemError(
|
||||
`serve-sim became ready but wrote no session token for device ${device}. The preview is ` +
|
||||
'on a public tunnel and would be reachable without one, so the session cannot continue. ' +
|
||||
'This usually means the state file was not written as expected; retry the session, and ' +
|
||||
'report it if it repeats.'
|
||||
);
|
||||
}
|
||||
}
|
||||
return host;
|
||||
} catch (error) {
|
||||
await host.finishAsync();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function finishDeviceSessionHostAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
previewTask,
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
recording,
|
||||
logger,
|
||||
}: {
|
||||
previewTask: Promise<DeviceWebPreview> | null;
|
||||
previewServer: DetachedProcessHandle;
|
||||
serverName: string;
|
||||
port: number;
|
||||
recording: AndroidSessionRecording | null;
|
||||
logger: bunyan;
|
||||
}
|
||||
): Promise<void> {
|
||||
// Native ngrok operations have no scoped cancellation. Retire a late listener too.
|
||||
const retirePreview = withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Preview tunnel retirement', timeoutMs: 5_000 },
|
||||
async () => {
|
||||
// A preview that failed to open, or was closed by its opener after finish began, has no tunnel left.
|
||||
const preview = await previewTask?.catch(() => null);
|
||||
await preview?.closeAsync();
|
||||
}
|
||||
).catch(err => {
|
||||
logger.warn({ err }, `Could not close the ${serverName} preview tunnel within its deadline.`);
|
||||
});
|
||||
let finalization: AndroidRecordingFinalization | null = null;
|
||||
if (recording) {
|
||||
// stopAsync signals the whole process group, including capture's encoder.
|
||||
// Finalize the MP4 first; the token protects this route on the preview server.
|
||||
finalization = await finalizeAndroidRecordingAsync({
|
||||
port,
|
||||
controlToken: recording.controlToken,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
let hostStopped = false;
|
||||
try {
|
||||
await previewServer.stopAsync();
|
||||
if (previewServer.pid !== undefined && isProcessRunning(previewServer.pid)) {
|
||||
throw new Error('Session host is still running after shutdown.');
|
||||
}
|
||||
hostStopped = true;
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `Could not stop the ${serverName} session host.`);
|
||||
}
|
||||
await retirePreview;
|
||||
// A Hub that never recorded has logged its reason and left nothing to upload.
|
||||
const captured = finalization !== 'not-recording';
|
||||
let uploaded = false;
|
||||
if (recording && captured && hostStopped) {
|
||||
uploaded = await uploadFinishedAndroidRecordingAsync(ctx, { recording, logger });
|
||||
}
|
||||
if (recording && captured && (finalization === 'failed' || !uploaded)) {
|
||||
// The Hub reports capture failures on stderr; the stop route answers with only a summary.
|
||||
logger.warn(
|
||||
{ hostOutput: previewServer.getOutput().slice(-HOST_OUTPUT_TAIL_CHARS) || '<empty>' },
|
||||
'Session host output around the recording failure.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type AndroidRecordingFinalization = 'finalized' | 'not-recording' | 'failed';
|
||||
|
||||
async function finalizeAndroidRecordingAsync({
|
||||
port,
|
||||
controlToken,
|
||||
logger,
|
||||
}: {
|
||||
port: number;
|
||||
controlToken: string;
|
||||
logger: bunyan;
|
||||
}): Promise<AndroidRecordingFinalization> {
|
||||
try {
|
||||
const response = await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Android recording finalization', timeoutMs: 60_000 },
|
||||
async signal =>
|
||||
await turtleFetch(
|
||||
`http://${WEB_PREVIEW_HOST}:${port}/_eas/android-recording/stop`,
|
||||
'POST',
|
||||
{
|
||||
headers: { Authorization: `Bearer ${controlToken}` },
|
||||
retries: 0,
|
||||
shouldThrowOnNotOk: false,
|
||||
signal,
|
||||
}
|
||||
)
|
||||
);
|
||||
if (response.status === 409) {
|
||||
// The Hub answers 409 with the reason nothing was recorded, such as the emulator count.
|
||||
logger.warn(`Android recording was not captured: ${await response.text()}`);
|
||||
return 'not-recording';
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new SystemError(
|
||||
`Android recording finalization returned HTTP ${response.status}: ${await response.text()}`
|
||||
);
|
||||
}
|
||||
return 'finalized';
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not finalize Android recording before shutdown', error, {
|
||||
level: 'warning',
|
||||
});
|
||||
logger.warn({ err: error }, 'Could not finalize Android recording before shutdown.');
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFinishedAndroidRecordingAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{ recording, logger }: { recording: AndroidSessionRecording; logger: bunyan }
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
let recordings = parseDeviceScreenRecordings(
|
||||
JSON.parse(
|
||||
await fs.promises.readFile(path.join(recording.directory, 'recordings.json'), 'utf8')
|
||||
)
|
||||
);
|
||||
if (recordings.length === 0) {
|
||||
// recordings.json is written at finish; a killed Hub leaves what it had on disk unlisted.
|
||||
recordings = await findUnlistedDeviceScreenRecordingsAsync({
|
||||
root: recording.directory,
|
||||
env: recording.env,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
for (const item of recordings) {
|
||||
if (
|
||||
path.dirname(path.resolve(item.directory)) !== recording.directory ||
|
||||
(await fs.promises.lstat(item.directory)).isSymbolicLink()
|
||||
) {
|
||||
throw new Error('Recording directory is not an owned session child.');
|
||||
}
|
||||
}
|
||||
const uploaded = await uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: recording.deviceRunSessionId,
|
||||
recordings,
|
||||
});
|
||||
if (recordings.length > 0 && uploaded) {
|
||||
await fs.promises.rm(recording.directory, { recursive: true });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not upload the Android session recording', error, { level: 'warning' });
|
||||
logger.warn(
|
||||
{ err: error, recordingDirectory: recording.directory },
|
||||
'Could not upload the Android session recording.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
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 { distributionCertificate } from '../__tests__/fixtures';
|
||||
import Keychain from '../keychain';
|
||||
|
||||
const mockLogger = createLogger({ name: 'mock-logger' });
|
||||
|
||||
jest.setTimeout(60 * 1000);
|
||||
|
||||
// Those are in fact "real" tests in that they really execute the `fastlane` commands.
|
||||
// We need the JS code to modify the file system, so we need to mock it.
|
||||
jest.unmock('fs');
|
||||
|
||||
describe('Keychain class', () => {
|
||||
describe('ensureCertificateImported method', () => {
|
||||
let keychain: Keychain;
|
||||
const certificatePath = path.join(os.tmpdir(), `cert-${uuid()}.p12`);
|
||||
|
||||
beforeAll(async () => {
|
||||
await fs.writeFile(
|
||||
certificatePath,
|
||||
new Uint8Array(Buffer.from(distributionCertificate.dataBase64, 'base64'))
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.remove(certificatePath);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
keychain = new Keychain();
|
||||
await keychain.create(mockLogger);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await keychain.destroy(mockLogger);
|
||||
});
|
||||
|
||||
it("should throw an error if the certificate hasn't been imported", async () => {
|
||||
await expect(
|
||||
keychain.ensureCertificateImported(
|
||||
distributionCertificate.teamId,
|
||||
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(
|
||||
mockLogger,
|
||||
certificatePath,
|
||||
distributionCertificate.password
|
||||
);
|
||||
await expect(
|
||||
keychain.ensureCertificateImported(
|
||||
distributionCertificate.teamId,
|
||||
distributionCertificate.fingerprint
|
||||
)
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { createLogger } from '@expo/logger';
|
||||
|
||||
import { provisioningProfile } from './fixtures';
|
||||
import Keychain from '../keychain';
|
||||
import Keychain from '../../../../../ios/credentials/keychain';
|
||||
import ProvisioningProfile from '../provisioningProfile';
|
||||
|
||||
const mockLogger = createLogger({ name: 'mock-logger' });
|
||||
@@ -18,11 +18,11 @@ describe('ProvisioningProfile class', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
keychain = new Keychain();
|
||||
await keychain.create(mockLogger);
|
||||
await keychain.create({ logger: mockLogger });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await keychain.destroy(mockLogger);
|
||||
await keychain.destroy({ logger: mockLogger });
|
||||
});
|
||||
|
||||
it("shouldn't throw any error if the provisioning profile and distribution certificate match", async () => {
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { runFastlane } from '../fastlane';
|
||||
|
||||
export default class Keychain {
|
||||
private readonly keychainPath: string;
|
||||
private readonly keychainPassword: string;
|
||||
private created = false;
|
||||
private destroyed = false;
|
||||
|
||||
constructor() {
|
||||
this.keychainPath = path.join(os.tmpdir(), `eas-build-${uuid()}.keychain`);
|
||||
this.keychainPassword = uuid();
|
||||
}
|
||||
|
||||
get data(): { path: string; password: string } {
|
||||
return {
|
||||
path: this.keychainPath,
|
||||
password: this.keychainPassword,
|
||||
};
|
||||
}
|
||||
|
||||
public async create(logger: bunyan): Promise<void> {
|
||||
logger.debug(`Creating keychain - ${this.keychainPath}`);
|
||||
await runFastlane([
|
||||
'run',
|
||||
'create_keychain',
|
||||
`path:${this.keychainPath}`,
|
||||
`password:${this.keychainPassword}`,
|
||||
'unlock:true',
|
||||
'timeout:360000',
|
||||
]);
|
||||
this.created = true;
|
||||
}
|
||||
|
||||
public async importCertificate(
|
||||
logger: bunyan,
|
||||
certPath: string,
|
||||
certPassword: string
|
||||
): Promise<void> {
|
||||
if (!this.created) {
|
||||
throw new Error('You must create a keychain first.');
|
||||
}
|
||||
|
||||
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}`,
|
||||
]);
|
||||
}
|
||||
|
||||
public async ensureCertificateImported(teamId: string, fingerprint: string): Promise<void> {
|
||||
const identities = await this.findIdentitiesByTeamId(teamId);
|
||||
if (!identities.includes(fingerprint)) {
|
||||
throw new Error(
|
||||
`Distribution certificate with fingerprint ${fingerprint} hasn't been imported successfully`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(logger: bunyan, keychainPath?: string): Promise<void> {
|
||||
if (!keychainPath && !this.created) {
|
||||
logger.warn("There is nothing to destroy, a keychain hasn't been created yet.");
|
||||
return;
|
||||
}
|
||||
if (this.destroyed) {
|
||||
logger.warn('The keychain has been already destroyed');
|
||||
return;
|
||||
}
|
||||
const keychainToDeletePath = keychainPath ?? this.keychainPath;
|
||||
logger.info(`Destroying keychain - ${keychainToDeletePath}`);
|
||||
try {
|
||||
await runFastlane(['run', 'delete_keychain', `keychain_path:${keychainToDeletePath}`]);
|
||||
this.destroyed = true;
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to delete the keychain\n');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
public async cleanUpKeychains(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)
|
||||
);
|
||||
const turtleKeychainList = keychainList.filter(keychain =>
|
||||
/eas-build-[\w-]+\.keychain$/.exec(keychain)
|
||||
);
|
||||
for (const turtleKeychainPath of turtleKeychainList) {
|
||||
await this.destroy(logger, turtleKeychainPath);
|
||||
}
|
||||
}
|
||||
|
||||
private async findIdentitiesByTeamId(teamId: string): Promise<string> {
|
||||
const { output } = await spawn(
|
||||
'security',
|
||||
['find-identity', '-v', '-s', `(${teamId})`, this.keychainPath],
|
||||
{
|
||||
stdio: 'pipe',
|
||||
}
|
||||
);
|
||||
return output.join('');
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import path from 'path';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import * as distributionCertificateUtils from './distributionCertificate';
|
||||
import Keychain from './keychain';
|
||||
import Keychain from '../../../../ios/credentials/keychain';
|
||||
import ProvisioningProfile, {
|
||||
DistributionType,
|
||||
ProvisioningProfileData,
|
||||
@@ -36,7 +36,7 @@ export default class IosCredentialsManager {
|
||||
|
||||
logger.info('Creating keychain');
|
||||
this.keychain = new Keychain();
|
||||
await this.keychain.create(logger);
|
||||
await this.keychain.create({ logger });
|
||||
|
||||
const targets = Object.keys(this.buildCredentials);
|
||||
const targetProvisioningProfiles: TargetProvisioningProfiles = {};
|
||||
@@ -70,7 +70,7 @@ export default class IosCredentialsManager {
|
||||
}
|
||||
|
||||
if (this.keychain) {
|
||||
await this.keychain.destroy(logger);
|
||||
await this.keychain.destroy({ logger });
|
||||
}
|
||||
if (this.provisioningProfiles) {
|
||||
for (const provisioningProfile of this.provisioningProfiles) {
|
||||
@@ -109,11 +109,11 @@ export default class IosCredentialsManager {
|
||||
);
|
||||
|
||||
logger.info('Importing distribution certificate into the keychain');
|
||||
await this.keychain.importCertificate(
|
||||
await this.keychain.importCertificate({
|
||||
logger,
|
||||
distCertPath,
|
||||
targetCredentials.distributionCertificate.password
|
||||
);
|
||||
certPath: distCertPath,
|
||||
certPassword: targetCredentials.distributionCertificate.password,
|
||||
});
|
||||
|
||||
logger.info('Initializing provisioning profile');
|
||||
const provisioningProfile = new ProvisioningProfile(
|
||||
@@ -125,10 +125,10 @@ export default class IosCredentialsManager {
|
||||
await provisioningProfile.init(logger);
|
||||
|
||||
logger.info('Validating whether distribution certificate has been imported successfully');
|
||||
await this.keychain.ensureCertificateImported(
|
||||
provisioningProfile.data.teamId,
|
||||
certificateFingerprint
|
||||
);
|
||||
await this.keychain.ensureCertificateImported({
|
||||
teamId: provisioningProfile.data.teamId,
|
||||
fingerprint: certificateFingerprint,
|
||||
});
|
||||
|
||||
logger.info('Verifying whether the distribution certificate and provisioning profile match');
|
||||
provisioningProfile.verifyCertificate(certificateFingerprint);
|
||||
|
||||
@@ -11,6 +11,7 @@ import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
|
||||
import { type DetachedProcessHandle, spawnDetached } from './remoteDeviceRunSession';
|
||||
@@ -344,6 +345,82 @@ export async function configureSystemProxyAsync({
|
||||
return { service };
|
||||
}
|
||||
|
||||
export const LOCAL_EGRESS_NO_PROXY = 'localhost,127.0.0.1,::1';
|
||||
|
||||
/**
|
||||
* Proxy environment for processes inside the simulator. CFNetwork ignores these
|
||||
* variables and follows the system proxy, but clients with their own network
|
||||
* stack that read them (gRPC and therefore Firestore, libcurl, Go) then send
|
||||
* HTTP CONNECT requests to the same loopback port. Loopback is excluded so the
|
||||
* ports forwarded for `--egress-allow` behave the same on both paths. Lowercase
|
||||
* and uppercase forms are both set because clients differ in which they read.
|
||||
*/
|
||||
export function buildLocalEgressSimulatorEnvironment(port: number): Record<string, string> {
|
||||
const proxyUrl = `http://${LOCAL_EGRESS_PROXY_HOST}:${port}`;
|
||||
return {
|
||||
http_proxy: proxyUrl,
|
||||
https_proxy: proxyUrl,
|
||||
HTTP_PROXY: proxyUrl,
|
||||
HTTPS_PROXY: proxyUrl,
|
||||
grpc_proxy: proxyUrl,
|
||||
no_proxy: LOCAL_EGRESS_NO_PROXY,
|
||||
NO_PROXY: LOCAL_EGRESS_NO_PROXY,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the local egress proxy environment in a booted simulator when a local
|
||||
* egress session is active. Returns false when local egress is not configured
|
||||
* or the environment could not be set. Failure is a warning, not an error: the
|
||||
* session still works, but clients that read these variables then exit from
|
||||
* this worker instead of the egress client, which the warning spells out.
|
||||
*/
|
||||
export async function configureSimulatorProxyEnvironmentAsync({
|
||||
udid,
|
||||
env,
|
||||
logger,
|
||||
handoffPath = LOCAL_EGRESS_HANDOFF_PATH,
|
||||
}: {
|
||||
udid: IosSimulatorUuid;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
handoffPath?: string;
|
||||
}): Promise<boolean> {
|
||||
let handoff: LocalEgressHandoff | null;
|
||||
try {
|
||||
handoff = await readLocalEgressHandoffAsync(handoffPath);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Local egress: could not read the local egress handoff, so proxy environment variables were ' +
|
||||
'not set in the Simulator. Clients that read http_proxy/https_proxy (for example gRPC and ' +
|
||||
'libcurl) will bypass local egress and exit from this worker.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (!handoff) {
|
||||
return false;
|
||||
}
|
||||
const variables = buildLocalEgressSimulatorEnvironment(handoff.port);
|
||||
try {
|
||||
await IosSimulatorUtils.setLaunchdEnvironmentAsync({ udid, env, variables });
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Local egress: could not set proxy environment variables in the Simulator. Clients that read ' +
|
||||
'http_proxy/https_proxy (for example gRPC and libcurl) will bypass local egress and exit from ' +
|
||||
'this worker. CFNetwork clients (WebKit, URLSession) still follow the system proxy.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
logger.info(
|
||||
`Local egress: proxy environment variables set in the Simulator (${Object.keys(variables).join(
|
||||
', '
|
||||
)}), so clients that read them, such as gRPC and libcurl, also exit from the egress client.`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function writeLocalEgressHandoffAsync(
|
||||
handoff: LocalEgressHandoff,
|
||||
handoffPath: string = LOCAL_EGRESS_HANDOFF_PATH
|
||||
|
||||
@@ -0,0 +1,699 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
|
||||
import {
|
||||
LOCAL_EGRESS_HANDOFF_PATH,
|
||||
buildLocalEgressSimulatorEnvironment,
|
||||
collectSimulatorProcessIds,
|
||||
readLocalEgressHandoffAsync,
|
||||
} from './localEgress';
|
||||
|
||||
/**
|
||||
* Worker side of the local egress guard: a dylib injected into every process
|
||||
* the simulator launches, which refuses connections that do not go to
|
||||
* loopback (where the proxy and the `--egress-allow` forwards live) and
|
||||
* records one event per destination per process. This module installs it
|
||||
* through the simulator's launchd environment and relays its events into the
|
||||
* session log. See resources/egress-guard/README.md.
|
||||
*/
|
||||
|
||||
export const EGRESS_GUARD_LIBRARY_FILE = 'egress-guard.dylib';
|
||||
export const EGRESS_GUARD_CHECK_FILE = 'egress-guard-check';
|
||||
export const EGRESS_GUARD_LOG_ENV = 'EAS_EGRESS_GUARD_LOG';
|
||||
export const EGRESS_GUARD_MODE_ENV = 'EAS_EGRESS_GUARD_MODE';
|
||||
export const LOCAL_EGRESS_GUARD_LOG_PATH = path.join(os.tmpdir(), 'eas-local-egress-guard.log');
|
||||
const GUARD_EVENT_PREFIX = 'eas-egress-guard';
|
||||
/** Written once by a process whose per-process table of destinations is full; see policy.h. */
|
||||
const GUARD_OVERFLOW_FUNCTION = 'overflow';
|
||||
const GUARD_RELAY_LOG_LIMIT = 200;
|
||||
const GUARD_TAIL_INTERVAL_MS = 1_000;
|
||||
|
||||
export type EgressGuardMode = 'block' | 'log';
|
||||
|
||||
export function buildGuardLaunchdEnvironment({
|
||||
libraryPath,
|
||||
logPath,
|
||||
mode,
|
||||
}: {
|
||||
libraryPath: string;
|
||||
logPath: string;
|
||||
mode: EgressGuardMode;
|
||||
}): Record<string, string> {
|
||||
return {
|
||||
DYLD_INSERT_LIBRARIES: libraryPath,
|
||||
[EGRESS_GUARD_LOG_ENV]: logPath,
|
||||
[EGRESS_GUARD_MODE_ENV]: mode,
|
||||
};
|
||||
}
|
||||
|
||||
export type GuardEvent = {
|
||||
process: string;
|
||||
pid: number;
|
||||
function: string;
|
||||
action: 'blocked' | 'logged';
|
||||
peer: string;
|
||||
/** Image names above the interposer, innermost first. */
|
||||
callers: string[];
|
||||
};
|
||||
|
||||
/** One tab-separated line written by the guard; see policy.h for the format. */
|
||||
export function parseGuardLogLine(line: string): GuardEvent | null {
|
||||
const fields = line.split('\t');
|
||||
if (fields.length < 7 || fields[0] !== GUARD_EVENT_PREFIX) {
|
||||
return null;
|
||||
}
|
||||
const [, process, pidText, fn, action, peer, callerText] = fields;
|
||||
const pid = Number(pidText);
|
||||
if (!Number.isInteger(pid) || (action !== 'blocked' && action !== 'logged') || !fn || !peer) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
process,
|
||||
pid,
|
||||
function: fn,
|
||||
action,
|
||||
peer,
|
||||
callers: callerText ? callerText.split(',').filter(Boolean) : [],
|
||||
};
|
||||
}
|
||||
|
||||
const PACKAGED_BIN_DIR = path.join(__dirname, '..', '..', '..', 'bin');
|
||||
|
||||
async function resolvePackagedFileAsync(binDir: string, file: string): Promise<string | null> {
|
||||
const filePath = path.join(binDir, file);
|
||||
try {
|
||||
await fs.promises.access(filePath);
|
||||
return filePath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The packaged library, next to the compiled package like record-sim. */
|
||||
export async function resolveEgressGuardLibraryAsync(
|
||||
binDir: string = PACKAGED_BIN_DIR
|
||||
): Promise<string | null> {
|
||||
return await resolvePackagedFileAsync(binDir, EGRESS_GUARD_LIBRARY_FILE);
|
||||
}
|
||||
|
||||
/** The packaged self-check binary, built alongside the library. */
|
||||
export async function resolveEgressGuardCheckAsync(
|
||||
binDir: string = PACKAGED_BIN_DIR
|
||||
): Promise<string | null> {
|
||||
return await resolvePackagedFileAsync(binDir, EGRESS_GUARD_CHECK_FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns guard events into session log lines: one line the first time a
|
||||
* process reaches a destination through a given call, counts after that, and
|
||||
* a summary at the end.
|
||||
*/
|
||||
export class GuardEventRelay {
|
||||
private readonly seen = new Set<string>();
|
||||
private readonly peers = new Set<string>();
|
||||
private readonly processes = new Set<string>();
|
||||
private blocked = 0;
|
||||
private logged = 0;
|
||||
private suppressed = 0;
|
||||
private logger: bunyan;
|
||||
|
||||
constructor(
|
||||
logger: bunyan,
|
||||
private readonly limit: number = GUARD_RELAY_LOG_LIMIT
|
||||
) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log through a different step's logger from now on. Refusals happen for
|
||||
* the life of the session, so they belong to the step that runs the
|
||||
* session, not the one that booted the simulator minutes earlier.
|
||||
*/
|
||||
setLogger(logger: bunyan): void {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
handle(event: GuardEvent): void {
|
||||
if (event.process === EGRESS_GUARD_CHECK_FILE) {
|
||||
// The self-check deliberately trips the guard once; not a bypass.
|
||||
return;
|
||||
}
|
||||
if (event.function === GUARD_OVERFLOW_FUNCTION) {
|
||||
const verb = event.action === 'blocked' ? 'refused' : 'observed';
|
||||
this.logger.info(
|
||||
`Local egress guard: ${event.process} (pid ${event.pid}) reached the limit of ${event.peer} listed per process; further distinct destinations from it are ${verb} but not listed.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.action === 'blocked') {
|
||||
this.blocked++;
|
||||
} else {
|
||||
this.logged++;
|
||||
}
|
||||
this.peers.add(event.peer);
|
||||
this.processes.add(event.process);
|
||||
const key = `${event.process}|${event.function}|${event.peer}`;
|
||||
if (this.seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
this.seen.add(key);
|
||||
if (this.seen.size > this.limit) {
|
||||
this.suppressed++;
|
||||
return;
|
||||
}
|
||||
const verb = event.action === 'blocked' ? 'refused' : 'observed';
|
||||
const callers = event.callers.length ? `; callers: ${event.callers.join(', ')}` : '';
|
||||
this.logger.info(
|
||||
`Local egress guard: ${verb} ${event.function} from ${event.process} (pid ${event.pid}) to ${event.peer}${callers}`
|
||||
);
|
||||
}
|
||||
|
||||
summary(): { blocked: number; logged: number; distinct: number; suppressed: number } {
|
||||
return {
|
||||
blocked: this.blocked,
|
||||
logged: this.logged,
|
||||
distinct: this.peers.size,
|
||||
suppressed: this.suppressed,
|
||||
};
|
||||
}
|
||||
|
||||
logSummary(): void {
|
||||
const { blocked, logged, distinct, suppressed } = this.summary();
|
||||
const observed = logged ? ` and observed ${logged} more without refusing` : '';
|
||||
const dropped = suppressed
|
||||
? ` ${suppressed} further distinct destination(s) were not logged individually.`
|
||||
: '';
|
||||
this.logger.info(
|
||||
`Local egress guard: refused ${blocked} connection attempt(s) to ${distinct} distinct destination(s) from ${this.processes.size} process(es)${observed}.${dropped}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls a file the simulator processes append to and delivers whole lines.
|
||||
* Tolerates the file not existing yet, partial trailing lines, and truncation.
|
||||
*/
|
||||
export class GuardLogTailer {
|
||||
private readonly path: string;
|
||||
private readonly onLine: (line: string) => void;
|
||||
private readonly intervalMs: number;
|
||||
private offset = 0;
|
||||
private partial = '';
|
||||
private timer: NodeJS.Timeout | undefined;
|
||||
private reading: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor({
|
||||
path: filePath,
|
||||
onLine,
|
||||
intervalMs = GUARD_TAIL_INTERVAL_MS,
|
||||
}: {
|
||||
path: string;
|
||||
onLine: (line: string) => void;
|
||||
intervalMs?: number;
|
||||
}) {
|
||||
this.path = filePath;
|
||||
this.onLine = onLine;
|
||||
this.intervalMs = intervalMs;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.timer = setInterval(() => {
|
||||
this.reading = this.reading.then(() => this.readAsync()).catch(() => {});
|
||||
}, this.intervalMs);
|
||||
this.timer.unref();
|
||||
}
|
||||
|
||||
async stopAsync(): Promise<void> {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
await this.reading.catch(() => {});
|
||||
await this.readAsync().catch(() => {});
|
||||
}
|
||||
|
||||
private async readAsync(): Promise<void> {
|
||||
let handle: fs.promises.FileHandle;
|
||||
try {
|
||||
handle = await fs.promises.open(this.path, 'r');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
const { size } = await handle.stat();
|
||||
if (size < this.offset) {
|
||||
// Truncated or replaced; start over.
|
||||
this.offset = 0;
|
||||
this.partial = '';
|
||||
}
|
||||
if (size === this.offset) {
|
||||
return;
|
||||
}
|
||||
const buffer = new Uint8Array(size - this.offset);
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, this.offset);
|
||||
this.offset += bytesRead;
|
||||
const text = this.partial + Buffer.from(buffer.buffer, 0, bytesRead).toString('utf8');
|
||||
const lines = text.split('\n');
|
||||
this.partial = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
if (line.length > 0) {
|
||||
this.onLine(line);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The environment the simulator's launchd must have from its first process:
|
||||
* the guard and the proxy variables. Pass it to `IosSimulatorUtils.bootAsync`,
|
||||
* which hands it to launchd before anything is spawned; `launchctl setenv`
|
||||
* after boot only reaches later processes. Returns null when no local egress
|
||||
* session is active. Throws when the guard library is not packaged, since a
|
||||
* local egress session without it would silently leak.
|
||||
*/
|
||||
export async function resolveLocalEgressBootEnvironmentAsync({
|
||||
handoffPath = LOCAL_EGRESS_HANDOFF_PATH,
|
||||
libraryPath,
|
||||
logPath = LOCAL_EGRESS_GUARD_LOG_PATH,
|
||||
mode = 'block',
|
||||
}: {
|
||||
handoffPath?: string;
|
||||
/** Explicit library path, `null` for "not available"; resolved from the package when omitted. */
|
||||
libraryPath?: string | null;
|
||||
logPath?: string;
|
||||
mode?: EgressGuardMode;
|
||||
} = {}): Promise<Record<string, string> | null> {
|
||||
const handoff = await readLocalEgressHandoffAsync(handoffPath);
|
||||
if (!handoff) {
|
||||
return null;
|
||||
}
|
||||
const resolvedLibrary =
|
||||
libraryPath === undefined ? await resolveEgressGuardLibraryAsync() : libraryPath;
|
||||
if (!resolvedLibrary) {
|
||||
throw new SystemError(
|
||||
'The local egress guard library is not available on this device host, so this local egress session ' +
|
||||
'cannot guarantee that connections bypassing the system proxy are refused. The device host image is ' +
|
||||
'missing bin/egress-guard.dylib; this is a service problem, please contact support.'
|
||||
);
|
||||
}
|
||||
return {
|
||||
...buildGuardLaunchdEnvironment({ libraryPath: resolvedLibrary, logPath, mode }),
|
||||
...buildLocalEgressSimulatorEnvironment(handoff.port),
|
||||
};
|
||||
}
|
||||
|
||||
type ActiveRelay = { tailer: GuardLogTailer; relay: GuardEventRelay };
|
||||
const activeRelays = new Map<string, ActiveRelay>();
|
||||
|
||||
/**
|
||||
* Install the guard into a simulator when a local egress session is active,
|
||||
* and start relaying its events into the session log. Returns false when
|
||||
* there is no local egress session. Throws when the library is not packaged
|
||||
* or launchd could not be configured: a local egress session without the
|
||||
* guard would silently leak, so it must not start. Only an unwritable event
|
||||
* log is a warning, since refusals still happen and only reporting is lost.
|
||||
*
|
||||
* Call this as soon as `simctl boot` returns: launchd is up and nothing else
|
||||
* has started, so every process the boot spawns inherits the guard. Verify
|
||||
* with `verifyLocalEgressGuardAsync` once boot completes.
|
||||
*/
|
||||
export async function installLocalEgressGuardAsync({
|
||||
udid,
|
||||
env,
|
||||
logger,
|
||||
handoffPath = LOCAL_EGRESS_HANDOFF_PATH,
|
||||
libraryPath,
|
||||
logPath = LOCAL_EGRESS_GUARD_LOG_PATH,
|
||||
mode = 'block',
|
||||
tailIntervalMs,
|
||||
}: {
|
||||
udid: IosSimulatorUuid;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
handoffPath?: string;
|
||||
/** Explicit library path, `null` for "not available"; resolved from the package when omitted. */
|
||||
libraryPath?: string | null;
|
||||
logPath?: string;
|
||||
mode?: EgressGuardMode;
|
||||
tailIntervalMs?: number;
|
||||
}): Promise<boolean> {
|
||||
let handoff;
|
||||
try {
|
||||
handoff = await readLocalEgressHandoffAsync(handoffPath);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Local egress guard: could not read the local egress handoff, so the guard was not installed. ' +
|
||||
'Connections that bypass the proxy will exit from this worker.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (!handoff) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedLibrary =
|
||||
libraryPath === undefined ? await resolveEgressGuardLibraryAsync() : libraryPath;
|
||||
if (!resolvedLibrary) {
|
||||
throw new SystemError(
|
||||
'The local egress guard library is not available on this device host, so this local egress session ' +
|
||||
'cannot guarantee that connections bypassing the system proxy are refused. The device host image is ' +
|
||||
'missing bin/egress-guard.dylib; this is a service problem, please contact support.'
|
||||
);
|
||||
}
|
||||
|
||||
await logAlreadyRunningProcessesAsync({ env, logger });
|
||||
|
||||
let logWritable = true;
|
||||
try {
|
||||
await fs.promises.mkdir(path.dirname(logPath), { recursive: true });
|
||||
await fs.promises.appendFile(logPath, '');
|
||||
} catch (err) {
|
||||
logWritable = false;
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Local egress guard: could not create ${logPath}, so refused connections will not be reported in this log. They are still refused.`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await IosSimulatorUtils.setLaunchdEnvironmentAsync({
|
||||
udid,
|
||||
env,
|
||||
variables: buildGuardLaunchdEnvironment({ libraryPath: resolvedLibrary, logPath, mode }),
|
||||
});
|
||||
} catch (err) {
|
||||
throw new SystemError(
|
||||
'Could not install the local egress guard in the Simulator (launchctl setenv failed), so this local ' +
|
||||
'egress session cannot guarantee that connections bypassing the system proxy are refused. Retry the ' +
|
||||
'session; if it keeps failing, please contact support.',
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
|
||||
if (logWritable && !activeRelays.has(logPath)) {
|
||||
const relay = new GuardEventRelay(logger);
|
||||
const tailer = new GuardLogTailer({
|
||||
path: logPath,
|
||||
intervalMs: tailIntervalMs,
|
||||
onLine: line => {
|
||||
const event = parseGuardLogLine(line);
|
||||
if (event) {
|
||||
relay.handle(event);
|
||||
}
|
||||
},
|
||||
});
|
||||
tailer.start();
|
||||
activeRelays.set(logPath, { tailer, relay });
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Local egress guard installed in the Simulator (mode ${mode}): connections that bypass the system proxy are ${
|
||||
mode === 'block' ? 'refused' : 'observed'
|
||||
} in the process that makes them and reported here as they happen.`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the simulator already runs when the guard is installed never get
|
||||
* it. Right after `simctl boot` that is nothing; later it is SpringBoard and
|
||||
* the early daemons, which follow the system proxy anyway. Log them so the
|
||||
* uncovered set is visible rather than assumed.
|
||||
*/
|
||||
async function logAlreadyRunningProcessesAsync({
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
let psOutput = '';
|
||||
try {
|
||||
psOutput = (await spawn('ps', ['-axo', 'pid=,ppid=,comm='], { env, stdio: 'pipe' })).stdout;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const pids = new Set(collectSimulatorProcessIds(psOutput));
|
||||
const names = new Set<string>();
|
||||
for (const line of psOutput.split('\n')) {
|
||||
const match = /^\s*(\d+)\s+\d+\s+(\S.*)$/.exec(line);
|
||||
if (match && pids.has(Number(match[1]))) {
|
||||
names.add(path.basename(match[2].trim()));
|
||||
}
|
||||
}
|
||||
if (names.size === 0) {
|
||||
logger.info(
|
||||
'Local egress guard: no simulator process was running before the guard was installed.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const listed = [...names].sort();
|
||||
const shown =
|
||||
listed.slice(0, 20).join(', ') + (listed.length > 20 ? `, and ${listed.length - 20} more` : '');
|
||||
logger.info(
|
||||
`Local egress guard: ${names.size} simulator process(es) were already running before the guard was installed and are not covered by it: ${shown}.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* launchd's trampoline exists for milliseconds between fork and exec of the
|
||||
* real service, with nothing mapped yet; it never makes a connection itself.
|
||||
*/
|
||||
const COVERAGE_IGNORED_PROCESSES = new Set(['xpcproxy_sim']);
|
||||
|
||||
export type GuardCoverage = {
|
||||
/** Simulator processes with the guard library mapped. */
|
||||
covered: string[];
|
||||
/** Simulator processes without it: started before the guard was installed. */
|
||||
uncovered: string[];
|
||||
/** Pids behind `uncovered`, for comparing samples. */
|
||||
uncoveredPids: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Which simulator processes have the guard library mapped, from
|
||||
* `ps -axo pid=,ppid=,comm=` and `lsof -nP -a -p <pids> -d txt -F pn` output.
|
||||
* A process is covered when any of its mapped images is the guard library.
|
||||
*/
|
||||
export function parseGuardCoverage(psOutput: string, lsofOutput: string): GuardCoverage {
|
||||
const simulatorPids = new Set(collectSimulatorProcessIds(psOutput));
|
||||
const commandsByPid = new Map<number, string>();
|
||||
for (const line of psOutput.split('\n')) {
|
||||
const match = /^\s*(\d+)\s+\d+\s+(\S.*)$/.exec(line);
|
||||
if (match) {
|
||||
commandsByPid.set(Number(match[1]), path.basename(match[2].trim()));
|
||||
}
|
||||
}
|
||||
const loaded = new Set<number>();
|
||||
let pid: number | null = null;
|
||||
for (const line of lsofOutput.split('\n')) {
|
||||
if (line[0] === 'p') {
|
||||
pid = Number(line.slice(1));
|
||||
} else if (line[0] === 'n' && pid !== null && line.endsWith(EGRESS_GUARD_LIBRARY_FILE)) {
|
||||
loaded.add(pid);
|
||||
}
|
||||
}
|
||||
const covered: string[] = [];
|
||||
const uncovered: string[] = [];
|
||||
const uncoveredPids: number[] = [];
|
||||
for (const simulatorPid of simulatorPids) {
|
||||
const name = commandsByPid.get(simulatorPid) ?? String(simulatorPid);
|
||||
if (COVERAGE_IGNORED_PROCESSES.has(name)) {
|
||||
continue;
|
||||
}
|
||||
if (loaded.has(simulatorPid)) {
|
||||
covered.push(name);
|
||||
} else {
|
||||
uncovered.push(name);
|
||||
uncoveredPids.push(simulatorPid);
|
||||
}
|
||||
}
|
||||
covered.sort();
|
||||
uncovered.sort();
|
||||
return { covered, uncovered, uncoveredPids };
|
||||
}
|
||||
|
||||
/**
|
||||
* A process caught between fork and exec has nothing mapped yet and looks
|
||||
* uncovered for an instant. Two samples a moment apart separate those from
|
||||
* processes that really run without the guard: only pids uncovered in both
|
||||
* count, reported with the later sample's names.
|
||||
*/
|
||||
export function mergeGuardCoverageSamples(
|
||||
first: GuardCoverage,
|
||||
second: GuardCoverage
|
||||
): GuardCoverage {
|
||||
const persistent = new Set(first.uncoveredPids.filter(pid => second.uncoveredPids.includes(pid)));
|
||||
const uncovered: string[] = [];
|
||||
const uncoveredPids: number[] = [];
|
||||
second.uncoveredPids.forEach((pid, index) => {
|
||||
if (persistent.has(pid)) {
|
||||
uncovered.push(second.uncovered[index]);
|
||||
uncoveredPids.push(pid);
|
||||
}
|
||||
});
|
||||
return { covered: second.covered, uncovered, uncoveredPids };
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure and log guard coverage across the simulator's processes. Observation
|
||||
* only: the uncovered set is whatever started before the guard was installed,
|
||||
* which is nothing when installation runs right after `simctl boot`.
|
||||
*/
|
||||
export async function reportLocalEgressGuardCoverageAsync({
|
||||
env,
|
||||
logger,
|
||||
sampleIntervalMs = 1_000,
|
||||
}: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
sampleIntervalMs?: number;
|
||||
}): Promise<GuardCoverage | null> {
|
||||
const first = await sampleGuardCoverageAsync({ env });
|
||||
if (!first) {
|
||||
return null;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, sampleIntervalMs));
|
||||
const second = await sampleGuardCoverageAsync({ env });
|
||||
const coverage = second ? mergeGuardCoverageSamples(first, second) : first;
|
||||
const total = coverage.covered.length + coverage.uncovered.length;
|
||||
if (coverage.uncovered.length === 0) {
|
||||
logger.info(
|
||||
`Local egress guard coverage: all ${total} simulator process(es) have the guard loaded.`
|
||||
);
|
||||
} else {
|
||||
const shown =
|
||||
coverage.uncovered.slice(0, 20).join(', ') +
|
||||
(coverage.uncovered.length > 20 ? `, and ${coverage.uncovered.length - 20} more` : '');
|
||||
logger.info(
|
||||
`Local egress guard coverage: ${coverage.covered.length} of ${total} simulator process(es) have the guard loaded. Not covered, started before the guard was installed: ${shown}.`
|
||||
);
|
||||
}
|
||||
return coverage;
|
||||
}
|
||||
|
||||
async function sampleGuardCoverageAsync({
|
||||
env,
|
||||
}: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<GuardCoverage | null> {
|
||||
let psOutput: string;
|
||||
try {
|
||||
psOutput = (await spawn('ps', ['-axo', 'pid=,ppid=,comm='], { env, stdio: 'pipe' })).stdout;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const pids = collectSimulatorProcessIds(psOutput);
|
||||
if (pids.length === 0) {
|
||||
return null;
|
||||
}
|
||||
let lsofOutput: string;
|
||||
try {
|
||||
lsofOutput = (
|
||||
await spawn('lsof', ['-nP', '-a', '-p', pids.join(','), '-d', 'txt', '-F', 'pn'], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
})
|
||||
).stdout;
|
||||
} catch (err) {
|
||||
// lsof exits 1 when some listed pid has already exited; stdout is still valid.
|
||||
const result = err as { status?: number | null; stdout?: string };
|
||||
if (result.status !== 1) {
|
||||
return null;
|
||||
}
|
||||
lsofOutput = result.stdout ?? '';
|
||||
}
|
||||
return parseGuardCoverage(psOutput, lsofOutput);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the packaged self-check inside the simulator: it must find the guard
|
||||
* loaded in a fresh process and see it behave as `mode` says. Throws when the
|
||||
* check binary is missing or the check fails, which fails the session.
|
||||
*/
|
||||
export async function verifyLocalEgressGuardAsync({
|
||||
udid,
|
||||
env,
|
||||
logger,
|
||||
mode = 'block',
|
||||
checkPath,
|
||||
}: {
|
||||
udid: IosSimulatorUuid;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
mode?: EgressGuardMode;
|
||||
/** Explicit check binary path, `null` for "not available"; resolved from the package when omitted. */
|
||||
checkPath?: string | null;
|
||||
}): Promise<void> {
|
||||
const resolvedCheck = checkPath === undefined ? await resolveEgressGuardCheckAsync() : checkPath;
|
||||
if (!resolvedCheck) {
|
||||
throw new SystemError(
|
||||
'The local egress guard self-check is not available on this device host, so this session cannot ' +
|
||||
'verify that the guard is in effect. The device host image is missing bin/egress-guard-check; ' +
|
||||
'this is a service problem, please contact support.'
|
||||
);
|
||||
}
|
||||
let output: string;
|
||||
try {
|
||||
const result = await spawn('xcrun', ['simctl', 'spawn', udid, resolvedCheck, '--mode', mode], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
output = result.stdout.trim();
|
||||
} catch (err) {
|
||||
const failed = err as { status?: number | null; stdout?: string; stderr?: string };
|
||||
const detail = [failed.stdout, failed.stderr].filter(Boolean).join('\n').trim();
|
||||
throw new SystemError(
|
||||
'The local egress guard is not in effect in the Simulator: the self-check run right after boot ' +
|
||||
`failed${failed.status != null ? ` (exit ${failed.status})` : ''}. Connections that bypass the ` +
|
||||
'system proxy would leave from the device host, so the session was stopped. Retry the session; if it ' +
|
||||
`keeps failing, please contact support.${detail ? ` Self-check output: ${detail}` : ''}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
logger.info(`Local egress guard verified in the Simulator: ${output || 'self-check passed'}.`);
|
||||
await reportLocalEgressGuardCoverageAsync({ env, logger });
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribute guard events to the step that runs the session from now on. The
|
||||
* relay starts under the simulator boot step's logger; once the session step
|
||||
* takes over, its lines should appear under that step in the job log, next
|
||||
* to the rest of the session's output, rather than under a step that already
|
||||
* finished.
|
||||
*/
|
||||
export function rebindLocalEgressGuardRelays(logger: bunyan): void {
|
||||
for (const { relay } of activeRelays.values()) {
|
||||
relay.setLogger(logger);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop relaying and write each relay's summary; called from the session cleanup. */
|
||||
export async function stopLocalEgressGuardRelaysAsync(logger: bunyan): Promise<void> {
|
||||
const relays = [...activeRelays.values()];
|
||||
activeRelays.clear();
|
||||
for (const { tailer, relay } of relays) {
|
||||
try {
|
||||
await tailer.stopAsync();
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Local egress guard: could not read the last events from the guard log.'
|
||||
);
|
||||
}
|
||||
relay.logSummary();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
readLocalEgressHandoffAsync,
|
||||
stopLocalEgressResourcesAsync,
|
||||
} from './localEgress';
|
||||
import { rebindLocalEgressGuardRelays } from './localEgressGuard';
|
||||
import { uploadRemoteSessionConfigAsync } from './remoteDeviceRunSession';
|
||||
|
||||
/** Release the pre-boot egress resources even if controller startup or teardown fails. */
|
||||
@@ -39,6 +40,9 @@ export async function uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
remoteConfig: { ...options.remoteConfig, ...buildEgressRemoteConfigFields(localEgress) },
|
||||
});
|
||||
if (localEgress && !signal?.aborted) {
|
||||
// Guard refusals from here on show up under this step in the job log,
|
||||
// alongside the monitor's reports, instead of under the boot step.
|
||||
rebindLocalEgressGuardRelays(options.logger);
|
||||
options.logger.info(
|
||||
'Local egress: waiting for the EAS CLI egress client to connect. Proxied HTTP(S) ' +
|
||||
'requests are unavailable until it does.'
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { BuildRuntimePlatform, BuildStepEnv, spawnAsync } from '@expo/steps';
|
||||
import {
|
||||
BuildRuntimePlatform,
|
||||
BuildStepEnv,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
spawnAsync,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import { graphql } from 'gql.tada';
|
||||
@@ -14,22 +20,16 @@ import { clearTimeout, setTimeout } from 'node:timers';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
parseLaunchArgsInput,
|
||||
parseNonEmptyStringInput,
|
||||
parseOpenUrlInput,
|
||||
} from '../functions/launchApplication';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const WEB_PREVIEW_HOST = '127.0.0.1';
|
||||
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
|
||||
const SERVE_SIM_MAX_DIMENSION = '960';
|
||||
const SERVE_SIM_MJPEG_QUALITY = '0.55';
|
||||
const SERVE_SIM_VIDEO_BITRATE = '6000000';
|
||||
const SERVE_SIM_VIDEO_FPS = '60';
|
||||
const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub';
|
||||
const EXPO_DEVICE_HUB_MAX_DIMENSION = '960';
|
||||
const EXPO_DEVICE_HUB_VIDEO_BITRATE = '6000000';
|
||||
const EXPO_DEVICE_HUB_VIDEO_FPS = '60';
|
||||
|
||||
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
|
||||
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
|
||||
@@ -531,10 +531,11 @@ export type DetachedProcessHandle = {
|
||||
/** PID of the directly spawned process, if the OS assigned one. */
|
||||
pid: number | undefined;
|
||||
getOutput: () => string;
|
||||
getExitError: () => Error | undefined;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
export function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
@@ -543,13 +544,16 @@ function isProcessRunning(pid: number): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
async function stopDetachedProcessAsync(pid: number | undefined): Promise<void> {
|
||||
async function stopDetachedProcessAsync(
|
||||
pid: number | undefined,
|
||||
gracePeriodMs = 5_000
|
||||
): Promise<void> {
|
||||
if (pid === undefined || !isProcessRunning(pid)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// spawnDetached creates a dedicated process group. Signaling the group also
|
||||
// terminates npx/bunx descendants instead of leaving the actual daemon alive.
|
||||
// terminates npx/bun descendants instead of leaving the actual daemon alive.
|
||||
process.kill(-pid, 'SIGTERM');
|
||||
} catch {
|
||||
try {
|
||||
@@ -559,7 +563,7 @@ async function stopDetachedProcessAsync(pid: number | undefined): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
const deadline = Date.now() + 5_000;
|
||||
const deadline = Date.now() + gracePeriodMs;
|
||||
while (Date.now() < deadline && isProcessRunning(pid)) {
|
||||
await sleepAsync(100);
|
||||
}
|
||||
@@ -573,6 +577,43 @@ async function stopDetachedProcessAsync(pid: number | undefined): Promise<void>
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {}
|
||||
}
|
||||
// kill(pid, 0) succeeds on the zombie until libuv reaps it on a later loop turn.
|
||||
const killDeadline = Date.now() + 5_000;
|
||||
while (Date.now() < killDeadline && isProcessRunning(pid)) {
|
||||
await sleepAsync(100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs every named teardown to completion and logs each failure. The first failure is rethrown
|
||||
* only when the session body succeeded, so a teardown error cannot replace the error that ended it.
|
||||
*/
|
||||
export async function finishRemoteSessionAsync({
|
||||
teardown,
|
||||
sessionFailed,
|
||||
logger,
|
||||
}: {
|
||||
teardown: [name: string, task: Promise<unknown> | undefined][];
|
||||
sessionFailed: boolean;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const results = await Promise.allSettled(teardown.map(([, task]) => task));
|
||||
const failures = results.flatMap((result, index) =>
|
||||
result.status === 'rejected' ? [{ name: teardown[index][0], err: result.reason }] : []
|
||||
);
|
||||
for (const { name, err } of failures) {
|
||||
logger.warn({ err }, `Could not stop the ${name} during remote session teardown.`);
|
||||
if (sessionFailed) {
|
||||
// The session error is what the step reports, so a swallowed teardown failure goes to Sentry.
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture(`Could not stop the ${name} after the remote session failed`, error, {
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!sessionFailed && failures.length > 0) {
|
||||
throw failures[0].err;
|
||||
}
|
||||
}
|
||||
|
||||
export function spawnDetached({
|
||||
@@ -580,11 +621,13 @@ export function spawnDetached({
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
stopGracePeriodMs,
|
||||
}: {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
env: BuildStepEnv;
|
||||
stopGracePeriodMs?: number;
|
||||
}): DetachedProcessHandle {
|
||||
const promise = spawn(command, args, {
|
||||
cwd,
|
||||
@@ -592,9 +635,24 @@ export function spawnDetached({
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
});
|
||||
// We don't await the process — it should outlive this step. Failures show
|
||||
// up in the captured output; suppress unhandled rejections here.
|
||||
promise.catch(() => {});
|
||||
// Observe completion without rejecting in the background. Startup callers can
|
||||
// distinguish a dead process from one that is still preparing its state file.
|
||||
let exitError: Error | undefined;
|
||||
// The spawn promise waits for stdio to close. Descendants may keep those
|
||||
// pipes open after the launcher exits, so observe the exit itself as well.
|
||||
promise.child.once('exit', (code, signal) => {
|
||||
exitError = new Error(
|
||||
signal ? `Process exited with signal ${signal}.` : `Process exited with code ${code}.`
|
||||
);
|
||||
});
|
||||
void promise.then(
|
||||
() => {
|
||||
exitError ??= new Error('Process exited with code 0.');
|
||||
},
|
||||
error => {
|
||||
exitError ??= error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
);
|
||||
promise.child.unref();
|
||||
|
||||
let output = '';
|
||||
@@ -608,103 +666,83 @@ export function spawnDetached({
|
||||
return {
|
||||
pid,
|
||||
getOutput: () => output,
|
||||
stopAsync: async () => await stopDetachedProcessAsync(pid),
|
||||
getExitError: () => exitError,
|
||||
stopAsync: async () => await stopDetachedProcessAsync(pid, stopGracePeriodMs),
|
||||
};
|
||||
}
|
||||
|
||||
export function metricsCorsOriginToServeSimArgs(env: BuildStepEnv): string[] {
|
||||
const origin = env.EAS_SIMULATOR_METRICS_CORS_ORIGIN;
|
||||
if (!origin) {
|
||||
return [];
|
||||
}
|
||||
const args: string[] = [];
|
||||
for (const value of origin.split(',')) {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
args.push('--metrics-cors-origin', trimmed);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
export interface ServeSimLaunchOptions {
|
||||
launchAppIdentifier?: string;
|
||||
launchArgs?: string[];
|
||||
openUrl?: string;
|
||||
}
|
||||
|
||||
function createServeSimPackageSpec(packageVersion: string | undefined): string {
|
||||
return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
|
||||
}
|
||||
|
||||
function createExpoDeviceHubPackageSpec(packageVersion: string | undefined): string {
|
||||
return `${EXPO_DEVICE_HUB_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
|
||||
}
|
||||
|
||||
export function createServeSimArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
metricsCorsArgs = [],
|
||||
packageVersion,
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
metricsCorsArgs?: string[];
|
||||
packageVersion?: string;
|
||||
}): string[] {
|
||||
/**
|
||||
* serve-sim performs the launch so the application starts under its instrumentation.
|
||||
*/
|
||||
export function createServeSimLaunchInputProviders(): ReturnType<
|
||||
typeof BuildStepInput.createProvider
|
||||
>[] {
|
||||
return [
|
||||
'--yes',
|
||||
createServeSimPackageSpec(packageVersion),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
WEB_PREVIEW_HOST,
|
||||
'--require-token',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'vp8',
|
||||
'--max-dimension',
|
||||
SERVE_SIM_MAX_DIMENSION,
|
||||
'--mjpeg-quality',
|
||||
SERVE_SIM_MJPEG_QUALITY,
|
||||
'--video-bitrate',
|
||||
SERVE_SIM_VIDEO_BITRATE,
|
||||
'--video-fps',
|
||||
SERVE_SIM_VIDEO_FPS,
|
||||
...turnArgs,
|
||||
...metricsCorsArgs,
|
||||
BuildStepInput.createProvider({
|
||||
id: 'launch_app_identifier',
|
||||
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,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export function createExpoDeviceHubArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
packageVersion,
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
packageVersion?: string;
|
||||
}): string[] {
|
||||
return [
|
||||
'--yes',
|
||||
createExpoDeviceHubPackageSpec(packageVersion),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
WEB_PREVIEW_HOST,
|
||||
'--platform',
|
||||
'android',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'h264',
|
||||
'--webrtc-ice-policy',
|
||||
'all',
|
||||
'--max-dimension',
|
||||
EXPO_DEVICE_HUB_MAX_DIMENSION,
|
||||
'--video-bitrate',
|
||||
EXPO_DEVICE_HUB_VIDEO_BITRATE,
|
||||
'--video-fps',
|
||||
EXPO_DEVICE_HUB_VIDEO_FPS,
|
||||
'--hide-sidebar',
|
||||
'--hide-boot-device',
|
||||
...turnArgs,
|
||||
];
|
||||
export function parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: rawLaunchAppIdentifier,
|
||||
launchArgs: rawLaunchArgs,
|
||||
openUrl: rawOpenUrl,
|
||||
}: { launchAppIdentifier?: unknown; launchArgs?: unknown; openUrl?: unknown },
|
||||
{ runtimePlatform }: { runtimePlatform: BuildRuntimePlatform }
|
||||
): ServeSimLaunchOptions {
|
||||
const launchAppIdentifier =
|
||||
rawLaunchAppIdentifier === undefined
|
||||
? undefined
|
||||
: parseNonEmptyStringInput(rawLaunchAppIdentifier, 'launch_app_identifier');
|
||||
const launchArgs = parseLaunchArgsInput(rawLaunchArgs);
|
||||
const openUrl = rawOpenUrl === undefined ? undefined : parseOpenUrlInput(rawOpenUrl);
|
||||
if (!launchAppIdentifier && (launchArgs.length > 0 || openUrl)) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
'Inputs "launch_args" and "open_url" only work with an application launch. Pass "launch_app_identifier", or remove them.'
|
||||
);
|
||||
}
|
||||
if (launchAppIdentifier && runtimePlatform !== BuildRuntimePlatform.DARWIN) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
`Input "launch_app_identifier" launches an application on an iOS simulator, and this session runs on ${runtimePlatform}. Run the session on an iOS simulator, or drop the launch inputs.`
|
||||
);
|
||||
}
|
||||
return { launchAppIdentifier, launchArgs, openUrl };
|
||||
}
|
||||
|
||||
export function describeServeSimLaunch({
|
||||
launchAppIdentifier,
|
||||
launchArgs = [],
|
||||
openUrl,
|
||||
}: ServeSimLaunchOptions): string | null {
|
||||
if (!launchAppIdentifier) {
|
||||
return null;
|
||||
}
|
||||
const withArguments =
|
||||
launchArgs.length > 0 ? ` with arguments ${JSON.stringify(launchArgs)}` : '';
|
||||
const thenOpen = openUrl ? `, then open ${openUrl}` : '';
|
||||
return `serve-sim will launch ${launchAppIdentifier}${withArguments}${thenOpen}.`;
|
||||
}
|
||||
|
||||
export async function findAvailablePortAsync(): Promise<number> {
|
||||
@@ -712,7 +750,7 @@ export async function findAvailablePortAsync(): Promise<number> {
|
||||
server.unref();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, WEB_PREVIEW_HOST, () => resolve());
|
||||
server.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
const address = server.address();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -724,239 +762,16 @@ export async function findAvailablePortAsync(): Promise<number> {
|
||||
return address.port;
|
||||
}
|
||||
|
||||
const WebPreviewReadyResponseSchema = z.object({
|
||||
status: z.literal('ready'),
|
||||
device: z.string(),
|
||||
});
|
||||
|
||||
export async function waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
}: {
|
||||
previewServer: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
|
||||
serverName: string;
|
||||
port: number;
|
||||
timeoutMs: number;
|
||||
}): Promise<string> {
|
||||
const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
if (previewServer.pid !== undefined && !isProcessRunning(previewServer.pid)) {
|
||||
throw new SystemError(
|
||||
`${serverName} exited before becoming ready. Last output:\n${
|
||||
previewServer.getOutput() || '<empty>'
|
||||
}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
const response = await turtleFetch(readyUrl, 'GET', {
|
||||
retries: 0,
|
||||
timeout: 2_000,
|
||||
});
|
||||
const ready = WebPreviewReadyResponseSchema.parse(await response.json());
|
||||
return ready.device;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
throw new SystemError(
|
||||
`Timed out waiting for ${serverName} readiness at ${readyUrl}${
|
||||
lastError instanceof Error ? `: ${lastError.message}` : ''
|
||||
}. Last output:\n${previewServer.getOutput() || '<empty>'}`
|
||||
);
|
||||
}
|
||||
|
||||
export type DeviceWebPreviewHandle = {
|
||||
previewUrl: string;
|
||||
/** Session token gating the preview. Only serve-sim mints one. */
|
||||
previewToken?: string;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type ServeSimPreviewHandle = DeviceWebPreviewHandle;
|
||||
|
||||
async function startWebPreviewWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
serverName,
|
||||
packageSpec,
|
||||
createArgs,
|
||||
readPreviewTokenAsync,
|
||||
}: {
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
serverName: string;
|
||||
packageSpec: string;
|
||||
createArgs: (port: number, turnArgs: string[]) => string[];
|
||||
readPreviewTokenAsync?: (device: string) => Promise<string>;
|
||||
}
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
const port = await findAvailablePortAsync();
|
||||
logger.info(`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port}.`);
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
|
||||
const previewServer = spawnDetached({
|
||||
command: 'npx',
|
||||
args: createArgs(port, turnArgs),
|
||||
env,
|
||||
});
|
||||
|
||||
try {
|
||||
logger.info(`Waiting for ${serverName} to become ready.`);
|
||||
const device = await waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
});
|
||||
const previewToken = await readPreviewTokenAsync?.(device);
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix: 'web-preview',
|
||||
baseDomain,
|
||||
authtoken: getNgrokAuthtokenOrThrow(env),
|
||||
logger,
|
||||
});
|
||||
return {
|
||||
previewUrl: tunnel.url,
|
||||
previewToken,
|
||||
stopAsync: async () => {
|
||||
const results = await Promise.allSettled([tunnel.stopAsync(), previewServer.stopAsync()]);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
logger.warn({ err: result.reason }, `Could not stop a ${serverName} preview resource.`);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await previewServer.stopAsync();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readServeSimPreviewTokenAsync(
|
||||
udid: string,
|
||||
stateDir: string = SERVE_SIM_STATE_DIR
|
||||
): Promise<string | undefined> {
|
||||
const servers = await readServeSimServersAsync(stateDir);
|
||||
return servers.find(server => server.udid === udid)?.token;
|
||||
}
|
||||
|
||||
export async function startServeSimWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
packageVersion,
|
||||
}: {
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
}
|
||||
): Promise<ServeSimPreviewHandle> {
|
||||
const metricsCorsArgs = metricsCorsOriginToServeSimArgs(env);
|
||||
return await startWebPreviewWithTunnelAsync(ctx, {
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
serverName: 'serve-sim',
|
||||
packageSpec: createServeSimPackageSpec(packageVersion),
|
||||
createArgs: (port, turnArgs) =>
|
||||
createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }),
|
||||
readPreviewTokenAsync: async device => {
|
||||
const previewToken = await readServeSimPreviewTokenAsync(device);
|
||||
if (!previewToken) {
|
||||
// A serve-sim that does not know --require-token fails earlier, in the readiness check, so
|
||||
// reaching here means it started and left no token in its state file.
|
||||
throw new SystemError(
|
||||
`serve-sim became ready but wrote no session token for device ${device}. The preview is ` +
|
||||
'on a public tunnel and would be reachable without one, so the session cannot continue. ' +
|
||||
'This usually means the state file was not written as expected; retry the session, and ' +
|
||||
'report it if it repeats.'
|
||||
);
|
||||
}
|
||||
return previewToken;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function startExpoDeviceHubWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
runtimePlatform,
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
packageVersion,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
}
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
if (runtimePlatform === BuildRuntimePlatform.LINUX) {
|
||||
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
}
|
||||
return await startWebPreviewWithTunnelAsync(ctx, {
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
serverName: 'expo-device-hub',
|
||||
packageSpec: createExpoDeviceHubPackageSpec(packageVersion),
|
||||
createArgs: (port, turnArgs) => createExpoDeviceHubArgs({ port, turnArgs, packageVersion }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function startDeviceWebPreviewWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
runtimePlatform,
|
||||
...options
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
}
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
switch (runtimePlatform) {
|
||||
case BuildRuntimePlatform.DARWIN:
|
||||
return await startServeSimWithTunnelAsync(ctx, options);
|
||||
case BuildRuntimePlatform.LINUX:
|
||||
return await startExpoDeviceHubWithTunnelAsync(ctx, { ...options, runtimePlatform });
|
||||
}
|
||||
}
|
||||
|
||||
export type NgrokTunnelHandle = {
|
||||
url: string;
|
||||
subdomainId: string;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
export async function startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix,
|
||||
subdomainId: subdomainIdArg,
|
||||
baseDomain,
|
||||
authtoken,
|
||||
rewriteHostHeader,
|
||||
@@ -964,12 +779,14 @@ export async function startNgrokTunnelAsync({
|
||||
}: {
|
||||
port: number;
|
||||
subdomainPrefix: string;
|
||||
subdomainId?: string;
|
||||
baseDomain: string;
|
||||
authtoken: string;
|
||||
rewriteHostHeader?: boolean;
|
||||
logger: bunyan;
|
||||
}): Promise<NgrokTunnelHandle> {
|
||||
const domain = `${subdomainPrefix}-${randomBytes(16).toString('hex')}.${baseDomain}`;
|
||||
const subdomainId = subdomainIdArg ?? randomBytes(16).toString('hex');
|
||||
const domain = `${subdomainPrefix}-${subdomainId}.${baseDomain}`;
|
||||
logger.info(`Starting ngrok tunnel ${domain} -> http://localhost:${port}.`);
|
||||
// Run the ngrok agent in-process via the SDK; it keeps the session alive until
|
||||
// the process exits, and the step blocks forever to hold it open.
|
||||
@@ -987,6 +804,7 @@ export async function startNgrokTunnelAsync({
|
||||
let stopped = false;
|
||||
return {
|
||||
url,
|
||||
subdomainId,
|
||||
stopAsync: async () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
type SandboxDaemonCommandParams,
|
||||
type SandboxDaemonCommandResult,
|
||||
type SandboxDaemonMethod,
|
||||
} from '@expo/eas-build-job';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { ShellSessionManager } from './shellSessionManager';
|
||||
|
||||
const DEFAULT_EXEC_YIELD_TIME_MS = 10_000;
|
||||
const DEFAULT_WRITE_YIELD_TIME_MS = 250;
|
||||
|
||||
export type SandboxDaemonCommandImplementations = {
|
||||
[Method in SandboxDaemonMethod]: (
|
||||
params: SandboxDaemonCommandParams<Method>
|
||||
) => Promise<SandboxDaemonCommandResult<Method>>;
|
||||
};
|
||||
|
||||
export function createSandboxCommandImplementations({
|
||||
workingDirectory,
|
||||
env,
|
||||
signal,
|
||||
}: {
|
||||
workingDirectory: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
signal: AbortSignal;
|
||||
}): {
|
||||
commandImplementations: SandboxDaemonCommandImplementations;
|
||||
stoppedPromise: Promise<void>;
|
||||
} {
|
||||
const sessions = new ShellSessionManager({ workingDirectory, env, signal });
|
||||
return {
|
||||
commandImplementations: {
|
||||
async execCommand(params) {
|
||||
const callStartedAt = performance.now();
|
||||
const sessionId = await sessions.startAsync({
|
||||
cmd: params.cmd,
|
||||
workdir: params.workdir,
|
||||
tty: params.tty,
|
||||
});
|
||||
const result = await sessions.readAsync(
|
||||
sessionId,
|
||||
params.yieldTimeMs ?? DEFAULT_EXEC_YIELD_TIME_MS
|
||||
);
|
||||
return { ...result, wallTimeSeconds: (performance.now() - callStartedAt) / 1_000 };
|
||||
},
|
||||
async writeStdin(params) {
|
||||
const callStartedAt = performance.now();
|
||||
if (params.chars !== undefined) {
|
||||
sessions.write(params.sessionId, params.chars);
|
||||
}
|
||||
const result = await sessions.readAsync(
|
||||
params.sessionId,
|
||||
params.yieldTimeMs ?? DEFAULT_WRITE_YIELD_TIME_MS
|
||||
);
|
||||
return { ...result, wallTimeSeconds: (performance.now() - callStartedAt) / 1_000 };
|
||||
},
|
||||
},
|
||||
stoppedPromise: sessions.stoppedPromise,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import {
|
||||
type SandboxDaemonCommandResult,
|
||||
SandboxDaemonCommands,
|
||||
type SandboxDaemonMethod,
|
||||
SandboxDaemonRequestZ,
|
||||
type SandboxDaemonResponse,
|
||||
SystemError,
|
||||
} from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
import {
|
||||
type SandboxDaemonCommandImplementations,
|
||||
createSandboxCommandImplementations,
|
||||
} from './sandboxCommandImplementations';
|
||||
|
||||
export interface SandboxDaemonOptions {
|
||||
credential: string;
|
||||
serverUrl: string;
|
||||
reconnectDelayMs: number;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
workingDirectory: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export interface SandboxDaemon {
|
||||
ready: Promise<void>;
|
||||
stopAsync(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function startSandboxDaemonAsync(
|
||||
options: SandboxDaemonOptions
|
||||
): Promise<SandboxDaemon> {
|
||||
options.signal?.throwIfAborted();
|
||||
let socket: WebSocket | undefined;
|
||||
const abortController = new AbortController();
|
||||
let hasConnected = false;
|
||||
let resolveConnected!: () => void;
|
||||
let rejectConnected!: (error: Error) => void;
|
||||
const { commandImplementations, stoppedPromise: commandsStoppedPromise } =
|
||||
createSandboxCommandImplementations({
|
||||
workingDirectory: options.workingDirectory,
|
||||
env: options.env,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
const connected = new Promise<void>((resolve, reject) => {
|
||||
resolveConnected = resolve;
|
||||
rejectConnected = reject;
|
||||
});
|
||||
const stop = (): void => {
|
||||
abortController.abort();
|
||||
socket?.close(1000, 'sandbox stopped');
|
||||
};
|
||||
options.signal?.addEventListener('abort', stop, { once: true });
|
||||
|
||||
const connectionLoop = (async () => {
|
||||
while (!abortController.signal.aborted) {
|
||||
try {
|
||||
const connectedSocket = new WebSocket(new URL('/sandbox/connect', options.serverUrl), {
|
||||
handshakeTimeout: 10_000,
|
||||
headers: { Authorization: `Bearer ${options.credential}` },
|
||||
});
|
||||
socket = connectedSocket;
|
||||
await waitForOpen(connectedSocket);
|
||||
options.logger.info('Sandbox MCP server connected.');
|
||||
connectedSocket.on('message', async message => {
|
||||
try {
|
||||
const response = await handleMessageAsync(commandImplementations, message.toString());
|
||||
// MCP fails pending calls on disconnect. Responses belong to this socket only.
|
||||
if (connectedSocket.readyState !== WebSocket.OPEN) {
|
||||
options.logger.warn(
|
||||
'Sandbox command response was lost after disconnect. The command may have run and output may have been consumed.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
connectedSocket.send(JSON.stringify(response), error => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
options.logger.warn({ err: error }, 'Could not send sandbox command response.');
|
||||
}
|
||||
});
|
||||
hasConnected = true;
|
||||
resolveConnected();
|
||||
await waitForClose(connectedSocket);
|
||||
} catch (error: any) {
|
||||
if (!hasConnected) {
|
||||
const message = `Sandbox MCP server connection failed: ${error?.message ?? 'unknown error'}`;
|
||||
if (!abortController.signal.aborted) {
|
||||
options.logger.error({ err: error }, message);
|
||||
}
|
||||
rejectConnected(new SystemError(message, { cause: error }));
|
||||
return;
|
||||
}
|
||||
if (!abortController.signal.aborted) {
|
||||
options.logger.warn(
|
||||
{ err: error },
|
||||
`Sandbox MCP server connection failed: ${error?.message ?? 'unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!abortController.signal.aborted) {
|
||||
try {
|
||||
await setTimeoutAsync(options.reconnectDelayMs, undefined, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!abortController.signal.aborted) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
ready: connected,
|
||||
async stopAsync(): Promise<void> {
|
||||
options.signal?.removeEventListener('abort', stop);
|
||||
stop();
|
||||
await commandsStoppedPromise;
|
||||
await connectionLoop;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function waitForOpen(socket: WebSocket): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.once('open', resolve);
|
||||
socket.once('error', reject);
|
||||
socket.once('close', () => reject(new Error('Sandbox MCP server closed before it connected.')));
|
||||
});
|
||||
}
|
||||
|
||||
function waitForClose(socket: WebSocket): Promise<void> {
|
||||
if (socket.readyState === WebSocket.CLOSING || socket.readyState === WebSocket.CLOSED) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.once('close', resolve);
|
||||
socket.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMessageAsync(
|
||||
commandImplementations: SandboxDaemonCommandImplementations,
|
||||
message: string
|
||||
): Promise<SandboxDaemonResponse> {
|
||||
let rawRequest: unknown;
|
||||
try {
|
||||
rawRequest = JSON.parse(message);
|
||||
} catch {
|
||||
return { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } };
|
||||
}
|
||||
|
||||
const request = SandboxDaemonRequestZ.safeParse(rawRequest);
|
||||
if (!request.success) {
|
||||
return { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid request' } };
|
||||
}
|
||||
|
||||
const { id, method, params } = request.data;
|
||||
if (!Object.hasOwn(SandboxDaemonCommands, method)) {
|
||||
return { jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } };
|
||||
}
|
||||
const commandMethod = method as SandboxDaemonMethod;
|
||||
|
||||
const parsedParams = SandboxDaemonCommands[commandMethod].params.safeParse(params);
|
||||
if (!parsedParams.success) {
|
||||
return { jsonrpc: '2.0', id, error: { code: -32602, message: 'Invalid params' } };
|
||||
}
|
||||
try {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: await (
|
||||
commandImplementations[commandMethod] as (
|
||||
params: typeof parsedParams.data
|
||||
) => Promise<SandboxDaemonCommandResult<typeof commandMethod>>
|
||||
)(parsedParams.data),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: { code: -32603, message: error instanceof Error ? error.message : 'Internal error' },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import * as pty from 'node-pty';
|
||||
import { type ChildProcess, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import { constants as osConstants } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { killProcessGroup } from '../../utils/processes';
|
||||
|
||||
const PROCESS_STOP_GRACE_PERIOD_MS = 1_000;
|
||||
const SIGNAL_NAMES = new Map(
|
||||
Object.entries(osConstants.signals)
|
||||
.reverse()
|
||||
.map(([name, number]) => [number, name])
|
||||
);
|
||||
|
||||
export class ShellSessionManager {
|
||||
// Keep completed sessions so callers can still read their output and exit status.
|
||||
private readonly sessions = new Map<number, CommandSession>();
|
||||
private nextSessionId = 1;
|
||||
public readonly stoppedPromise: Promise<void>;
|
||||
|
||||
public constructor(
|
||||
private readonly options: {
|
||||
workingDirectory: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
) {
|
||||
this.stoppedPromise = (async () => {
|
||||
if (!options.signal.aborted) {
|
||||
await new Promise<void>(resolve => {
|
||||
options.signal.addEventListener('abort', () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
await this.stopAsync();
|
||||
})();
|
||||
// Cleanup can fail before the owner awaits it.
|
||||
this.stoppedPromise.catch(() => {});
|
||||
}
|
||||
|
||||
public async startAsync({
|
||||
cmd,
|
||||
workdir,
|
||||
tty,
|
||||
}: {
|
||||
cmd: string;
|
||||
workdir?: string;
|
||||
tty?: boolean;
|
||||
}): Promise<number> {
|
||||
this.throwIfStopped();
|
||||
const workingDirectory = workdir
|
||||
? path.resolve(this.options.workingDirectory, workdir)
|
||||
: this.options.workingDirectory;
|
||||
await validateWorkingDirectoryAsync(workingDirectory);
|
||||
this.throwIfStopped();
|
||||
const sessionId = this.nextSessionId++;
|
||||
const session = tty
|
||||
? startPtyCommand({ command: cmd, workingDirectory, env: this.options.env })
|
||||
: startPipeCommand({ command: cmd, workingDirectory, env: this.options.env });
|
||||
this.sessions.set(sessionId, session);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
public write(sessionId: number, chars: string): void {
|
||||
this.throwIfStopped();
|
||||
const session = this.getSession(sessionId);
|
||||
if (!session.isCompleted && !session.error) {
|
||||
session.write(chars);
|
||||
}
|
||||
}
|
||||
|
||||
public async readAsync(
|
||||
sessionId: number,
|
||||
yieldTimeMs: number
|
||||
): Promise<
|
||||
{ output: string } & (
|
||||
| { exitCode: number }
|
||||
| { terminationSignal: string }
|
||||
| { sessionId: number }
|
||||
)
|
||||
> {
|
||||
this.throwIfStopped();
|
||||
const session = this.getSession(sessionId);
|
||||
await waitForSessionsAsync([session], yieldTimeMs);
|
||||
if (session.error) {
|
||||
throw session.error;
|
||||
}
|
||||
|
||||
const output = session.output;
|
||||
session.output = '';
|
||||
const result = {
|
||||
output,
|
||||
};
|
||||
if (session.terminationSignal !== undefined) {
|
||||
return { ...result, terminationSignal: session.terminationSignal };
|
||||
}
|
||||
if (session.exitCode !== undefined) {
|
||||
return { ...result, exitCode: session.exitCode };
|
||||
}
|
||||
return { ...result, sessionId };
|
||||
}
|
||||
|
||||
private async stopAsync(): Promise<void> {
|
||||
const sessions = [...this.sessions.values()];
|
||||
// Only signal groups while their leaders are alive. After a leader exits, its PID may
|
||||
// be reused. Any surviving descendants are left to VM teardown.
|
||||
for (const session of sessions) {
|
||||
if (!session.hasLeaderExited) {
|
||||
killProcessGroup(session.process, 'SIGTERM');
|
||||
}
|
||||
}
|
||||
await waitForSessionsAsync(sessions, PROCESS_STOP_GRACE_PERIOD_MS);
|
||||
for (const session of sessions) {
|
||||
if (!session.hasLeaderExited) {
|
||||
killProcessGroup(session.process, 'SIGKILL');
|
||||
}
|
||||
}
|
||||
await waitForSessionsAsync(sessions, PROCESS_STOP_GRACE_PERIOD_MS);
|
||||
this.sessions.clear();
|
||||
}
|
||||
|
||||
private throwIfStopped(): void {
|
||||
this.options.signal.throwIfAborted();
|
||||
}
|
||||
|
||||
private getSession(sessionId: number): CommandSession {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Command session ${sessionId} does not exist.`);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
type CommandSession = {
|
||||
process: ChildProcess | pty.IPty;
|
||||
completed: Promise<void>;
|
||||
resolveCompleted: () => void;
|
||||
isCompleted: boolean;
|
||||
hasLeaderExited: boolean;
|
||||
// Unread output is intentionally unlimited for now and can exhaust memory. Reading clears it.
|
||||
output: string;
|
||||
exitCode?: number;
|
||||
terminationSignal?: string;
|
||||
error?: Error;
|
||||
write(chars: string): void;
|
||||
};
|
||||
|
||||
function startPipeCommand({
|
||||
command,
|
||||
workingDirectory,
|
||||
env,
|
||||
}: {
|
||||
command: string;
|
||||
workingDirectory: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): CommandSession {
|
||||
const child = spawn(command, {
|
||||
cwd: workingDirectory,
|
||||
env,
|
||||
shell: env.SHELL ?? true,
|
||||
detached: true,
|
||||
});
|
||||
const session = createSession(child);
|
||||
session.write = (chars: string): void => {
|
||||
child.stdin.write(chars);
|
||||
};
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', data => {
|
||||
session.output += data;
|
||||
});
|
||||
child.stderr.on('data', data => {
|
||||
session.output += data;
|
||||
});
|
||||
child.stdin.on('error', () => {});
|
||||
child.once('exit', () => {
|
||||
session.hasLeaderExited = true;
|
||||
});
|
||||
child.once('error', error => {
|
||||
if (!session.isCompleted) {
|
||||
session.hasLeaderExited = true;
|
||||
session.isCompleted = true;
|
||||
session.error = error;
|
||||
session.resolveCompleted();
|
||||
}
|
||||
});
|
||||
child.once('close', (exitCode, signal) => {
|
||||
if (!session.isCompleted) {
|
||||
session.isCompleted = true;
|
||||
if (signal) {
|
||||
session.terminationSignal = signal;
|
||||
} else {
|
||||
session.exitCode = exitCode ?? 1;
|
||||
}
|
||||
session.resolveCompleted();
|
||||
}
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
function startPtyCommand({
|
||||
command,
|
||||
workingDirectory,
|
||||
env,
|
||||
}: {
|
||||
command: string;
|
||||
workingDirectory: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): CommandSession {
|
||||
const shell = env.SHELL ?? '/bin/sh';
|
||||
const terminal = pty.spawn(shell, ['-c', command], {
|
||||
cwd: workingDirectory,
|
||||
env,
|
||||
name: 'xterm-256color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
const session = createSession(terminal);
|
||||
session.write = (chars: string): void => {
|
||||
terminal.write(chars);
|
||||
};
|
||||
terminal.onData(data => {
|
||||
session.output += data;
|
||||
});
|
||||
terminal.onExit(({ exitCode, signal }) => {
|
||||
session.hasLeaderExited = true;
|
||||
session.isCompleted = true;
|
||||
if (signal) {
|
||||
session.terminationSignal = SIGNAL_NAMES.get(signal) ?? `SIGNAL_${signal}`;
|
||||
} else {
|
||||
session.exitCode = exitCode;
|
||||
}
|
||||
session.resolveCompleted();
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
function createSession(process: CommandSession['process']): CommandSession {
|
||||
let resolveCompleted!: () => void;
|
||||
const completed = new Promise<void>(resolve => {
|
||||
resolveCompleted = resolve;
|
||||
});
|
||||
return {
|
||||
process,
|
||||
completed,
|
||||
resolveCompleted,
|
||||
isCompleted: false,
|
||||
hasLeaderExited: false,
|
||||
output: '',
|
||||
write: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
async function validateWorkingDirectoryAsync(workingDirectory: string): Promise<void> {
|
||||
let stats;
|
||||
try {
|
||||
stats = await fs.stat(workingDirectory);
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
throw new Error(`Working directory does not exist: ${workingDirectory}`);
|
||||
}
|
||||
if (error?.code === 'EACCES') {
|
||||
throw new Error(`Working directory is not accessible: ${workingDirectory}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Working directory is not a directory: ${workingDirectory}`);
|
||||
}
|
||||
try {
|
||||
await fs.access(workingDirectory, fs.constants.R_OK | fs.constants.X_OK);
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'EACCES') {
|
||||
throw new Error(`Working directory is not accessible: ${workingDirectory}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForSessionsAsync(sessions: CommandSession[], timeoutMs: number): Promise<void> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
Promise.all(sessions.map(session => session.completed)),
|
||||
new Promise<void>(resolve => {
|
||||
timer = setTimeout(resolve, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,67 @@ export namespace IosSimulatorUtils {
|
||||
}
|
||||
}
|
||||
|
||||
const UDID_PATTERN = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i;
|
||||
|
||||
/**
|
||||
* The UDID for a device name or UDID. A name picks the first available
|
||||
* device with that name, as `simctl` itself does.
|
||||
*/
|
||||
export async function resolveUdidAsync({
|
||||
deviceIdentifier,
|
||||
env,
|
||||
}: {
|
||||
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<IosSimulatorUuid> {
|
||||
if (UDID_PATTERN.test(deviceIdentifier)) {
|
||||
return deviceIdentifier as IosSimulatorUuid;
|
||||
}
|
||||
const devices = await getAvailableDevicesAsync({ env, filter: 'available' });
|
||||
const device = devices.find(candidate => candidate.name === deviceIdentifier);
|
||||
if (!device) {
|
||||
throw new UserError(
|
||||
'EAS_IOS_SIMULATOR_NOT_FOUND',
|
||||
`No available iOS Simulator is named "${deviceIdentifier}". Run \`xcrun simctl list devices available\` on the device host to see the devices it offers.`
|
||||
);
|
||||
}
|
||||
return device.udid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start booting without waiting for boot to complete; follow with
|
||||
* `startAsync` to wait for it. `launchdEnvironment` is handed to the
|
||||
* simulator's launchd before it spawns anything: `simctl` forwards every
|
||||
* `SIMCTL_CHILD_`-prefixed variable of its own environment to the process
|
||||
* it starts, and for `boot` that process is launchd itself. This is the only
|
||||
* way to give the first processes of a boot an environment; `launchctl
|
||||
* setenv` after boot only reaches processes started later. A device that is
|
||||
* already booted keeps its environment.
|
||||
*/
|
||||
export async function bootAsync({
|
||||
deviceIdentifier,
|
||||
env,
|
||||
launchdEnvironment = {},
|
||||
}: {
|
||||
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
launchdEnvironment?: Record<string, string>;
|
||||
}): Promise<void> {
|
||||
const bootEnv = { ...env };
|
||||
for (const [name, value] of Object.entries(launchdEnvironment)) {
|
||||
bootEnv[`SIMCTL_CHILD_${name}`] = value;
|
||||
}
|
||||
try {
|
||||
await spawn('xcrun', ['simctl', 'boot', deviceIdentifier], { env: bootEnv, stdio: 'pipe' });
|
||||
} catch (err) {
|
||||
const failed = err as { stderr?: string };
|
||||
if (/current state: Booted/.test(failed.stderr ?? '')) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startAsync({
|
||||
deviceIdentifier,
|
||||
env,
|
||||
@@ -285,6 +346,32 @@ export namespace IosSimulatorUtils {
|
||||
throw lastError ?? new SystemError('Unable to disable apsd in the Simulator.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set environment variables in the Simulator's launchd. Every process that
|
||||
* launchd spawns afterwards inherits them: apps launched by SpringBoard
|
||||
* (deep links, taps, WebDriverAgent) as well as by `simctl launch`.
|
||||
* Processes that are already running keep their environment.
|
||||
*/
|
||||
export async function setLaunchdEnvironmentAsync({
|
||||
udid,
|
||||
env,
|
||||
variables,
|
||||
}: {
|
||||
udid: IosSimulatorUuid;
|
||||
env: NodeJS.ProcessEnv;
|
||||
variables: Record<string, string>;
|
||||
}): Promise<void> {
|
||||
// One invocation for every variable: each `simctl spawn` costs a few
|
||||
// hundred milliseconds on a device host, and this runs in the window
|
||||
// between `simctl boot` returning and launchd spawning the boot's
|
||||
// processes, which must inherit these.
|
||||
const pairs = Object.entries(variables).flat();
|
||||
if (pairs.length === 0) {
|
||||
return;
|
||||
}
|
||||
await spawn('xcrun', ['simctl', 'spawn', udid, 'launchctl', 'setenv', ...pairs], { env });
|
||||
}
|
||||
|
||||
export async function collectLogsAsync({
|
||||
deviceIdentifier,
|
||||
env,
|
||||
|
||||
@@ -340,4 +340,136 @@ describe('IosSimulatorUtils', () => {
|
||||
).rejects.toThrow(SystemError);
|
||||
});
|
||||
});
|
||||
describe(IosSimulatorUtils.setLaunchdEnvironmentAsync, () => {
|
||||
it('sets every variable in the simulator launchd with one invocation', async () => {
|
||||
await IosSimulatorUtils.setLaunchdEnvironmentAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
variables: { https_proxy: 'http://127.0.0.1:8899', no_proxy: 'localhost,127.0.0.1' },
|
||||
});
|
||||
|
||||
expect(mockedSpawn.mock.calls).toEqual([
|
||||
[
|
||||
'xcrun',
|
||||
[
|
||||
'simctl',
|
||||
'spawn',
|
||||
'test-udid',
|
||||
'launchctl',
|
||||
'setenv',
|
||||
'https_proxy',
|
||||
'http://127.0.0.1:8899',
|
||||
'no_proxy',
|
||||
'localhost,127.0.0.1',
|
||||
],
|
||||
{ env: process.env },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('does nothing for an empty variable set', async () => {
|
||||
await IosSimulatorUtils.setLaunchdEnvironmentAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
variables: {},
|
||||
});
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates a launchctl failure', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('launchctl failed'));
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.setLaunchdEnvironmentAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
variables: { https_proxy: 'http://127.0.0.1:8899' },
|
||||
})
|
||||
).rejects.toThrow('launchctl failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.resolveUdidAsync, () => {
|
||||
it('passes a udid through without listing devices', async () => {
|
||||
await expect(
|
||||
IosSimulatorUtils.resolveUdidAsync({
|
||||
deviceIdentifier: '8027F627-9534-4679-85BF-0F14AFF228E8' as any,
|
||||
env: process.env,
|
||||
})
|
||||
).resolves.toBe('8027F627-9534-4679-85BF-0F14AFF228E8');
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves a device name to the first available device with that name', async () => {
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
devices: {
|
||||
'com.apple.CoreSimulator.SimRuntime.iOS-26-5': [
|
||||
{ udid: 'AAAA', name: 'iPhone 17', isAvailable: true, state: 'Shutdown' },
|
||||
{ udid: 'BBBB', name: 'iPhone Air', isAvailable: true, state: 'Shutdown' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.resolveUdidAsync({
|
||||
deviceIdentifier: 'iPhone Air' as any,
|
||||
env: process.env,
|
||||
})
|
||||
).resolves.toBe('BBBB');
|
||||
await expect(
|
||||
IosSimulatorUtils.resolveUdidAsync({
|
||||
deviceIdentifier: 'iPhone 99' as any,
|
||||
env: process.env,
|
||||
})
|
||||
).rejects.toThrow(UserError);
|
||||
});
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.bootAsync, () => {
|
||||
it('boots without waiting and tolerates an already booted device', async () => {
|
||||
await IosSimulatorUtils.bootAsync({ deviceIdentifier: 'AAAA' as any, env: process.env });
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('xcrun', ['simctl', 'boot', 'AAAA'], {
|
||||
env: process.env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
mockedSpawn.mockRejectedValueOnce(
|
||||
Object.assign(new Error('boot failed'), {
|
||||
stderr: 'Unable to boot device in current state: Booted',
|
||||
})
|
||||
);
|
||||
await expect(
|
||||
IosSimulatorUtils.bootAsync({ deviceIdentifier: 'AAAA' as any, env: process.env })
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
mockedSpawn.mockRejectedValueOnce(
|
||||
Object.assign(new Error('boot failed'), { stderr: 'other' })
|
||||
);
|
||||
await expect(
|
||||
IosSimulatorUtils.bootAsync({ deviceIdentifier: 'AAAA' as any, env: process.env })
|
||||
).rejects.toThrow('boot failed');
|
||||
});
|
||||
|
||||
it('hands launchd environment to the boot through SIMCTL_CHILD_ variables', async () => {
|
||||
await IosSimulatorUtils.bootAsync({
|
||||
deviceIdentifier: 'AAAA' as any,
|
||||
env: { PATH: '/usr/bin' },
|
||||
launchdEnvironment: {
|
||||
DYLD_INSERT_LIBRARIES: '/w/guard.dylib',
|
||||
https_proxy: 'http://127.0.0.1:8899',
|
||||
},
|
||||
});
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('xcrun', ['simctl', 'boot', 'AAAA'], {
|
||||
env: {
|
||||
PATH: '/usr/bin',
|
||||
SIMCTL_CHILD_DYLD_INSERT_LIBRARIES: '/w/guard.dylib',
|
||||
SIMCTL_CHILD_https_proxy: 'http://127.0.0.1:8899',
|
||||
},
|
||||
stdio: 'pipe',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { graphqlAbortContext } from '../graphqlAbort';
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
it('returns nothing without a signal', () => {
|
||||
expect(graphqlAbortContext(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('aborts the request when either the caller or urql aborts', async () => {
|
||||
const seen: AbortSignal[] = [];
|
||||
globalThis.fetch = jest.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
seen.push(init!.signal!);
|
||||
return new Response('{}');
|
||||
}) as typeof fetch;
|
||||
const caller = new AbortController();
|
||||
const urql = new AbortController();
|
||||
const context = graphqlAbortContext(caller.signal)!;
|
||||
await context.fetch!('https://api.test/graphql', { method: 'POST', signal: urql.signal });
|
||||
await context.fetch!('https://api.test/graphql', { method: 'POST' });
|
||||
expect(seen).toHaveLength(2);
|
||||
expect(seen.every(signal => !signal.aborted)).toBe(true);
|
||||
urql.abort(new Error('urql gave up'));
|
||||
expect(seen[0].aborted).toBe(true);
|
||||
expect(seen[1].aborted).toBe(false);
|
||||
caller.abort(new Error('caller gave up'));
|
||||
expect(seen[1].aborted).toBe(true);
|
||||
});
|
||||
@@ -9,6 +9,12 @@ import {
|
||||
PackageManager,
|
||||
findPackagerRootDir,
|
||||
getPackageVersionFromPackageJson,
|
||||
resolveConfiguredPackageManager,
|
||||
resolveFallbackPackageManager,
|
||||
resolveOverridePackageManager,
|
||||
resolvePackageAdd,
|
||||
resolvePackageExec,
|
||||
resolvePackageInstall,
|
||||
resolvePackageManager,
|
||||
resolvePackageVersionAsync,
|
||||
shouldUseFrozenLockfile,
|
||||
@@ -117,6 +123,176 @@ describe(resolvePackageManager, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveFallbackPackageManager, () => {
|
||||
it('returns undefined when unset or empty', () => {
|
||||
expect(resolveFallbackPackageManager({})).toBeUndefined();
|
||||
expect(resolveFallbackPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: '' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns bun when set', () => {
|
||||
expect(resolveFallbackPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: 'bun' })).toBe(
|
||||
PackageManager.BUN
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a UserError on an unsupported value', () => {
|
||||
expect(() => resolveFallbackPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: 'bunn' })).toThrow(
|
||||
errors.UserError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveOverridePackageManager, () => {
|
||||
it('returns undefined when unset or empty', () => {
|
||||
expect(resolveOverridePackageManager({})).toBeUndefined();
|
||||
expect(resolveOverridePackageManager({ EAS_OVERRIDE_PACKAGE_MANAGER: '' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns bun when set', () => {
|
||||
expect(resolveOverridePackageManager({ EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' })).toBe(
|
||||
PackageManager.BUN
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a UserError on an unsupported value', () => {
|
||||
expect(() => resolveOverridePackageManager({ EAS_OVERRIDE_PACKAGE_MANAGER: 'bunn' })).toThrow(
|
||||
errors.UserError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveConfiguredPackageManager, () => {
|
||||
it('uses the unset default', () => {
|
||||
expect(resolveConfiguredPackageManager({}, PackageManager.NPM)).toBe(PackageManager.NPM);
|
||||
});
|
||||
|
||||
it('uses EAS_FALLBACK_PACKAGE_MANAGER when no override is set', () => {
|
||||
expect(
|
||||
resolveConfiguredPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: 'bun' }, PackageManager.NPM)
|
||||
).toBe(PackageManager.BUN);
|
||||
});
|
||||
|
||||
it('prefers EAS_OVERRIDE_PACKAGE_MANAGER over the fallback', () => {
|
||||
expect(
|
||||
resolveConfiguredPackageManager(
|
||||
{
|
||||
EAS_OVERRIDE_PACKAGE_MANAGER: 'npm',
|
||||
EAS_FALLBACK_PACKAGE_MANAGER: 'bun',
|
||||
},
|
||||
PackageManager.BUN
|
||||
)
|
||||
).toBe(PackageManager.NPM);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolvePackageExec, () => {
|
||||
const packageArgs = ['@expo/serve-sim@latest', '--port', '1'];
|
||||
|
||||
it('maps npm to npx --yes', () => {
|
||||
expect(resolvePackageExec(PackageManager.NPM, packageArgs)).toEqual({
|
||||
command: 'npx',
|
||||
args: ['--yes', ...packageArgs],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps bun to bun x', () => {
|
||||
expect(resolvePackageExec(PackageManager.BUN, packageArgs)).toEqual({
|
||||
command: 'bun',
|
||||
args: ['x', ...packageArgs],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps yarn to npx --yes', () => {
|
||||
expect(resolvePackageExec(PackageManager.YARN, packageArgs)).toEqual({
|
||||
command: 'npx',
|
||||
args: ['--yes', ...packageArgs],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps pnpm to pnpm dlx', () => {
|
||||
expect(resolvePackageExec(PackageManager.PNPM, packageArgs)).toEqual({
|
||||
command: 'pnpm',
|
||||
args: ['dlx', ...packageArgs],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolvePackageAdd, () => {
|
||||
it('maps npm to npm install --no-audit', () => {
|
||||
expect(resolvePackageAdd(PackageManager.NPM, 'appium@^3')).toEqual({
|
||||
command: 'npm',
|
||||
args: ['install', '--no-audit', 'appium@^3'],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps bun to bun add', () => {
|
||||
expect(resolvePackageAdd(PackageManager.BUN, 'agent-device@latest')).toEqual({
|
||||
command: 'bun',
|
||||
args: ['add', 'agent-device@latest'],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps yarn to yarn add', () => {
|
||||
expect(resolvePackageAdd(PackageManager.YARN, 'appium@^3')).toEqual({
|
||||
command: 'yarn',
|
||||
args: ['add', 'appium@^3'],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps pnpm to pnpm add', () => {
|
||||
expect(resolvePackageAdd(PackageManager.PNPM, 'appium@^3')).toEqual({
|
||||
command: 'pnpm',
|
||||
args: ['add', 'appium@^3'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolvePackageInstall, () => {
|
||||
it('maps bun production install', () => {
|
||||
expect(resolvePackageInstall(PackageManager.BUN, { production: true })).toEqual({
|
||||
command: 'bun',
|
||||
args: ['install', '--production'],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps npm production install without audit', () => {
|
||||
expect(resolvePackageInstall(PackageManager.NPM, { production: true })).toEqual({
|
||||
command: 'npm',
|
||||
args: ['install', '--no-audit', '--omit=dev'],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps yarn and pnpm production installs', () => {
|
||||
expect(resolvePackageInstall(PackageManager.YARN, { production: true })).toEqual({
|
||||
command: 'yarn',
|
||||
args: ['install', '--production'],
|
||||
});
|
||||
expect(resolvePackageInstall(PackageManager.PNPM, { production: true })).toEqual({
|
||||
command: 'pnpm',
|
||||
args: ['install', '--prod'],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps non-production installs', () => {
|
||||
expect(resolvePackageInstall(PackageManager.NPM)).toEqual({
|
||||
command: 'npm',
|
||||
args: ['install', '--no-audit'],
|
||||
});
|
||||
expect(resolvePackageInstall(PackageManager.BUN)).toEqual({
|
||||
command: 'bun',
|
||||
args: ['install'],
|
||||
});
|
||||
expect(resolvePackageInstall(PackageManager.YARN)).toEqual({
|
||||
command: 'yarn',
|
||||
args: ['install'],
|
||||
});
|
||||
expect(resolvePackageInstall(PackageManager.PNPM)).toEqual({
|
||||
command: 'pnpm',
|
||||
args: ['install'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolvePackageVersionAsync, () => {
|
||||
const logger = {
|
||||
debug: jest.fn(),
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { OperationContext } from '@urql/core';
|
||||
|
||||
/**
|
||||
* urql replaces `fetchOptions.signal` with its own, so a caller's signal has to be joined into
|
||||
* fetch after urql has resolved auth and its signal. Returns nothing without a signal, so the
|
||||
* client's default fetch stays in use.
|
||||
*/
|
||||
export function graphqlAbortContext(
|
||||
signal: AbortSignal | undefined
|
||||
): Pick<OperationContext, 'fetch'> | undefined {
|
||||
if (!signal) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
fetch: (input, init) =>
|
||||
fetch(input, {
|
||||
...init,
|
||||
signal: init?.signal ? AbortSignal.any([init.signal, signal]) : signal,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -30,19 +30,100 @@ export function resolvePackageManager(
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const fallback = env.EAS_FALLBACK_PACKAGE_MANAGER;
|
||||
if (fallback) {
|
||||
const parsed = z.enum(PackageManager).safeParse(fallback);
|
||||
if (parsed.success) {
|
||||
return parsed.data;
|
||||
}
|
||||
const allowed = Object.values(PackageManager).join(', ');
|
||||
throw new errors.UserError(
|
||||
'EAS_INVALID_FALLBACK_PACKAGE_MANAGER',
|
||||
`Invalid EAS_FALLBACK_PACKAGE_MANAGER value "${fallback}" (expected one of: ${allowed}).`
|
||||
);
|
||||
return resolveFallbackPackageManager(env) ?? PackageManager.YARN;
|
||||
}
|
||||
|
||||
function parsePackageManagerEnvValue(
|
||||
value: string | undefined,
|
||||
envName: string,
|
||||
errorCode: string
|
||||
): PackageManager | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = z.enum(PackageManager).safeParse(value);
|
||||
if (parsed.success) {
|
||||
return parsed.data;
|
||||
}
|
||||
const allowed = Object.values(PackageManager).join(', ');
|
||||
throw new errors.UserError(
|
||||
errorCode,
|
||||
`Invalid ${envName} value "${value}" (expected one of: ${allowed}).`
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveFallbackPackageManager(env: BuildStepEnv): PackageManager | undefined {
|
||||
return parsePackageManagerEnvValue(
|
||||
env.EAS_FALLBACK_PACKAGE_MANAGER,
|
||||
'EAS_FALLBACK_PACKAGE_MANAGER',
|
||||
'EAS_INVALID_FALLBACK_PACKAGE_MANAGER'
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveOverridePackageManager(env: BuildStepEnv): PackageManager | undefined {
|
||||
return parsePackageManagerEnvValue(
|
||||
env.EAS_OVERRIDE_PACKAGE_MANAGER,
|
||||
'EAS_OVERRIDE_PACKAGE_MANAGER',
|
||||
'EAS_INVALID_OVERRIDE_PACKAGE_MANAGER'
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveConfiguredPackageManager(
|
||||
env: BuildStepEnv,
|
||||
whenUnset: PackageManager
|
||||
): PackageManager {
|
||||
return resolveOverridePackageManager(env) ?? resolveFallbackPackageManager(env) ?? whenUnset;
|
||||
}
|
||||
|
||||
export function resolvePackageExec(
|
||||
manager: PackageManager,
|
||||
args: string[]
|
||||
): { command: string; args: string[] } {
|
||||
switch (manager) {
|
||||
case PackageManager.NPM:
|
||||
return { command: 'npx', args: ['--yes', ...args] };
|
||||
case PackageManager.BUN:
|
||||
return { command: 'bun', args: ['x', ...args] };
|
||||
case PackageManager.YARN:
|
||||
return { command: 'npx', args: ['--yes', ...args] };
|
||||
case PackageManager.PNPM:
|
||||
return { command: 'pnpm', args: ['dlx', ...args] };
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePackageAdd(
|
||||
manager: PackageManager,
|
||||
packageSpec: string
|
||||
): { command: string; args: string[] } {
|
||||
switch (manager) {
|
||||
case PackageManager.NPM:
|
||||
return { command: 'npm', args: ['install', '--no-audit', packageSpec] };
|
||||
case PackageManager.BUN:
|
||||
return { command: 'bun', args: ['add', packageSpec] };
|
||||
case PackageManager.YARN:
|
||||
return { command: 'yarn', args: ['add', packageSpec] };
|
||||
case PackageManager.PNPM:
|
||||
return { command: 'pnpm', args: ['add', packageSpec] };
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePackageInstall(
|
||||
manager: PackageManager,
|
||||
{ production }: { production?: boolean } = {}
|
||||
): { command: string; args: string[] } {
|
||||
switch (manager) {
|
||||
case PackageManager.NPM:
|
||||
return {
|
||||
command: 'npm',
|
||||
args: production ? ['install', '--no-audit', '--omit=dev'] : ['install', '--no-audit'],
|
||||
};
|
||||
case PackageManager.BUN:
|
||||
return { command: 'bun', args: production ? ['install', '--production'] : ['install'] };
|
||||
case PackageManager.YARN:
|
||||
return { command: 'yarn', args: production ? ['install', '--production'] : ['install'] };
|
||||
case PackageManager.PNPM:
|
||||
return { command: 'pnpm', args: production ? ['install', '--prod'] : ['install'] };
|
||||
}
|
||||
return PackageManager.YARN;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,14 +10,20 @@ export function isChildProcessAlive(child: ChildProcess): boolean {
|
||||
* Kill a detached spawn's process group. Negated pid targets the group so bash/sleep
|
||||
* children cannot survive after the parent is gone (e.g. across upterm redial).
|
||||
*/
|
||||
export function killProcessGroup(child: ChildProcess): void {
|
||||
export function killProcessGroup(
|
||||
child: { pid?: number; kill(signal?: NodeJS.Signals): void },
|
||||
signal: NodeJS.Signals = 'SIGTERM'
|
||||
): void {
|
||||
if (child.pid == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGTERM');
|
||||
} catch {
|
||||
child.kill();
|
||||
process.kill(-child.pid, signal);
|
||||
} catch (error: any) {
|
||||
// ESRCH means the process group no longer exists, so there is nothing left to stop.
|
||||
if (error?.code !== 'ESRCH') {
|
||||
child.kill(signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "node",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"target": "es2022",
|
||||
"module": "commonjs",
|
||||
"strict": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "24.0.0",
|
||||
"version": "24.8.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,13 +35,13 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "24.0.0"
|
||||
"@expo/steps": "24.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "20.14.2",
|
||||
"@types/node": "20.19.43",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@vercel/ncc": "^0.38.1",
|
||||
"arg": "^5.0.2",
|
||||
@@ -52,7 +52,7 @@
|
||||
"prompts": "^2.4.2",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.5.4",
|
||||
"typescript": "^6.0.3",
|
||||
"update-check": "^1.5.4"
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/steps": "^1.0.119",
|
||||
"@types/node": "20.14.11",
|
||||
"@types/node": "20.19.43",
|
||||
"@vercel/ncc": "^0.38.1",
|
||||
"typescript": "^5.5.3"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"module": "node16",
|
||||
"sourceMap": true,
|
||||
"inlineSources": true,
|
||||
"strict": true,
|
||||
@@ -16,7 +16,7 @@
|
||||
"outDir": "build",
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "node16",
|
||||
"rootDir": "src",
|
||||
"declaration": false,
|
||||
"composite": false
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"include": ["src/**/*.ts"],
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "node",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"target": "es2022",
|
||||
"module": "commonjs",
|
||||
"strict": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "24.0.0",
|
||||
"version": "24.8.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -31,10 +31,10 @@
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "20.14.2",
|
||||
"@types/node": "20.19.43",
|
||||
"jest": "^29.7.0",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "^29.1.4",
|
||||
"typescript": "^5.5.4"
|
||||
"ts-jest": "^29.4.12",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "node",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"target": "es2022",
|
||||
"module": "commonjs",
|
||||
"strict": true,
|
||||
@@ -8,6 +9,7 @@
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"types": ["node", "jest"],
|
||||
"composite": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist"
|
||||
|
||||
Loaded 100 of 306 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user