mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
cc37c1c360 | ||
|
|
e78771e0f4 | ||
|
|
288bc44dfc | ||
|
|
d3db3926e3 | ||
|
|
5b87ba0adb | ||
|
|
0b2cea7bb7 | ||
|
|
eab4fc3cd5 | ||
|
|
e2e8cd245c | ||
|
|
80d46f0783 | ||
|
|
3b6b63a273 | ||
|
|
8373ae353c | ||
|
|
994794dbad | ||
|
|
d103da3bc5 | ||
|
|
ee3c62683b | ||
|
|
8ac9d5f101 | ||
|
|
1eaa799b9f | ||
|
|
bb0f2dfb21 |
No files matched your search
@@ -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:
|
||||
|
||||
@@ -6,6 +6,89 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
### 🐛 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))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [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))
|
||||
|
||||
## [24.1.1](https://github.com/expo/eas-cli/releases/tag/v24.1.1) - 2026-09-11
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Stop telling interactive `eas simulator:start --egress local` users to run `eas simulator:egress` when the egress client already runs in the same terminal. ([#4382](https://github.com/expo/eas-cli/pull/4382) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [24.1.0](https://github.com/expo/eas-cli/releases/tag/v24.1.0) - 2026-09-10
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--egress-allow <host:port>` to `eas simulator:start` so local egress sessions can reach dev servers on this machine or its network. ([#4369](https://github.com/expo/eas-cli/pull/4369) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli][build-tools] Forward `--egress-allow localhost:<port>` and `127.0.0.1:<port>` entries from the simulator host's loopback interface to this machine (like `adb reverse`), so dev server URLs that use `127.0.0.1` work through local egress. ([#4369](https://github.com/expo/eas-cli/pull/4369) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Add the `eas/start_local_egress` step for iOS simulator sessions with local egress: the device host's system proxy points at a reverse tunnel to the EAS CLI machine, so HTTP(S) requests that honor it exit from that machine and third parties see its IP. ([#4364](https://github.com/expo/eas-cli/pull/4364) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Add `--egress local` to `eas simulator:start` and the `eas simulator:egress` command, which run the local egress client for the session. ([#4364](https://github.com/expo/eas-cli/pull/4364) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Require the serve-sim session token for device run session previews, so the tunnel no longer exposes the shell-exec route. ([#4306](https://github.com/expo/eas-cli/pull/4306) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Report a system error when project source refresh fails for a remote build. ([#4359](https://github.com/expo/eas-cli/pull/4359) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [24.0.0](https://github.com/expo/eas-cli/releases/tag/v24.0.0) - 2026-09-09
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Align the `eas observe:*` commands with the renamed Observe GraphQL schema. `--json` output now uses `name`/`value` instead of `metricName`/`metricValue` (metrics) and `name` instead of `eventName` (log events and event-name summaries), and `eas observe:events` now lists user-defined events only — exceptions moved to the new `eas observe:errors`. ([#4338](https://github.com/expo/eas-cli/pull/4338) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "23.2.0",
|
||||
"PRODUCTION": "23.2.0"
|
||||
"STAGING": "24.4.1",
|
||||
"PRODUCTION": "24.4.1"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "24.0.0",
|
||||
"version": "24.4.2",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -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.0.0",
|
||||
"version": "24.4.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",
|
||||
@@ -40,14 +42,14 @@
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "24.0.0",
|
||||
"@expo/eas-build-job": "24.0.0",
|
||||
"@expo/eas-build-job": "24.3.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "24.0.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "24.0.0",
|
||||
"@expo/steps": "24.3.0",
|
||||
"@expo/template-file": "24.0.0",
|
||||
"@expo/turtle-spawn": "24.0.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
@@ -75,6 +77,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"
|
||||
},
|
||||
@@ -92,6 +95,7 @@
|
||||
"@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",
|
||||
|
||||
@@ -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"
|
||||
@@ -4,9 +4,11 @@ import { BuildStepGlobalContext, StepsConfigParser } from '@expo/steps';
|
||||
import { runGenericJobAsync } from '../generic';
|
||||
import { CustomBuildContext } from '../customBuildContext';
|
||||
import { uploadJobOutputsToWwwAsync } from '../utils/outputs';
|
||||
import { stopLocalEgressResourcesAsync } from '../steps/utils/localEgress';
|
||||
|
||||
jest.mock('../customBuildContext');
|
||||
jest.mock('../utils/outputs');
|
||||
jest.mock('../steps/utils/localEgress', () => ({ stopLocalEgressResourcesAsync: jest.fn() }));
|
||||
jest.mock('../common/projectSources');
|
||||
jest.mock('../steps/easFunctions', () => ({ getEasFunctions: jest.fn().mockReturnValue([]) }));
|
||||
jest.mock('../steps/easFunctionGroups', () => ({
|
||||
@@ -58,6 +60,25 @@ describe(runGenericJobAsync, () => {
|
||||
mockUploadJobOutputsToWwwAsync.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it.each(['success', 'failure'])(
|
||||
'releases local egress after workflow %s, before uploading outputs',
|
||||
async outcome => {
|
||||
const executeAsync =
|
||||
outcome === 'success'
|
||||
? jest.fn().mockResolvedValue(undefined)
|
||||
: jest.fn().mockRejectedValue(new Error(outcome));
|
||||
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
|
||||
parseAsync: jest.fn().mockResolvedValue({ executeAsync }),
|
||||
}));
|
||||
const { runResult } = await runGenericJobAsync(mockCtx);
|
||||
expect(runResult.ok).toBe(outcome === 'success');
|
||||
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledWith(mockCtx.logger);
|
||||
expect(jest.mocked(stopLocalEgressResourcesAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockUploadJobOutputsToWwwAsync.mock.invocationCallOrder[0]
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('awaits drainPendingMetricUploads in COMPLETE_JOB phase', async () => {
|
||||
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
|
||||
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
|
||||
|
||||
@@ -2,8 +2,10 @@ import {
|
||||
ArchiveSourceType,
|
||||
BuildMode,
|
||||
BuildTrigger,
|
||||
ErrorCode,
|
||||
Job,
|
||||
Platform,
|
||||
SystemError,
|
||||
Workflow,
|
||||
} from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
@@ -45,7 +47,7 @@ describe('projectSources', () => {
|
||||
expect(logger.info).toHaveBeenCalledWith('Normalizing project source permissions');
|
||||
});
|
||||
|
||||
it('should use the refreshed repository URL', async () => {
|
||||
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 });
|
||||
@@ -61,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,
|
||||
},
|
||||
@@ -111,102 +115,88 @@ describe('projectSources', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to the original repository URL if the refresh fails', async () => {
|
||||
const robotAccessToken = randomUUID();
|
||||
const buildId = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
|
||||
it.each(['http', 'network', 'json', 'schema', 'missing-url', 'PATH', 'GCS', 'R2'])(
|
||||
'throws a system error if refresh fails (%s)',
|
||||
async failure => {
|
||||
const robotAccessToken = randomUUID();
|
||||
const buildId = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
|
||||
|
||||
const ctx = new BuildContext(
|
||||
{
|
||||
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
|
||||
type: Workflow.MANAGED,
|
||||
mode: BuildMode.BUILD,
|
||||
initiatingUserId: randomUUID(),
|
||||
appId: randomUUID(),
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash: randomBytes(20).toString('hex'),
|
||||
},
|
||||
platform: Platform.IOS,
|
||||
secrets: {
|
||||
robotAccessToken,
|
||||
environmentSecrets: [],
|
||||
},
|
||||
} as Job,
|
||||
{
|
||||
env: {
|
||||
__API_SERVER_URL: 'https://api.expo.dev',
|
||||
EXPO_TOKEN: robotAccessToken,
|
||||
EAS_BUILD_ID: buildId,
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
},
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
const ctx = new BuildContext(
|
||||
{
|
||||
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
|
||||
type: Workflow.MANAGED,
|
||||
mode: BuildMode.BUILD,
|
||||
initiatingUserId: randomUUID(),
|
||||
appId: randomUUID(),
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash: randomBytes(20).toString('hex'),
|
||||
},
|
||||
platform: Platform.IOS,
|
||||
secrets: {
|
||||
robotAccessToken,
|
||||
environmentSecrets: [],
|
||||
},
|
||||
} as Job,
|
||||
{
|
||||
env: {
|
||||
__API_SERVER_URL: 'https://api.expo.dev',
|
||||
EXPO_TOKEN: robotAccessToken,
|
||||
EAS_BUILD_ID: buildId,
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
},
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
const cause = new Error('Source request failed');
|
||||
fetchMock.mockImplementation(async () => {
|
||||
if (failure === 'network') {
|
||||
throw cause;
|
||||
}
|
||||
return {
|
||||
ok: failure !== 'http',
|
||||
status: failure === 'http' ? 500 : 200,
|
||||
json: async () => {
|
||||
if (failure === 'json') {
|
||||
throw cause;
|
||||
}
|
||||
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;
|
||||
});
|
||||
|
||||
const result = prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
await expect(result).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(result).rejects.toMatchObject({
|
||||
errorCode: ErrorCode.SERVER_ERROR,
|
||||
cause: expect.any(Error),
|
||||
});
|
||||
if (failure === 'network') {
|
||||
await expect(result).rejects.toMatchObject({ cause });
|
||||
}
|
||||
);
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
ok: false,
|
||||
text: async () => 'Failed to generate repository URL',
|
||||
}) as Response
|
||||
);
|
||||
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
archiveSource: {
|
||||
...ctx.job.projectArchive,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
},
|
||||
})
|
||||
);
|
||||
fetchMock.mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
ok: false,
|
||||
json: async () => ({
|
||||
// repositoryUrl is the right key
|
||||
repository_url: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
}),
|
||||
}) as Response
|
||||
);
|
||||
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
archiveSource: {
|
||||
...ctx.job.projectArchive,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
fetchMock.mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
ok: false,
|
||||
json: () => Promise.reject(new Error('Failed to generate repository URL')),
|
||||
}) as Response
|
||||
);
|
||||
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
archiveSource: {
|
||||
...ctx.job.projectArchive,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(shallowCloneRepositoryAsync).toHaveBeenCalledTimes(3);
|
||||
}, 15_000);
|
||||
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
},
|
||||
15_000
|
||||
);
|
||||
|
||||
it('should retry fetching the repository URL', async () => {
|
||||
const robotAccessToken = randomUUID();
|
||||
@@ -224,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,
|
||||
},
|
||||
@@ -281,7 +270,7 @@ describe('projectSources', () => {
|
||||
);
|
||||
}, 15_000);
|
||||
|
||||
it(`should fallback to the original repository URL if we're missing some config`, async () => {
|
||||
it(`throws a system error if refresh configuration is missing`, async () => {
|
||||
const robotAccessToken = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
|
||||
const logger = createMockLogger();
|
||||
@@ -295,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'),
|
||||
},
|
||||
@@ -319,12 +307,11 @@ describe('projectSources', () => {
|
||||
}
|
||||
);
|
||||
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
'Failed to refresh project archive, falling back to the original one'
|
||||
);
|
||||
await expect(prepareProjectSourcesAsync(ctx, ctx.buildDirectory)).rejects.toMatchObject({
|
||||
errorCode: ErrorCode.SERVER_ERROR,
|
||||
cause: new Error('EAS_BUILD_ID is not set'),
|
||||
});
|
||||
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('uploadProjectMetadataAsFireAndForget', () => {
|
||||
@@ -404,6 +391,10 @@ describe('projectSources', () => {
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockImplementation(async () => ({ ok: true }) as Response);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: ctx.job.projectArchive }),
|
||||
} as Response);
|
||||
|
||||
// Call prepareProjectSourcesAsync and don't await metadata upload
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
@@ -517,6 +508,10 @@ describe('projectSources', () => {
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockResolvedValue({ ok: true } as Response);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: ctx.job.projectArchive }),
|
||||
} as Response);
|
||||
|
||||
const startTime = Date.now();
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
@@ -581,6 +576,10 @@ describe('projectSources', () => {
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockResolvedValue({ ok: true } as Response);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: ctx.job.projectArchive }),
|
||||
} as Response);
|
||||
|
||||
// Should not throw even though upload will fail
|
||||
await expect(prepareProjectSourcesAsync(ctx, ctx.buildDirectory)).resolves.not.toThrow();
|
||||
@@ -595,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,5 +1,5 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { ArchiveSource, ArchiveSourceSchemaZ, ArchiveSourceType, Job } 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';
|
||||
@@ -19,33 +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) {
|
||||
ctx.logger.error(
|
||||
{ err: projectArchiveResult.reason },
|
||||
'Failed to refresh project archive, falling back to the original one'
|
||||
);
|
||||
}
|
||||
|
||||
projectArchive = projectArchiveResult.value ?? ctx.job.projectArchive;
|
||||
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 };
|
||||
@@ -249,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(
|
||||
@@ -287,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)}`
|
||||
|
||||
@@ -16,6 +16,7 @@ import { BuildContext } from './context';
|
||||
import { CustomBuildContext } from './customBuildContext';
|
||||
import { getEasFunctionGroups } from './steps/easFunctionGroups';
|
||||
import { getEasFunctions } from './steps/easFunctions';
|
||||
import { stopLocalEgressResourcesAsync } from './steps/utils/localEgress';
|
||||
import { uploadJobOutputsToWwwAsync } from './utils/outputs';
|
||||
import { retryAsync } from './utils/retry';
|
||||
|
||||
@@ -82,7 +83,17 @@ export async function runGenericJobAsync(
|
||||
}
|
||||
});
|
||||
|
||||
const runResult = await asyncResult(workflow.executeAsync());
|
||||
const runResult = await asyncResult(
|
||||
(async () => {
|
||||
try {
|
||||
await workflow.executeAsync();
|
||||
} finally {
|
||||
// The session-owning step may never run if simulator boot/setup fails,
|
||||
// or may time out while resource acquisition is still in flight.
|
||||
await stopLocalEgressResourcesAsync(ctx.logger);
|
||||
}
|
||||
})()
|
||||
);
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.COMPLETE_JOB, async () => {
|
||||
const results = await Promise.allSettled([
|
||||
|
||||
@@ -51,8 +51,10 @@ import { createStartAppiumRemoteSessionBuildFunction } from './functions/startAp
|
||||
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
|
||||
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
|
||||
import { createStartIosSimulatorRecordingsBuildFunction } from './functions/startIosSimulatorRecordings';
|
||||
import { createStartLocalEgressBuildFunction } from './functions/startLocalEgress';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/startWebPreviewRemoteSession';
|
||||
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
|
||||
import { createStartSandboxBuildFunction } from './functions/startSandbox';
|
||||
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
|
||||
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
|
||||
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings';
|
||||
@@ -103,11 +105,13 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createStartAndroidEmulatorBuildFunction(),
|
||||
createStartCuttlefishDeviceBuildFunction(),
|
||||
createStartIosSimulatorBuildFunction(),
|
||||
createStartLocalEgressBuildFunction(),
|
||||
createStartIosSimulatorRecordingsBuildFunction(),
|
||||
createFinishIosSimulatorRecordingsBuildFunction(),
|
||||
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',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import {
|
||||
monitorLocalEgressAsync,
|
||||
readLocalEgressHandoffAsync,
|
||||
registerLocalEgressResources,
|
||||
stopLocalEgressResourcesAsync,
|
||||
} from '../../utils/localEgress';
|
||||
import {
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
|
||||
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
|
||||
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/IosSimulatorUtils');
|
||||
jest.mock('../../../utils/processes');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../utils/agentDeviceArtifacts');
|
||||
jest.mock('../../utils/argentArtifacts');
|
||||
jest.mock('../../utils/agentDeviceEvents', () => ({
|
||||
startAgentDeviceEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
|
||||
}));
|
||||
jest.mock('../../utils/argentEvents', () => ({
|
||||
ARGENT_EVENT_LOG_FILENAME: 'events.jsonl',
|
||||
startArgentEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
|
||||
}));
|
||||
jest.mock('../../utils/appiumEvents', () => ({
|
||||
startAppiumEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
|
||||
}));
|
||||
jest.mock('../../utils/localEgress', () => ({
|
||||
...jest.requireActual('../../utils/localEgress'),
|
||||
readLocalEgressHandoffAsync: jest.fn(),
|
||||
monitorLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
getDeviceRunSessionIdOrThrow: () => 'session-id',
|
||||
getNgrokTunnelDomainOrThrow: () => 'example.test',
|
||||
getNgrokAuthtokenOrThrow: () => 'ngrok-token',
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
spawnDetached: () => ({ pid: 123, getOutput: () => '', stopAsync: jest.fn() }),
|
||||
waitForFileAsync: async () => ({ port: 1234, token: 'controller-token' }),
|
||||
startNgrokTunnelAsync: async () => ({ url: 'https://controller.test', stopAsync: jest.fn() }),
|
||||
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const env = { DEVICE_RUN_SESSION_ID: 'session-id' };
|
||||
const handoff = { url: 'https://egress.test', token: 'secret', fingerprint: 'key=', port: 8899 };
|
||||
const fields = {
|
||||
egressUrl: handoff.url,
|
||||
egressToken: handoff.token,
|
||||
egressFingerprint: handoff.fingerprint,
|
||||
egressPort: handoff.port,
|
||||
};
|
||||
const controllers = [
|
||||
['agent-device', createStartAgentDeviceRemoteSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
|
||||
['Appium', createStartAppiumRemoteSessionBuildFunction, 'appiumUrl'],
|
||||
['Argent', createStartArgentRemoteSessionBuildFunction, 'toolsUrl'],
|
||||
['web preview', createStartWebPreviewRemoteSessionBuildFunction, 'previewUrl'],
|
||||
] as const;
|
||||
|
||||
describe.each(controllers)('%s local egress', (_name, createFunction, controllerField) => {
|
||||
const stopPreview = jest.fn();
|
||||
const stopEgress = jest.fn();
|
||||
let lifetime: AbortSignal;
|
||||
async function run(signal?: AbortSignal): Promise<void> {
|
||||
await createFunction(ctx).fn!(
|
||||
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env,
|
||||
signal,
|
||||
} as never
|
||||
);
|
||||
}
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
lifetime = registerLocalEgressResources(stopEgress);
|
||||
stopEgress.mockResolvedValue(undefined);
|
||||
stopPreview.mockResolvedValue(undefined);
|
||||
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
jest.spyOn(fs.promises, 'mkdtemp').mockResolvedValue('/tmp/appium-egress-test');
|
||||
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
|
||||
jest.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
|
||||
jest.spyOn(fs.promises, 'readdir').mockResolvedValue(['tool-server.json'] as never);
|
||||
jest
|
||||
.spyOn(fs.promises, 'readFile')
|
||||
.mockResolvedValue(JSON.stringify({ port: 1234, pid: 124, token: 'controller-token' }));
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '{}' } as never);
|
||||
jest.mocked(isProcessDescendantOfAsync).mockResolvedValue(true);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'sim' }] as never);
|
||||
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(handoff);
|
||||
jest.mocked(monitorLocalEgressAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewUrl: 'https://preview.test',
|
||||
previewToken: 'preview-secret',
|
||||
stopAsync: stopPreview,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
|
||||
expect(lifetime.aborted).toBe(false);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
afterEach(async () => {
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reports credentials and starts monitoring before waiting, then releases resources', async () => {
|
||||
await run();
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
deviceRunSessionId: 'session-id',
|
||||
logger,
|
||||
remoteConfig: expect.objectContaining({
|
||||
...fields,
|
||||
[controllerField]: expect.any(String),
|
||||
[controllerField === 'previewUrl' ? 'previewToken' : 'webPreviewToken']: 'preview-secret',
|
||||
}),
|
||||
});
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledWith({
|
||||
port: handoff.port,
|
||||
env,
|
||||
logger,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(lifetime.aborted).toBe(true);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('leaves ordinary remote config unchanged and does not monitor', async () => {
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
stopEgress.mockClear();
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(null);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
await run();
|
||||
const { remoteConfig } = jest.mocked(uploadRemoteSessionConfigAsync).mock.calls[0][0];
|
||||
expect(remoteConfig).toHaveProperty(controllerField);
|
||||
expect(Object.keys(remoteConfig).some(key => key.startsWith('egress'))).toBe(false);
|
||||
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(stopEgress).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(['startup', 'report', 'wait', 'teardown'])(
|
||||
'releases egress after %s fails',
|
||||
async phase => {
|
||||
const error = new Error(`${phase} failed`);
|
||||
if (phase === 'startup') {
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockRejectedValue(error);
|
||||
} else if (phase === 'report') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValue(error);
|
||||
} else if (phase === 'wait') {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValue(error);
|
||||
} else {
|
||||
stopPreview.mockRejectedValue(error);
|
||||
}
|
||||
await expect(run()).rejects.toThrow(error);
|
||||
expect(lifetime.aborted).toBe(true);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(
|
||||
phase === 'startup' || phase === 'report' ? 0 : 1
|
||||
);
|
||||
}
|
||||
);
|
||||
it('passes cancellation to the monitor and releases the lifetime', async () => {
|
||||
const abort = new AbortController();
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => abort.abort());
|
||||
await run(abort.signal);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signal: abort.signal })
|
||||
);
|
||||
expect(lifetime.aborted).toBe(true);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('does not report or start a monitor after cancellation', async () => {
|
||||
const abort = new AbortController();
|
||||
abort.abort();
|
||||
await expect(run(abort.signal)).rejects.toThrow();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type BuildStepContext } from '@expo/steps';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import {
|
||||
LOCAL_EGRESS_HANDOFF_PATH,
|
||||
configureSystemProxyAsync,
|
||||
downloadChiselAsync,
|
||||
startChiselServerAsync,
|
||||
stopLocalEgressResourcesAsync,
|
||||
writeLocalEgressHandoffAsync,
|
||||
} from '../../utils/localEgress';
|
||||
import { findAvailablePortAsync, startNgrokTunnelAsync } from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartLocalEgressBuildFunction } from '../startLocalEgress';
|
||||
|
||||
jest.mock('../../utils/localEgress', () => ({
|
||||
...jest.requireActual('../../utils/localEgress'),
|
||||
configureSystemProxyAsync: jest.fn(),
|
||||
downloadChiselAsync: jest.fn(),
|
||||
startChiselServerAsync: jest.fn(),
|
||||
writeLocalEgressHandoffAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/localEgressGuard', () => ({
|
||||
stopLocalEgressGuardRelaysAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
findAvailablePortAsync: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
getNgrokAuthtokenOrThrow: () => 'ngrok-token',
|
||||
getNgrokTunnelDomainOrThrow: () => 'example.com',
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const server = { pid: 12345, getOutput: () => '', stopAsync: jest.fn() };
|
||||
const tunnel = { url: 'https://egress.example.com', stopAsync: jest.fn() };
|
||||
|
||||
async function start(signal?: AbortSignal): Promise<void> {
|
||||
await createStartLocalEgressBuildFunction().fn!({ logger } as BuildStepContext, {
|
||||
env: {},
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
describe('local egress acquisition', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(fs.promises, 'mkdtemp').mockResolvedValue('/tmp/egress-acquisition-test');
|
||||
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
|
||||
jest.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
|
||||
server.stopAsync.mockResolvedValue(undefined);
|
||||
tunnel.stopAsync.mockResolvedValue(undefined);
|
||||
jest.mocked(findAvailablePortAsync).mockResolvedValue(52001);
|
||||
jest.mocked(downloadChiselAsync).mockResolvedValue('/tmp/egress-acquisition-test/chisel');
|
||||
jest.mocked(startChiselServerAsync).mockResolvedValue({ process: server, fingerprint: 'key=' });
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValue(tunnel);
|
||||
jest.mocked(configureSystemProxyAsync).mockResolvedValue({ service: 'Ethernet' });
|
||||
jest.mocked(writeLocalEgressHandoffAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('stops the detached server and removes credentials when ngrok acquisition fails', async () => {
|
||||
jest.mocked(startNgrokTunnelAsync).mockRejectedValue(new Error('listener quota'));
|
||||
await expect(start()).rejects.toThrow('listener quota');
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
|
||||
expect(fs.promises.rm).toHaveBeenCalledWith('/tmp/egress-acquisition-test', {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('attempts every release even when one resource cannot stop', async () => {
|
||||
await start();
|
||||
tunnel.stopAsync.mockRejectedValue(new Error('ngrok close failed'));
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(fs.promises.rm).toHaveBeenCalledWith(LOCAL_EGRESS_HANDOFF_PATH, { force: true });
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Could not stop a local egress resource.'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'disposes late listeners without delaying cleanup (close fails: %s)',
|
||||
async closeFails => {
|
||||
let finishTunnel!: (value: typeof tunnel) => void;
|
||||
jest.mocked(startNgrokTunnelAsync).mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finishTunnel = resolve;
|
||||
})
|
||||
);
|
||||
const started = start();
|
||||
const rejected = expect(started).rejects.toThrow();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(startNgrokTunnelAsync).toHaveBeenCalledTimes(1);
|
||||
const stopped = stopLocalEgressResourcesAsync(logger);
|
||||
await Promise.all([stopped, rejected]);
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(tunnel.stopAsync).not.toHaveBeenCalled();
|
||||
if (closeFails) {
|
||||
tunnel.stopAsync.mockRejectedValue(new Error('late close failed'));
|
||||
}
|
||||
finishTunnel(tunnel);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
if (closeFails) {
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Could not release a cancelled local egress acquisition.'
|
||||
);
|
||||
}
|
||||
expect(tunnel.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
|
||||
expect(writeLocalEgressHandoffAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['job', 'step'])('finishes %s cancellation when ngrok never settles', async source => {
|
||||
jest.mocked(startNgrokTunnelAsync).mockReturnValue(new Promise(() => {}));
|
||||
const controller = new AbortController();
|
||||
const started = start(controller.signal);
|
||||
const rejected = expect(started).rejects.toThrow();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(startNgrokTunnelAsync).toHaveBeenCalledTimes(1);
|
||||
if (source === 'step') {
|
||||
controller.abort(new Error('cancelled'));
|
||||
}
|
||||
const stopped = source === 'job' ? stopLocalEgressResourcesAsync(logger) : Promise.resolve();
|
||||
const outcome = await Promise.race([
|
||||
Promise.all([stopped, rejected]).then(() => 'stopped'),
|
||||
new Promise(resolve => setImmediate(() => resolve('still waiting'))),
|
||||
]);
|
||||
expect(outcome).toBe('stopped');
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
|
||||
expect(writeLocalEgressHandoffAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles ngrok rejection after finalization without an unhandled rejection', async () => {
|
||||
let rejectTunnel!: (error: Error) => void;
|
||||
jest.mocked(startNgrokTunnelAsync).mockReturnValue(
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectTunnel = reject;
|
||||
})
|
||||
);
|
||||
const started = start();
|
||||
const rejected = expect(started).rejects.toThrow();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await Promise.all([stopLocalEgressResourcesAsync(logger), rejected]);
|
||||
rejectTunnel(new Error('late connection failure'));
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not launch a server after a download finishes following cancellation', async () => {
|
||||
let finishDownload!: (value: string) => void;
|
||||
jest.mocked(downloadChiselAsync).mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finishDownload = resolve;
|
||||
})
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const started = start(controller.signal);
|
||||
const rejected = expect(started).rejects.toThrow('cancelled');
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
controller.abort(new Error('cancelled'));
|
||||
await rejected;
|
||||
finishDownload('/tmp/chisel');
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(startChiselServerAsync).not.toHaveBeenCalled();
|
||||
expect(
|
||||
jest
|
||||
.mocked(fs.promises.rm)
|
||||
.mock.calls.filter(([target]) => target === '/tmp/egress-acquisition-test')
|
||||
).toHaveLength(2);
|
||||
expect(fs.promises.rm).toHaveBeenCalledWith('/tmp/egress-acquisition-test', {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
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: {},
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
start.mockRestore();
|
||||
}
|
||||
});
|
||||
it('provides one long-running step', () => {
|
||||
const ctx = { job: {} } as any;
|
||||
expect(createStartSandboxBuildFunction(ctx).getFullId()).toBe('eas/start_sandbox');
|
||||
});
|
||||
|
||||
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: {},
|
||||
})
|
||||
).rejects.toBeInstanceOf(SystemError);
|
||||
});
|
||||
|
||||
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.');
|
||||
});
|
||||
});
|
||||
+19
@@ -52,6 +52,25 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
stopAsync.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('reports the session token when serve-sim minted one', async () => {
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewUrl: 'https://web-preview.example.test',
|
||||
previewToken: 'tok-1',
|
||||
stopAsync,
|
||||
});
|
||||
|
||||
await runAsync(BuildRuntimePlatform.DARWIN);
|
||||
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: {
|
||||
previewUrl: 'https://web-preview.example.test',
|
||||
previewToken: 'tok-1',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[BuildRuntimePlatform.DARWIN, true],
|
||||
[BuildRuntimePlatform.LINUX, false],
|
||||
|
||||
@@ -13,6 +13,10 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
|
||||
@@ -25,7 +29,6 @@ import {
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
@@ -66,7 +69,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
// Fail fast before any expensive setup if the injected env
|
||||
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
|
||||
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
|
||||
@@ -121,13 +124,16 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
});
|
||||
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
agentDeviceRemoteSessionUrl,
|
||||
agentDeviceRemoteSessionToken: daemonToken,
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
@@ -173,7 +179,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
}
|
||||
await daemonProcess.stopAsync();
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
@@ -28,7 +32,6 @@ import {
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
@@ -62,7 +65,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
@@ -138,7 +141,9 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
@@ -149,6 +154,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
'appium:udid': device.udid,
|
||||
},
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
@@ -177,7 +183,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
await appiumProcess.stopAsync();
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { isProcessDescendantOfAsync } from '../../utils/processes';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
@@ -28,7 +32,6 @@ import {
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
@@ -79,7 +82,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
// Fail fast before any expensive setup if the injected env
|
||||
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
|
||||
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
|
||||
@@ -215,13 +218,16 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
});
|
||||
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
toolsUrl: publicToolsUrl,
|
||||
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
@@ -258,7 +264,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
}
|
||||
await argentServer.stopAsync();
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}: {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
CHISEL_VERSION,
|
||||
LOCAL_EGRESS_HANDOFF_PATH,
|
||||
LOCAL_EGRESS_PROXY_PORT,
|
||||
configureSystemProxyAsync,
|
||||
createChiselAuthfileContents,
|
||||
downloadChiselAsync,
|
||||
generateEgressCredentials,
|
||||
registerLocalEgressResources,
|
||||
startChiselServerAsync,
|
||||
stopLocalEgressResourcesAsync,
|
||||
writeLocalEgressHandoffAsync,
|
||||
} from '../utils/localEgress';
|
||||
import { stopLocalEgressGuardRelaysAsync } from '../utils/localEgressGuard';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
type NgrokTunnelHandle,
|
||||
findAvailablePortAsync,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
startNgrokTunnelAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
/**
|
||||
* The downloader and ngrok SDK do not accept an AbortSignal. Stop awaiting them
|
||||
* on cancellation, while retaining handlers that dispose any eventual result.
|
||||
*/
|
||||
function awaitLocalEgressAcquisitionAsync<T>(
|
||||
pending: Promise<T>,
|
||||
signal: AbortSignal,
|
||||
disposeAbandonedAsync: (result?: T) => Promise<void>,
|
||||
logger: bunyan
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let abandoned = false;
|
||||
const abort = (): void => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
abandoned = true;
|
||||
reject(signal.reason);
|
||||
};
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
if (signal.aborted) {
|
||||
abort();
|
||||
}
|
||||
void pending
|
||||
.then(
|
||||
async result => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
if (abandoned) {
|
||||
await disposeAbandonedAsync(result);
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
},
|
||||
async error => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
if (abandoned) {
|
||||
await disposeAbandonedAsync();
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
)
|
||||
.catch(err =>
|
||||
logger.warn({ err }, 'Could not release a cancelled local egress acquisition.')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Points the device host's system HTTP(S) proxy at the EAS CLI's egress client.
|
||||
* Must run before `eas/start_ios_simulator`: the simulator reads the system proxy
|
||||
* at boot. 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 {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_local_egress',
|
||||
name: 'Start local egress',
|
||||
__metricsId: 'eas/start_local_egress',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
fn: async ({ logger }, { env, signal }) => {
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
let workDir: string | undefined;
|
||||
let server: DetachedProcessHandle | undefined;
|
||||
let tunnel: NgrokTunnelHandle | undefined;
|
||||
let finishSetup!: () => void;
|
||||
const setupFinished = new Promise<void>(resolve => {
|
||||
finishSetup = resolve;
|
||||
});
|
||||
// Register before acquisition. Cancellation ends setup promptly; pending
|
||||
// SDK calls retain their own late-result disposal handlers.
|
||||
const lifetimeSignal = registerLocalEgressResources(async () => {
|
||||
await setupFinished;
|
||||
const results = await Promise.allSettled([
|
||||
Promise.resolve().then(() => stopLocalEgressGuardRelaysAsync(logger)),
|
||||
Promise.resolve().then(() => tunnel?.stopAsync()),
|
||||
Promise.resolve().then(() => server?.stopAsync()),
|
||||
]);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
logger.warn({ err: result.reason }, 'Could not stop a local egress resource.');
|
||||
}
|
||||
}
|
||||
await Promise.all([
|
||||
fs.promises.rm(LOCAL_EGRESS_HANDOFF_PATH, { force: true }),
|
||||
workDir ? fs.promises.rm(workDir, { recursive: true, force: true }) : undefined,
|
||||
]);
|
||||
});
|
||||
const startupSignal = signal ? AbortSignal.any([signal, lifetimeSignal]) : lifetimeSignal;
|
||||
|
||||
try {
|
||||
startupSignal.throwIfAborted();
|
||||
workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-local-egress-'));
|
||||
startupSignal.throwIfAborted();
|
||||
logger.info(`Downloading the reverse tunnel server (chisel ${CHISEL_VERSION}).`);
|
||||
const downloadDir = workDir;
|
||||
const chiselPath = await awaitLocalEgressAcquisitionAsync(
|
||||
downloadChiselAsync({ destinationDir: downloadDir, logger }),
|
||||
startupSignal,
|
||||
async () => await fs.promises.rm(downloadDir, { recursive: true, force: true }),
|
||||
logger
|
||||
);
|
||||
startupSignal.throwIfAborted();
|
||||
|
||||
const credentials = generateEgressCredentials();
|
||||
const authfilePath = path.join(workDir, 'authfile.json');
|
||||
await fs.promises.writeFile(
|
||||
authfilePath,
|
||||
createChiselAuthfileContents({ ...credentials, port: LOCAL_EGRESS_PROXY_PORT }),
|
||||
{ encoding: 'utf8', mode: 0o600 }
|
||||
);
|
||||
startupSignal.throwIfAborted();
|
||||
|
||||
const controlPort = await findAvailablePortAsync();
|
||||
startupSignal.throwIfAborted();
|
||||
logger.info(`Starting the reverse tunnel server on 127.0.0.1:${controlPort}.`);
|
||||
const started = await startChiselServerAsync({
|
||||
chiselPath,
|
||||
controlPort,
|
||||
authfilePath,
|
||||
env,
|
||||
signal: startupSignal,
|
||||
});
|
||||
server = started.process;
|
||||
startupSignal.throwIfAborted();
|
||||
tunnel = await awaitLocalEgressAcquisitionAsync(
|
||||
startNgrokTunnelAsync({
|
||||
port: controlPort,
|
||||
subdomainPrefix: 'egress',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
logger,
|
||||
}),
|
||||
startupSignal,
|
||||
async listener => await listener?.stopAsync(),
|
||||
logger
|
||||
);
|
||||
startupSignal.throwIfAborted();
|
||||
|
||||
const { service } = await configureSystemProxyAsync({
|
||||
env,
|
||||
logger,
|
||||
port: LOCAL_EGRESS_PROXY_PORT,
|
||||
signal: startupSignal,
|
||||
});
|
||||
startupSignal.throwIfAborted();
|
||||
await writeLocalEgressHandoffAsync({
|
||||
url: tunnel.url,
|
||||
token: credentials.password,
|
||||
fingerprint: started.fingerprint,
|
||||
port: LOCAL_EGRESS_PROXY_PORT,
|
||||
});
|
||||
startupSignal.throwIfAborted();
|
||||
logger.info(
|
||||
`Local egress is configured on network service "${service}". HTTP(S) and WebSocket ` +
|
||||
'requests that honor the system proxy (WebKit, URLSession and other CFNetwork clients) ' +
|
||||
'fail until the EAS CLI egress client connects, then exit from that machine. 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();
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
throw error;
|
||||
} finally {
|
||||
finishSetup();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import { graphql } from 'gql.tada';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
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 }) => {
|
||||
const sandboxToken = ctx.env.__EAS_SANDBOX_MCP_TOKEN;
|
||||
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,
|
||||
});
|
||||
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 },
|
||||
signal
|
||||
? {
|
||||
fetch: (input, init) =>
|
||||
fetch(input, {
|
||||
...init,
|
||||
signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal,
|
||||
}),
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
.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,12 +6,15 @@ import {
|
||||
} from '@expo/steps';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
@@ -37,7 +40,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
@@ -61,10 +64,15 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
logger.info(`Preview URL: ${webPreview.previewUrl}`);
|
||||
|
||||
try {
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: { previewUrl: webPreview.previewUrl },
|
||||
remoteConfig: {
|
||||
previewUrl: webPreview.previewUrl,
|
||||
...(webPreview.previewToken ? { previewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -78,6 +86,6 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
} finally {
|
||||
await webPreview.stopAsync();
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
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 { spawnDetached } from '../remoteDeviceRunSession';
|
||||
|
||||
import {
|
||||
CHISEL_VERSION,
|
||||
LOCAL_EGRESS_NO_PROXY,
|
||||
LOCAL_EGRESS_PROXY_PORT,
|
||||
buildEgressRemoteConfigFields,
|
||||
buildLocalEgressSimulatorEnvironment,
|
||||
buildNetworksetupProxyArgs,
|
||||
collectSimulatorProcessIds,
|
||||
configureSimulatorProxyEnvironmentAsync,
|
||||
createChiselAuthfileContents,
|
||||
getChiselAssetName,
|
||||
getChiselDownloadUrl,
|
||||
parseChiselFingerprint,
|
||||
parseDefaultRouteInterface,
|
||||
parseDirectSimulatorConnections,
|
||||
parseExitIpResponse,
|
||||
parseNetworkServiceNameForDevice,
|
||||
readLocalEgressHandoffAsync,
|
||||
registerLocalEgressResources,
|
||||
startChiselServerAsync,
|
||||
stopLocalEgressResourcesAsync,
|
||||
writeLocalEgressHandoffAsync,
|
||||
} from '../localEgress';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../remoteDeviceRunSession', () => ({ spawnDetached: jest.fn() }));
|
||||
|
||||
describe(getChiselAssetName, () => {
|
||||
it.each([
|
||||
['darwin', 'arm64', `chisel_${CHISEL_VERSION}_darwin_arm64.gz`],
|
||||
['darwin', 'x64', `chisel_${CHISEL_VERSION}_darwin_amd64.gz`],
|
||||
['linux', 'x64', `chisel_${CHISEL_VERSION}_linux_amd64.gz`],
|
||||
['linux', 'arm64', `chisel_${CHISEL_VERSION}_linux_arm64.gz`],
|
||||
] as const)('maps %s/%s to %s', (platform, arch, expected) => {
|
||||
expect(getChiselAssetName({ platform, arch })).toBe(expected);
|
||||
});
|
||||
|
||||
it('rejects platforms without a pinned binary', () => {
|
||||
expect(() => getChiselAssetName({ platform: 'win32', arch: 'x64' })).toThrow(
|
||||
'Local egress is not supported on win32/x64'
|
||||
);
|
||||
expect(() => getChiselAssetName({ platform: 'linux', arch: 'ia32' })).toThrow(
|
||||
'Local egress is not supported on linux/ia32'
|
||||
);
|
||||
});
|
||||
|
||||
it('downloads from the pinned GitHub release', () => {
|
||||
expect(getChiselDownloadUrl(`chisel_${CHISEL_VERSION}_darwin_arm64.gz`)).toBe(
|
||||
`https://github.com/jpillora/chisel/releases/download/v${CHISEL_VERSION}/chisel_${CHISEL_VERSION}_darwin_arm64.gz`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(createChiselAuthfileContents, () => {
|
||||
it('allows reverse remotes on the loopback proxy port and unprivileged loopback ports only', () => {
|
||||
const contents = createChiselAuthfileContents({ user: 'eas', password: 'pw', port: 8899 });
|
||||
const parsed = JSON.parse(contents) as Record<string, string[]>;
|
||||
expect(parsed['eas:pw']).toHaveLength(2);
|
||||
expect(parsed['eas:pw'][0]).toBe('^R:127\\.0\\.0\\.1:8899$');
|
||||
|
||||
const patterns = parsed['eas:pw'].map(source => new RegExp(source));
|
||||
const allowed = (remote: string): boolean => patterns.some(pattern => pattern.test(remote));
|
||||
expect(allowed('R:127.0.0.1:8899')).toBe(true);
|
||||
expect(allowed('R:127.0.0.1:1024')).toBe(true);
|
||||
expect(allowed('R:127.0.0.1:3000')).toBe(true);
|
||||
expect(allowed('R:127.0.0.1:8081')).toBe(true);
|
||||
expect(allowed('R:127.0.0.1:65535')).toBe(true);
|
||||
// Privileged and out-of-range ports, other interfaces, and forward remotes stay denied.
|
||||
expect(allowed('R:127.0.0.1:1023')).toBe(false);
|
||||
expect(allowed('R:127.0.0.1:80')).toBe(false);
|
||||
expect(allowed('R:127.0.0.1:0')).toBe(false);
|
||||
expect(allowed('R:127.0.0.1:65536')).toBe(false);
|
||||
expect(allowed('R:127.0.0.1:88990')).toBe(false);
|
||||
expect(allowed('R:0.0.0.0:8899')).toBe(false);
|
||||
expect(allowed('R:0.0.0.0:3000')).toBe(false);
|
||||
expect(allowed('R:[::1]:3000')).toBe(false);
|
||||
expect(allowed('R:127x0x0x1:8899')).toBe(false);
|
||||
expect(allowed('R:socks')).toBe(false);
|
||||
expect(allowed('example.com:443')).toBe(false);
|
||||
expect(allowed('127.0.0.1:3000')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseChiselFingerprint, () => {
|
||||
it('extracts the fingerprint chisel prints at startup', () => {
|
||||
const output =
|
||||
'2026/09/07 22:00:00 server: Fingerprint eZm5d2ZQ+Zb2aS0lJm7A5GkWm9G9ZqnPz2W0i2Y6Q1c=\n' +
|
||||
'2026/09/07 22:00:00 server: Reverse tunnelling enabled\n' +
|
||||
'2026/09/07 22:00:00 server: Listening on http://127.0.0.1:52001\n';
|
||||
expect(parseChiselFingerprint(output)).toBe('eZm5d2ZQ+Zb2aS0lJm7A5GkWm9G9ZqnPz2W0i2Y6Q1c=');
|
||||
});
|
||||
|
||||
it('returns null before the fingerprint line appears', () => {
|
||||
expect(parseChiselFingerprint('')).toBeNull();
|
||||
expect(parseChiselFingerprint('2026/09/07 22:00:00 server: starting')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseDefaultRouteInterface, () => {
|
||||
it('reads the interface from `route -n get default`', () => {
|
||||
const output =
|
||||
' route to: default\n' +
|
||||
'destination: default\n' +
|
||||
' mask: default\n' +
|
||||
' gateway: 172.20.10.1\n' +
|
||||
' interface: en0\n' +
|
||||
' flags: <UP,GATEWAY,DONE,STATIC,PRCLONING,GLOBAL>\n';
|
||||
expect(parseDefaultRouteInterface(output)).toBe('en0');
|
||||
});
|
||||
|
||||
it('returns null when there is no default route', () => {
|
||||
expect(
|
||||
parseDefaultRouteInterface('route: writing to routing socket: not in table\n')
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseNetworkServiceNameForDevice, () => {
|
||||
const listOutput =
|
||||
'An asterisk (*) denotes that a network service is disabled.\n' +
|
||||
'(1) Thunderbolt Ethernet Slot 0\n' +
|
||||
'(Hardware Port: Thunderbolt Ethernet Slot 0, Device: en8)\n' +
|
||||
'\n' +
|
||||
'(2) Thunderbolt Bridge\n' +
|
||||
'(Hardware Port: Thunderbolt Bridge, Device: bridge0)\n' +
|
||||
'\n' +
|
||||
'(3) Wi-Fi\n' +
|
||||
'(Hardware Port: Wi-Fi, Device: en0)\n' +
|
||||
'\n' +
|
||||
'(4) iPhone USB\n' +
|
||||
'(Hardware Port: iPhone USB, Device: en9)\n';
|
||||
|
||||
it('returns the service whose device matches', () => {
|
||||
expect(parseNetworkServiceNameForDevice(listOutput, 'en0')).toBe('Wi-Fi');
|
||||
expect(parseNetworkServiceNameForDevice(listOutput, 'en8')).toBe('Thunderbolt Ethernet Slot 0');
|
||||
});
|
||||
|
||||
it('returns null for an unknown device', () => {
|
||||
expect(parseNetworkServiceNameForDevice(listOutput, 'utun3')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildNetworksetupProxyArgs, () => {
|
||||
it('sets both the HTTP and HTTPS proxy to the loopback egress port', () => {
|
||||
expect(
|
||||
buildNetworksetupProxyArgs({ service: 'Ethernet', host: '127.0.0.1', port: 8899 })
|
||||
).toEqual([
|
||||
['-setwebproxy', 'Ethernet', '127.0.0.1', '8899'],
|
||||
['-setsecurewebproxy', 'Ethernet', '127.0.0.1', '8899'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(collectSimulatorProcessIds, () => {
|
||||
const runtimeRoot =
|
||||
'/Library/Developer/CoreSimulator/Volumes/iOS_23F77/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.5.simruntime/Contents/Resources/RuntimeRoot';
|
||||
const psOutput =
|
||||
' 1 0 /sbin/launchd\n' +
|
||||
' 501 1 /usr/local/bin/node\n' +
|
||||
` 600 1 ${runtimeRoot}/sbin/launchd_sim\n` +
|
||||
' 610 600 /Users/expo/Library/Developer/CoreSimulator/Devices/ABC/data/Containers/Bundle/Application/DEF/App.app/App\n' +
|
||||
` 611 600 ${runtimeRoot}/System/Library/ExtensionKit/Extensions/NetworkingExtension.appex/com.apple.WebKit.Networking\n` +
|
||||
' 620 610 /usr/bin/helper\n' +
|
||||
' 700 1 /usr/sbin/lsof\n';
|
||||
|
||||
it('returns every descendant of launchd_sim and nothing else', () => {
|
||||
expect(collectSimulatorProcessIds(psOutput).sort((a, b) => a - b)).toEqual([610, 611, 620]);
|
||||
});
|
||||
|
||||
it('returns nothing when no simulator is running', () => {
|
||||
expect(
|
||||
collectSimulatorProcessIds(' 1 0 /sbin/launchd\n 501 1 /usr/local/bin/node\n')
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseDirectSimulatorConnections, () => {
|
||||
const lsofOutput = [
|
||||
'p610',
|
||||
'cApp',
|
||||
'f10',
|
||||
'PTCP',
|
||||
'n127.0.0.1:52344->127.0.0.1:8899',
|
||||
'TST=ESTABLISHED',
|
||||
'f11',
|
||||
'PTCP',
|
||||
'n192.168.64.2:52345->93.184.216.34:443',
|
||||
'TST=ESTABLISHED',
|
||||
'f12',
|
||||
'PTCP',
|
||||
'n192.168.64.2:52346->104.16.0.1:443',
|
||||
'TST=SYN_SENT',
|
||||
'f13',
|
||||
'PTCP',
|
||||
'n*:8080',
|
||||
'TST=LISTEN',
|
||||
'f14',
|
||||
'PUDP',
|
||||
'n*:5353',
|
||||
'f15',
|
||||
'PUDP',
|
||||
'n192.168.64.2:60000->1.1.1.1:443',
|
||||
'f16',
|
||||
'PTCP',
|
||||
'n[::1]:52347->[::1]:8899',
|
||||
'TST=ESTABLISHED',
|
||||
'f17',
|
||||
'PTCP',
|
||||
'n[fd00::2]:52348->[2606:4700:4700::1111]:443',
|
||||
'TST=ESTABLISHED',
|
||||
'p700',
|
||||
'cnode',
|
||||
'f20',
|
||||
'PTCP',
|
||||
'n192.168.64.2:52349->93.184.216.34:443',
|
||||
'TST=ESTABLISHED',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
it('reports simulator connections to non-loopback peers only', () => {
|
||||
expect(parseDirectSimulatorConnections(lsofOutput, new Set([610]))).toEqual([
|
||||
{ pid: 610, command: 'App', protocol: 'TCP', remote: '93.184.216.34:443' },
|
||||
{ pid: 610, command: 'App', protocol: 'TCP', remote: '104.16.0.1:443' },
|
||||
{ pid: 610, command: 'App', protocol: 'UDP', remote: '1.1.1.1:443' },
|
||||
{ pid: 610, command: 'App', protocol: 'TCP', remote: '[2606:4700:4700::1111]:443' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores processes outside the simulator', () => {
|
||||
expect(parseDirectSimulatorConnections(lsofOutput, new Set([700]))).toEqual([
|
||||
{ pid: 700, command: 'node', protocol: 'TCP', remote: '93.184.216.34:443' },
|
||||
]);
|
||||
expect(parseDirectSimulatorConnections(lsofOutput, new Set([999]))).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns nothing for an empty listing', () => {
|
||||
expect(parseDirectSimulatorConnections('', new Set([610]))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildEgressRemoteConfigFields, () => {
|
||||
it('returns nothing without a handoff', () => {
|
||||
expect(buildEgressRemoteConfigFields(null)).toEqual({});
|
||||
});
|
||||
|
||||
it('maps the handoff to the remoteConfig field names', () => {
|
||||
expect(
|
||||
buildEgressRemoteConfigFields({
|
||||
url: 'https://egress-abc.eas-simulator.ngrok.dev',
|
||||
token: 'pw',
|
||||
fingerprint: 'fp=',
|
||||
port: LOCAL_EGRESS_PROXY_PORT,
|
||||
})
|
||||
).toEqual({
|
||||
egressUrl: 'https://egress-abc.eas-simulator.ngrok.dev',
|
||||
egressToken: 'pw',
|
||||
egressFingerprint: 'fp=',
|
||||
egressPort: LOCAL_EGRESS_PROXY_PORT,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseExitIpResponse, () => {
|
||||
it('reads the ip field', () => {
|
||||
expect(parseExitIpResponse('{"ip":"64.114.211.44"}')).toBe('64.114.211.44');
|
||||
});
|
||||
|
||||
it('rejects responses without an ip', () => {
|
||||
expect(() => parseExitIpResponse('{"error":"rate limited"}')).toThrow(
|
||||
'Unexpected exit IP response'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('local egress handoff', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'local-egress-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('round-trips through the handoff file', async () => {
|
||||
const handoffPath = path.join(tempDir, 'handoff.json');
|
||||
const handoff = {
|
||||
url: 'https://egress-abc.eas-simulator.ngrok.dev',
|
||||
token: 'pw',
|
||||
fingerprint: 'fp=',
|
||||
port: 8899,
|
||||
};
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
await expect(readLocalEgressHandoffAsync(handoffPath)).resolves.toEqual(handoff);
|
||||
});
|
||||
|
||||
it('returns null when no egress step ran', async () => {
|
||||
await expect(
|
||||
readLocalEgressHandoffAsync(path.join(tempDir, 'missing.json'))
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a malformed handoff', async () => {
|
||||
const handoffPath = path.join(tempDir, 'handoff.json');
|
||||
await fs.promises.writeFile(handoffPath, JSON.stringify({ url: 'x' }), 'utf8');
|
||||
await expect(readLocalEgressHandoffAsync(handoffPath)).rejects.toThrow('malformed');
|
||||
});
|
||||
});
|
||||
|
||||
describe(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',
|
||||
controlPort: 52001,
|
||||
authfilePath: '/tmp/authfile.json',
|
||||
env: { PATH: '/bin', AUTH: 'unexpected:password' },
|
||||
};
|
||||
let output: string;
|
||||
let stopAsync: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
output = 'server: Fingerprint pinned-key=\n';
|
||||
stopAsync = jest.fn().mockResolvedValue(undefined);
|
||||
jest.spyOn(process, 'kill').mockReturnValue(true);
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 12345,
|
||||
getOutput: () => output,
|
||||
stopAsync,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('waits for the child to bind its exact port and clears inherited AUTH', async () => {
|
||||
let resolved = false;
|
||||
const started = startChiselServerAsync(options).then(result => {
|
||||
resolved = true;
|
||||
return result;
|
||||
});
|
||||
output += 'server: Listening on http://127.0.0.1:520010\n';
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
expect(resolved).toBe(false);
|
||||
output += 'server: Listening on http://127.0.0.1:52001\n';
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
await expect(started).resolves.toMatchObject({ fingerprint: 'pinned-key=' });
|
||||
expect(spawnDetached).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ env: { PATH: '/bin', AUTH: '' } })
|
||||
);
|
||||
expect(options.env.AUTH).toBe('unexpected:password');
|
||||
expect(stopAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects and stops a child that prints its fingerprint then fails to bind', async () => {
|
||||
const started = startChiselServerAsync(options);
|
||||
const rejected = expect(started).rejects.toThrow('address already in use');
|
||||
output += 'listen tcp 127.0.0.1:52001: bind: address already in use\n';
|
||||
jest.mocked(process.kill).mockImplementation(() => {
|
||||
throw new Error('ESRCH');
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
await rejected;
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops the child when startup is cancelled', async () => {
|
||||
const controller = new AbortController();
|
||||
const started = startChiselServerAsync({ ...options, signal: controller.signal });
|
||||
const rejected = expect(started).rejects.toThrow('cancelled');
|
||||
controller.abort(new Error('cancelled'));
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
await rejected;
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('local egress resource lifetime', () => {
|
||||
const logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
|
||||
it('aborts the lifetime and awaits the same cleanup for concurrent callers', async () => {
|
||||
let finish!: () => void;
|
||||
const cleanup = jest.fn(
|
||||
() =>
|
||||
new Promise<void>(resolve => {
|
||||
finish = resolve;
|
||||
})
|
||||
);
|
||||
const signal = registerLocalEgressResources(cleanup);
|
||||
const first = stopLocalEgressResourcesAsync(logger);
|
||||
const second = stopLocalEgressResourcesAsync(logger);
|
||||
expect(signal.aborted).toBe(true);
|
||||
await Promise.resolve();
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
let complete = false;
|
||||
void second.then(() => {
|
||||
complete = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(complete).toBe(false);
|
||||
finish();
|
||||
await Promise.all([first, second]);
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports cleanup failure without masking the job result', async () => {
|
||||
const error = new Error('cleanup failed');
|
||||
registerLocalEgressResources(async () => {
|
||||
throw error;
|
||||
});
|
||||
await expect(stopLocalEgressResourcesAsync(logger)).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: error },
|
||||
'Could not stop a local egress resource.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
|
||||
import { sleepAsync } from '../../../utils/retry';
|
||||
import {
|
||||
createExpoDeviceHubArgs,
|
||||
@@ -33,6 +34,11 @@ jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../../utils/retry', () => ({ sleepAsync: jest.fn() }));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
// Spyable so a test can stand in for the serve-sim state directory, which a local serve-sim owns.
|
||||
jest.mock('../serveSimMetricsRecorder', () => {
|
||||
const actual = jest.requireActual('../serveSimMetricsRecorder');
|
||||
return { ...actual, readServeSimServersAsync: jest.fn(actual.readServeSimServersAsync) };
|
||||
});
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return {
|
||||
@@ -130,6 +136,7 @@ describe(createServeSimArgs, () => {
|
||||
'4321',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--require-token',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
@@ -329,6 +336,9 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
jest.mocked(spawn).mockReset();
|
||||
jest.mocked(ngrok.forward).mockReset();
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid: 'device-id', url: 'http://127.0.0.1:1', token: 'tok-1' }]);
|
||||
|
||||
const spawnPromise = Object.assign(Promise.resolve(undefined), {
|
||||
child: {
|
||||
@@ -417,6 +427,63 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('carries the serve-sim session token for Darwin', async () => {
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://preview.example.test',
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(preview.previewToken).toBe('tok-1');
|
||||
expect(preview.previewUrl).toBe('https://preview.example.test');
|
||||
});
|
||||
|
||||
// serve-sim is always launched with --require-token, so a missing token means it is running
|
||||
// ungated on a public tunnel. Failing beats handing out a preview that is dead or unprotected.
|
||||
it('fails for Darwin when serve-sim reports no token', async () => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid: 'device-id', url: 'http://127.0.0.1:1' }]);
|
||||
|
||||
await expect(
|
||||
startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
})
|
||||
).rejects.toThrow(/wrote no session token/);
|
||||
});
|
||||
|
||||
// expo-device-hub mints no token, so the Android preview must not require one.
|
||||
it('starts for Linux without a token, and does not gate expo-device-hub', async () => {
|
||||
jest.mocked(readServeSimServersAsync).mockResolvedValue([]);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://android-preview.example.test',
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(preview.previewToken).toBeUndefined();
|
||||
const [, args] = jest.mocked(spawn).mock.calls[0];
|
||||
expect(args).not.toContain('--require-token');
|
||||
});
|
||||
|
||||
it('starts serve-sim for Darwin with its metrics policy and cleans up the preview resources', async () => {
|
||||
const packageVersion = '4.5.6';
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { 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');
|
||||
|
||||
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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
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('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,
|
||||
});
|
||||
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()));
|
||||
});
|
||||
});
|
||||
@@ -101,6 +101,14 @@ describe(readServeSimServersAsync, () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes the session token when the state file carries one', async () => {
|
||||
await writeServerStateAsync('E', { device: 'E', url: 'http://127.0.0.1:5', token: 'tok-abc' });
|
||||
|
||||
expect(await readServeSimServersAsync(stateDir)).toEqual([
|
||||
{ udid: 'E', url: 'http://127.0.0.1:5', token: 'tok-abc' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns [] when the state dir does not exist', async () => {
|
||||
expect(await readServeSimServersAsync(path.join(workDir, 'nope'))).toEqual([]);
|
||||
});
|
||||
@@ -120,6 +128,20 @@ describe(streamServeSimMetricsToFileAsync, () => {
|
||||
expect(String(jest.mocked(fetch).mock.calls[0][0])).toBe('https://sim.example/metrics');
|
||||
});
|
||||
|
||||
it('sends the session token as a bearer header when given one', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(sseResponse());
|
||||
await streamServeSimMetricsToFileAsync({
|
||||
serveSimUrl: 'https://sim.example',
|
||||
serveSimToken: 'tok-abc',
|
||||
filePath: path.join(workDir, 'auth.ndjson'),
|
||||
signal: new AbortController().signal,
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
expect(jest.mocked(fetch).mock.calls[0][1]?.headers).toMatchObject({
|
||||
Authorization: 'Bearer tok-abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('writes nothing on a non-ok response', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(new Response('nope', { status: 502 }));
|
||||
const filePath = path.join(workDir, 'empty.ndjson');
|
||||
|
||||
@@ -0,0 +1,801 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
|
||||
import { type DetachedProcessHandle, spawnDetached } from './remoteDeviceRunSession';
|
||||
|
||||
/**
|
||||
* Local egress provides an HTTP(S) proxy through the machine running the EAS CLI.
|
||||
* Requests that use this proxy exit through that machine's network.
|
||||
*
|
||||
* Worker side: a chisel server in reverse mode listens on loopback and is exposed
|
||||
* through the session's ngrok domain. When the CLI's egress client connects, chisel
|
||||
* opens LOCAL_EGRESS_PROXY_PORT on this host and forwards every connection to the
|
||||
* HTTP proxy the CLI runs. The macOS system proxy points at that port. Until the
|
||||
* client connects, nothing listens on the port and proxied requests fail.
|
||||
*
|
||||
* Contract: HTTP(S) and WebSocket requests that honor the system proxy (WebKit,
|
||||
* URLSession and other CFNetwork clients) exit from the egress client's network
|
||||
* and fail while the client is disconnected. Requests from libraries that bypass
|
||||
* the system proxy are not covered and exit from this host. Nothing here enforces
|
||||
* routing; the session monitor reports such direct connections instead.
|
||||
*/
|
||||
|
||||
export const LOCAL_EGRESS_PROXY_HOST = '127.0.0.1';
|
||||
export const LOCAL_EGRESS_PROXY_PORT = 8899;
|
||||
export const LOCAL_EGRESS_USERNAME = 'eas';
|
||||
export const LOCAL_EGRESS_HANDOFF_PATH = path.join(os.tmpdir(), 'eas-simulator-local-egress.json');
|
||||
|
||||
export const CHISEL_VERSION = '1.12.0';
|
||||
// sha256 of the release .gz assets, cross-checked against chisel_1.12.0_checksums.txt.
|
||||
const CHISEL_SHA256_BY_ASSET: Record<string, string> = {
|
||||
'chisel_1.12.0_darwin_arm64.gz':
|
||||
'707a4b932eea214765146504a0df246cefc415b4297af65a80dd67cf69ba85a9',
|
||||
'chisel_1.12.0_darwin_amd64.gz':
|
||||
'4aeae36c867f11c8e8c3f2b913a0e063ea3c6d29e1c14a52ed2e6eef8cfc4395',
|
||||
'chisel_1.12.0_linux_amd64.gz':
|
||||
'f3f180f1d93aa72cce4e6386f98cc06569a0146fbd65eb4423cf83e6434bcfe6',
|
||||
'chisel_1.12.0_linux_arm64.gz':
|
||||
'2ec6152cd2c74fe0146d4d79e4e7aa174521368c56e433d55e023a92ea404ec3',
|
||||
};
|
||||
|
||||
const CHISEL_STARTUP_TIMEOUT_MS = 15_000;
|
||||
const EGRESS_MONITOR_INTERVAL_MS = 2_000;
|
||||
const EGRESS_ESCAPE_SCAN_INTERVAL_MS = 5_000;
|
||||
const EGRESS_ESCAPE_LOG_LIMIT = 50;
|
||||
|
||||
export type LocalEgressHandoff = {
|
||||
/** Public URL of the reverse tunnel server, reachable through ngrok. */
|
||||
url: string;
|
||||
/** Secret the client presents to the tunnel server, paired with LOCAL_EGRESS_USERNAME. */
|
||||
token: string;
|
||||
/** Fingerprint of the tunnel server key, for the client to pin. */
|
||||
fingerprint: string;
|
||||
/** Loopback port on this host that the client must serve. */
|
||||
port: number;
|
||||
};
|
||||
|
||||
export function getChiselAssetName({
|
||||
platform,
|
||||
arch,
|
||||
}: {
|
||||
platform: NodeJS.Platform;
|
||||
arch: string;
|
||||
}): string {
|
||||
const os = platform === 'darwin' ? 'darwin' : platform === 'linux' ? 'linux' : null;
|
||||
const cpu = arch === 'arm64' ? 'arm64' : arch === 'x64' ? 'amd64' : null;
|
||||
if (!os || !cpu) {
|
||||
throw new SystemError(
|
||||
`Local egress is not supported on ${platform}/${arch}. The reverse tunnel binary is available for macOS and Linux on arm64 and x64.`
|
||||
);
|
||||
}
|
||||
return `chisel_${CHISEL_VERSION}_${os}_${cpu}.gz`;
|
||||
}
|
||||
|
||||
export function getChiselDownloadUrl(assetName: string): string {
|
||||
return `https://github.com/jpillora/chisel/releases/download/v${CHISEL_VERSION}/${assetName}`;
|
||||
}
|
||||
|
||||
async function sha256FileAsync(filePath: string): Promise<string> {
|
||||
const hash = createHash('sha256');
|
||||
await pipeline(fs.createReadStream(filePath), hash);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
export async function downloadChiselAsync({
|
||||
destinationDir,
|
||||
logger,
|
||||
platform = process.platform,
|
||||
arch = process.arch,
|
||||
}: {
|
||||
destinationDir: string;
|
||||
logger: bunyan;
|
||||
platform?: NodeJS.Platform;
|
||||
arch?: string;
|
||||
}): Promise<string> {
|
||||
const assetName = getChiselAssetName({ platform, arch });
|
||||
const expectedSha256 = CHISEL_SHA256_BY_ASSET[assetName];
|
||||
if (!expectedSha256) {
|
||||
throw new SystemError(`No pinned checksum for ${assetName}.`);
|
||||
}
|
||||
const url = getChiselDownloadUrl(assetName);
|
||||
const archivePath = path.join(destinationDir, assetName);
|
||||
logger.info(`Downloading ${url}.`);
|
||||
await downloadFile(url, archivePath, { retry: 3, timeout: 60_000 });
|
||||
|
||||
const actualSha256 = await sha256FileAsync(archivePath);
|
||||
if (actualSha256 !== expectedSha256) {
|
||||
throw new SystemError(
|
||||
`Checksum mismatch for ${assetName}: expected ${expectedSha256}, got ${actualSha256}. ` +
|
||||
'The download may be corrupted or tampered with; the session was not started.'
|
||||
);
|
||||
}
|
||||
|
||||
const binaryPath = path.join(destinationDir, 'chisel');
|
||||
await pipeline(
|
||||
fs.createReadStream(archivePath),
|
||||
zlib.createGunzip(),
|
||||
fs.createWriteStream(binaryPath)
|
||||
);
|
||||
await fs.promises.chmod(binaryPath, 0o755);
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
export function generateEgressCredentials(): { user: string; password: string } {
|
||||
return { user: LOCAL_EGRESS_USERNAME, password: randomBytes(24).toString('base64url') };
|
||||
}
|
||||
|
||||
// Matches 1024-65535: every unprivileged TCP port.
|
||||
const UNPRIVILEGED_PORT_PATTERN =
|
||||
'(102[4-9]|10[3-9][0-9]|1[1-9][0-9]{2}|[2-9][0-9]{3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])';
|
||||
|
||||
/**
|
||||
* chisel authfile: one user, allowed to open reverse remotes on loopback only.
|
||||
* The proxy port carries proxied requests. Any unprivileged loopback port may be
|
||||
* forwarded too: the EAS CLI opens one per `--egress-allow localhost:<port>`
|
||||
* entry so `127.0.0.1:<port>` inside the simulator reaches that port on the
|
||||
* developer's machine, like `adb reverse`. iOS never sends loopback-literal
|
||||
* requests to the system proxy, so the proxy alone cannot serve them.
|
||||
* Reverse remotes are matched as `R:<interface>:<port>`.
|
||||
*/
|
||||
export function createChiselAuthfileContents({
|
||||
user,
|
||||
password,
|
||||
port,
|
||||
}: {
|
||||
user: string;
|
||||
password: string;
|
||||
port: number;
|
||||
}): string {
|
||||
const escapedHost = LOCAL_EGRESS_PROXY_HOST.replace(/\./g, '\\.');
|
||||
return JSON.stringify({
|
||||
[`${user}:${password}`]: [
|
||||
`^R:${escapedHost}:${port}$`,
|
||||
`^R:${escapedHost}:${UNPRIVILEGED_PORT_PATTERN}$`,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function parseChiselFingerprint(output: string): string | null {
|
||||
const match = /Fingerprint\s+(\S+)/.exec(output);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startChiselServerAsync({
|
||||
chiselPath,
|
||||
controlPort,
|
||||
authfilePath,
|
||||
env,
|
||||
signal,
|
||||
}: {
|
||||
chiselPath: string;
|
||||
controlPort: number;
|
||||
authfilePath: string;
|
||||
env: BuildStepEnv;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ process: DetachedProcessHandle; fingerprint: string }> {
|
||||
const server = spawnDetached({
|
||||
command: chiselPath,
|
||||
args: [
|
||||
'server',
|
||||
'--host',
|
||||
LOCAL_EGRESS_PROXY_HOST,
|
||||
'--port',
|
||||
String(controlPort),
|
||||
'--reverse',
|
||||
'--authfile',
|
||||
authfilePath,
|
||||
],
|
||||
// AUTH adds an unrestricted user even when --authfile is supplied.
|
||||
env: { ...env, AUTH: '' },
|
||||
});
|
||||
|
||||
try {
|
||||
const deadline = Date.now() + CHISEL_STARTUP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
signal?.throwIfAborted();
|
||||
if (server.pid === undefined || !isProcessRunning(server.pid)) {
|
||||
break;
|
||||
}
|
||||
const output = server.getOutput();
|
||||
const fingerprint = parseChiselFingerprint(output);
|
||||
// Chisel prints the fingerprint before binding. This line comes from the
|
||||
// child only after its listener succeeds, unlike a probe of a reused port.
|
||||
const listening = output
|
||||
.split('\n')
|
||||
.some(line =>
|
||||
line
|
||||
.trimEnd()
|
||||
.endsWith(`server: Listening on http://${LOCAL_EGRESS_PROXY_HOST}:${controlPort}`)
|
||||
);
|
||||
if (fingerprint && listening) {
|
||||
return { process: server, fingerprint };
|
||||
}
|
||||
await sleepAsync(250);
|
||||
}
|
||||
throw new SystemError(
|
||||
`The reverse tunnel server did not start within ${CHISEL_STARTUP_TIMEOUT_MS / 1000}s. Output:\n${
|
||||
server.getOutput() || '<empty>'
|
||||
}`
|
||||
);
|
||||
} catch (error) {
|
||||
await server.stopAsync();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDefaultRouteInterface(routeOutput: string): string | null {
|
||||
const match = /^\s*interface:\s*(\S+)/m.exec(routeOutput);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `networksetup -listnetworkserviceorder` prints each service as two lines:
|
||||
* "(1) Wi-Fi" followed by "(Hardware Port: Wi-Fi, Device: en0)". Return the
|
||||
* service name whose device matches.
|
||||
*/
|
||||
export function parseNetworkServiceNameForDevice(
|
||||
listOutput: string,
|
||||
device: string
|
||||
): string | null {
|
||||
const lines = listOutput.split('\n').map(line => line.trim());
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const hardwarePortMatch = /^\(Hardware Port: (.+), Device: (\S+)\)$/.exec(lines[i]);
|
||||
if (!hardwarePortMatch || hardwarePortMatch[2] !== device) {
|
||||
continue;
|
||||
}
|
||||
const serviceLineMatch = /^\(\d+\) (.+)$/.exec(lines[i - 1] ?? '');
|
||||
return serviceLineMatch?.[1] ?? hardwarePortMatch[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolveActiveNetworkServiceNameAsync({
|
||||
env,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
}): Promise<string> {
|
||||
const routeResult = await spawn('route', ['-n', 'get', 'default'], { env, stdio: 'pipe' });
|
||||
const device = parseDefaultRouteInterface(routeResult.stdout);
|
||||
if (!device) {
|
||||
throw new SystemError(
|
||||
'Could not determine the default network interface of the device host, so the system proxy ' +
|
||||
'cannot be configured. Output of `route -n get default`:\n' +
|
||||
(routeResult.stdout || '<empty>')
|
||||
);
|
||||
}
|
||||
const listResult = await spawn('networksetup', ['-listnetworkserviceorder'], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const service = parseNetworkServiceNameForDevice(listResult.stdout, device);
|
||||
if (!service) {
|
||||
throw new SystemError(
|
||||
`Could not find the network service for interface ${device}, so the system proxy cannot be ` +
|
||||
'configured. Output of `networksetup -listnetworkserviceorder`:\n' +
|
||||
(listResult.stdout || '<empty>')
|
||||
);
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
export function buildNetworksetupProxyArgs({
|
||||
service,
|
||||
host,
|
||||
port,
|
||||
}: {
|
||||
service: string;
|
||||
host: string;
|
||||
port: number;
|
||||
}): string[][] {
|
||||
return [
|
||||
['-setwebproxy', service, host, String(port)],
|
||||
['-setsecurewebproxy', service, host, String(port)],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the macOS system HTTP and HTTPS proxy at the loopback egress port. The
|
||||
* simulator reads these settings when it boots, so this must run before
|
||||
* `start_ios_simulator`. Existing bypass and automatic proxy settings are left
|
||||
* unchanged. Requests that bypass this proxy can leave from this host's address.
|
||||
*/
|
||||
export async function configureSystemProxyAsync({
|
||||
env,
|
||||
logger,
|
||||
port,
|
||||
signal,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
port: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ service: string }> {
|
||||
if (process.env.ENVIRONMENT === 'development') {
|
||||
logger.info('Job running outside of EAS, not changing the system proxy.');
|
||||
return { service: '<development>' };
|
||||
}
|
||||
const service = await resolveActiveNetworkServiceNameAsync({ env });
|
||||
for (const args of buildNetworksetupProxyArgs({ service, host: LOCAL_EGRESS_PROXY_HOST, port })) {
|
||||
signal?.throwIfAborted();
|
||||
await spawn('networksetup', args, { env, logger, signal });
|
||||
}
|
||||
logger.info(`System proxy for "${service}" set to ${LOCAL_EGRESS_PROXY_HOST}:${port}.`);
|
||||
return { service };
|
||||
}
|
||||
|
||||
export 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
|
||||
): Promise<void> {
|
||||
await fs.promises.writeFile(handoffPath, JSON.stringify(handoff), {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
export async function readLocalEgressHandoffAsync(
|
||||
handoffPath: string = LOCAL_EGRESS_HANDOFF_PATH
|
||||
): Promise<LocalEgressHandoff | null> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.promises.readFile(handoffPath, 'utf8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<LocalEgressHandoff>;
|
||||
if (
|
||||
typeof parsed.url !== 'string' ||
|
||||
typeof parsed.token !== 'string' ||
|
||||
typeof parsed.fingerprint !== 'string' ||
|
||||
typeof parsed.port !== 'number'
|
||||
) {
|
||||
throw new SystemError(`Local egress handoff at ${handoffPath} is malformed.`);
|
||||
}
|
||||
return {
|
||||
url: parsed.url,
|
||||
token: parsed.token,
|
||||
fingerprint: parsed.fingerprint,
|
||||
port: parsed.port,
|
||||
};
|
||||
}
|
||||
|
||||
/** remoteConfig fields the CLI needs to start the egress client. */
|
||||
export function buildEgressRemoteConfigFields(
|
||||
handoff: LocalEgressHandoff | null
|
||||
): Record<string, string | number> {
|
||||
if (!handoff) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
egressUrl: handoff.url,
|
||||
egressToken: handoff.token,
|
||||
egressFingerprint: handoff.fingerprint,
|
||||
egressPort: handoff.port,
|
||||
};
|
||||
}
|
||||
|
||||
// The egress step returns before the session ends, so the resources it created
|
||||
// are held here and released by the session step or the job's finally block.
|
||||
let activeLocalEgressResources:
|
||||
| { stopAsync: () => Promise<void>; controller: AbortController; stopping?: Promise<void> }
|
||||
| undefined;
|
||||
|
||||
export function registerLocalEgressResources(stopAsync: () => Promise<void>): AbortSignal {
|
||||
if (activeLocalEgressResources) {
|
||||
throw new SystemError('Local egress resources are already registered for this job.');
|
||||
}
|
||||
const controller = new AbortController();
|
||||
activeLocalEgressResources = { stopAsync, controller };
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
export async function stopLocalEgressResourcesAsync(logger: bunyan): Promise<void> {
|
||||
const resources = activeLocalEgressResources;
|
||||
if (!resources) {
|
||||
return;
|
||||
}
|
||||
resources.controller.abort();
|
||||
resources.stopping ??= Promise.resolve()
|
||||
.then(resources.stopAsync)
|
||||
.catch(err => logger.warn({ err }, 'Could not stop a local egress resource.'))
|
||||
.finally(() => {
|
||||
activeLocalEgressResources = undefined;
|
||||
});
|
||||
await resources.stopping;
|
||||
}
|
||||
|
||||
export async function isPortListeningAsync({
|
||||
host,
|
||||
port,
|
||||
timeoutMs = 1_000,
|
||||
}: {
|
||||
host: string;
|
||||
port: number;
|
||||
timeoutMs?: number;
|
||||
}): Promise<boolean> {
|
||||
return await new Promise<boolean>(resolve => {
|
||||
const socket = net.connect({ host, port });
|
||||
const finish = (listening: boolean): void => {
|
||||
socket.destroy();
|
||||
resolve(listening);
|
||||
};
|
||||
socket.setTimeout(timeoutMs, () => finish(false));
|
||||
socket.once('connect', () => finish(true));
|
||||
socket.once('error', () => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
export function parseExitIpResponse(body: string): string {
|
||||
const parsed = JSON.parse(body) as { ip?: unknown };
|
||||
if (typeof parsed.ip !== 'string' || parsed.ip.length === 0) {
|
||||
throw new SystemError(`Unexpected exit IP response: ${body}`);
|
||||
}
|
||||
return parsed.ip;
|
||||
}
|
||||
|
||||
export async function fetchExitIpThroughProxyAsync({
|
||||
port,
|
||||
env,
|
||||
}: {
|
||||
port: number;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<string> {
|
||||
const result = await spawn(
|
||||
'curl',
|
||||
[
|
||||
'-sS',
|
||||
'--max-time',
|
||||
'10',
|
||||
'-x',
|
||||
`http://${LOCAL_EGRESS_PROXY_HOST}:${port}`,
|
||||
'https://api.ipify.org?format=json',
|
||||
],
|
||||
{ env, stdio: 'pipe' }
|
||||
);
|
||||
return parseExitIpResponse(result.stdout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes inside the simulator run on the host as descendants of launchd_sim,
|
||||
* so `ps -axo pid=,ppid=,comm=` ancestry identifies them. Returns every
|
||||
* descendant pid; launchd_sim itself opens no network connections.
|
||||
*/
|
||||
export function collectSimulatorProcessIds(psOutput: string): number[] {
|
||||
const childrenByParent = new Map<number, number[]>();
|
||||
const roots: number[] = [];
|
||||
for (const line of psOutput.split('\n')) {
|
||||
const match = /^\s*(\d+)\s+(\d+)\s+(\S.*)$/.exec(line);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const pid = Number(match[1]);
|
||||
const parentPid = Number(match[2]);
|
||||
const command = match[3].trim();
|
||||
const siblings = childrenByParent.get(parentPid) ?? [];
|
||||
siblings.push(pid);
|
||||
childrenByParent.set(parentPid, siblings);
|
||||
if (path.basename(command) === 'launchd_sim') {
|
||||
roots.push(pid);
|
||||
}
|
||||
}
|
||||
|
||||
const descendants: number[] = [];
|
||||
const seen = new Set<number>(roots);
|
||||
const queue = [...roots];
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
for (const child of childrenByParent.get(queue[i]) ?? []) {
|
||||
if (seen.has(child)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(child);
|
||||
descendants.push(child);
|
||||
queue.push(child);
|
||||
}
|
||||
}
|
||||
return descendants;
|
||||
}
|
||||
|
||||
export type DirectSimulatorConnection = {
|
||||
pid: number;
|
||||
command: string;
|
||||
protocol: string;
|
||||
/** Remote peer as lsof prints it, e.g. `93.184.216.34:443` or `[2606::1]:443`. */
|
||||
remote: string;
|
||||
};
|
||||
|
||||
function remoteHostOf(remote: string): string {
|
||||
if (remote.startsWith('[')) {
|
||||
const end = remote.indexOf(']');
|
||||
return end === -1 ? remote : remote.slice(1, end);
|
||||
}
|
||||
const colon = remote.lastIndexOf(':');
|
||||
return colon === -1 ? remote : remote.slice(0, colon);
|
||||
}
|
||||
|
||||
function isLoopbackHost(host: string): boolean {
|
||||
return (
|
||||
host === 'localhost' || host.startsWith('127.') || host === '::1' || host === '::ffff:127.0.0.1'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `lsof -nP -i -F pcPnT` output. Returns the TCP and UDP connections that
|
||||
* simulator processes hold to peers outside loopback. Those did not go through
|
||||
* the proxy on 127.0.0.1, so they exit from this host instead of the egress
|
||||
* client. Listening sockets, unconnected UDP sockets and connections to
|
||||
* loopback (the proxy itself) are ignored.
|
||||
*/
|
||||
export function parseDirectSimulatorConnections(
|
||||
lsofOutput: string,
|
||||
simulatorPids: ReadonlySet<number>
|
||||
): DirectSimulatorConnection[] {
|
||||
const connections: DirectSimulatorConnection[] = [];
|
||||
let pid: number | null = null;
|
||||
let command = '';
|
||||
let protocol: string | null = null;
|
||||
let name: string | null = null;
|
||||
let tcpState: string | null = null;
|
||||
|
||||
const flush = (): void => {
|
||||
if (pid !== null && simulatorPids.has(pid) && protocol && name) {
|
||||
const arrow = name.indexOf('->');
|
||||
const remote = arrow === -1 ? null : name.slice(arrow + 2);
|
||||
const active =
|
||||
protocol !== 'TCP' ||
|
||||
tcpState === null ||
|
||||
tcpState === 'ESTABLISHED' ||
|
||||
tcpState === 'SYN_SENT';
|
||||
if (remote && active && !isLoopbackHost(remoteHostOf(remote))) {
|
||||
connections.push({ pid, command, protocol, remote });
|
||||
}
|
||||
}
|
||||
protocol = null;
|
||||
name = null;
|
||||
tcpState = null;
|
||||
};
|
||||
|
||||
for (const line of lsofOutput.split('\n')) {
|
||||
const field = line[0];
|
||||
const value = line.slice(1);
|
||||
switch (field) {
|
||||
case 'p':
|
||||
flush();
|
||||
pid = Number(value);
|
||||
command = '';
|
||||
break;
|
||||
case 'c':
|
||||
command = value;
|
||||
break;
|
||||
case 'f':
|
||||
flush();
|
||||
break;
|
||||
case 'P':
|
||||
protocol = value;
|
||||
break;
|
||||
case 'n':
|
||||
name = value;
|
||||
break;
|
||||
case 'T':
|
||||
if (value.startsWith('ST=')) {
|
||||
tcpState = value.slice('ST='.length);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return connections;
|
||||
}
|
||||
|
||||
async function findDirectSimulatorConnectionsAsync({
|
||||
env,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
}): Promise<DirectSimulatorConnection[]> {
|
||||
const ps = await spawn('ps', ['-axo', 'pid=,ppid=,comm='], { env, stdio: 'pipe' });
|
||||
const pids = collectSimulatorProcessIds(ps.stdout);
|
||||
if (pids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
let lsofOutput: string;
|
||||
try {
|
||||
const lsof = await spawn('lsof', ['-nP', '-i', '-F', 'pcPnT', '-a', '-p', pids.join(',')], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
lsofOutput = lsof.stdout;
|
||||
} catch (err) {
|
||||
// lsof exits with 1 when none of the processes holds a matching socket, and
|
||||
// when one of them exited between `ps` and `lsof`. Its stdout is still valid.
|
||||
const result = err as { status?: number | null; stdout?: string };
|
||||
if (result.status !== 1) {
|
||||
throw err;
|
||||
}
|
||||
lsofOutput = result.stdout ?? '';
|
||||
}
|
||||
return parseDirectSimulatorConnections(lsofOutput, new Set(pids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log proxy listener availability, the exit IP observed by a worker request
|
||||
* through it, and simulator connections that bypassed the proxy. Neither check
|
||||
* verifies that proxied simulator requests reach the egress client. Never
|
||||
* rejects: it runs in the background for the whole session.
|
||||
*/
|
||||
export async function monitorLocalEgressAsync({
|
||||
port,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
port: number;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal: AbortSignal;
|
||||
}): Promise<void> {
|
||||
let connected = false;
|
||||
let lastEscapeScanAt = 0;
|
||||
let escapeScanBroken = false;
|
||||
const reportedEscapes = new Set<string>();
|
||||
const lifetimeSignal = activeLocalEgressResources?.controller.signal;
|
||||
try {
|
||||
while (!signal.aborted && !lifetimeSignal?.aborted) {
|
||||
const listening = await isPortListeningAsync({ host: LOCAL_EGRESS_PROXY_HOST, port });
|
||||
if (listening && !connected) {
|
||||
connected = true;
|
||||
logger.info('Local egress proxy listener is available.');
|
||||
try {
|
||||
const exitIp = await fetchExitIpThroughProxyAsync({ port, env });
|
||||
logger.info(
|
||||
`Worker proxy exit-IP check observed ${exitIp}. This does not verify simulator routing.`
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'The local egress proxy listener is available, but the worker exit-IP check through it failed.'
|
||||
);
|
||||
}
|
||||
} else if (!listening && connected) {
|
||||
connected = false;
|
||||
logger.warn(
|
||||
'Local egress proxy listener is unavailable. Proxied HTTP(S) requests fail until it returns.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!escapeScanBroken && Date.now() - lastEscapeScanAt >= EGRESS_ESCAPE_SCAN_INTERVAL_MS) {
|
||||
lastEscapeScanAt = Date.now();
|
||||
try {
|
||||
for (const connection of await findDirectSimulatorConnectionsAsync({ env })) {
|
||||
const key = `${connection.pid}|${connection.protocol}|${connection.remote}`;
|
||||
if (reportedEscapes.has(key)) {
|
||||
continue;
|
||||
}
|
||||
reportedEscapes.add(key);
|
||||
if (reportedEscapes.size > EGRESS_ESCAPE_LOG_LIMIT) {
|
||||
if (reportedEscapes.size === EGRESS_ESCAPE_LOG_LIMIT + 1) {
|
||||
logger.warn(
|
||||
`Local egress: more than ${EGRESS_ESCAPE_LOG_LIMIT} direct connections were reported; further ones are not logged.`
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
logger.warn(
|
||||
`Local egress: ${connection.command} (pid ${connection.pid}) opened a direct ${connection.protocol} connection to ${connection.remote}, bypassing the system proxy. That traffic exits from this worker, not from the egress client.`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
escapeScanBroken = true;
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Local egress: could not inspect simulator connections. Direct connections that bypass the proxy will not be reported.'
|
||||
);
|
||||
}
|
||||
}
|
||||
await sleepAsync(EGRESS_MONITOR_INTERVAL_MS);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Local egress monitoring stopped unexpectedly.');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { type BuildFunction, type BuildStepEnv } from '@expo/steps';
|
||||
|
||||
import {
|
||||
buildEgressRemoteConfigFields,
|
||||
monitorLocalEgressAsync,
|
||||
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. */
|
||||
export function withLocalEgressSession(
|
||||
fn: NonNullable<BuildFunction['fn']>
|
||||
): NonNullable<BuildFunction['fn']> {
|
||||
return async (context, args) => {
|
||||
try {
|
||||
args.signal?.throwIfAborted();
|
||||
await fn(context, args);
|
||||
} finally {
|
||||
await stopLocalEgressResourcesAsync(context.logger);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** All simulator controllers publish the same egress handoff and run the same monitor. */
|
||||
export async function uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
...options
|
||||
}: Parameters<typeof uploadRemoteSessionConfigAsync>[0] & {
|
||||
env: BuildStepEnv;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
// Written by start_local_egress before simulator boot; absent for ordinary sessions.
|
||||
const localEgress = await readLocalEgressHandoffAsync();
|
||||
signal?.throwIfAborted();
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
...options,
|
||||
remoteConfig: { ...options.remoteConfig, ...buildEgressRemoteConfigFields(localEgress) },
|
||||
});
|
||||
if (localEgress && !signal?.aborted) {
|
||||
// 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.'
|
||||
);
|
||||
// The monitor also observes the registered resources' lifetime signal, which
|
||||
// withLocalEgressSession aborts on success, failure, or cancellation.
|
||||
void monitorLocalEgressAsync({
|
||||
port: localEgress.port,
|
||||
env,
|
||||
logger: options.logger,
|
||||
signal: signal ?? new AbortController().signal,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const WEB_PREVIEW_HOST = '127.0.0.1';
|
||||
@@ -652,6 +653,7 @@ export function createServeSimArgs({
|
||||
String(port),
|
||||
'--host',
|
||||
WEB_PREVIEW_HOST,
|
||||
'--require-token',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
@@ -705,7 +707,7 @@ export function createExpoDeviceHubArgs({
|
||||
];
|
||||
}
|
||||
|
||||
async function findAvailablePortAsync(): Promise<number> {
|
||||
export async function findAvailablePortAsync(): Promise<number> {
|
||||
const server = createServer();
|
||||
server.unref();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -737,7 +739,7 @@ export async function waitForWebPreviewReadyAsync({
|
||||
serverName: string;
|
||||
port: number;
|
||||
timeoutMs: number;
|
||||
}): Promise<void> {
|
||||
}): Promise<string> {
|
||||
const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
@@ -754,8 +756,8 @@ export async function waitForWebPreviewReadyAsync({
|
||||
retries: 0,
|
||||
timeout: 2_000,
|
||||
});
|
||||
WebPreviewReadyResponseSchema.parse(await response.json());
|
||||
return;
|
||||
const ready = WebPreviewReadyResponseSchema.parse(await response.json());
|
||||
return ready.device;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
@@ -770,6 +772,8 @@ export async function waitForWebPreviewReadyAsync({
|
||||
|
||||
export type DeviceWebPreviewHandle = {
|
||||
previewUrl: string;
|
||||
/** Session token gating the preview. Only serve-sim mints one. */
|
||||
previewToken?: string;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -785,6 +789,7 @@ async function startWebPreviewWithTunnelAsync(
|
||||
serverName,
|
||||
packageSpec,
|
||||
createArgs,
|
||||
readPreviewTokenAsync,
|
||||
}: {
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
@@ -793,6 +798,7 @@ async function startWebPreviewWithTunnelAsync(
|
||||
serverName: string;
|
||||
packageSpec: string;
|
||||
createArgs: (port: number, turnArgs: string[]) => string[];
|
||||
readPreviewTokenAsync?: (device: string) => Promise<string>;
|
||||
}
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
const port = await findAvailablePortAsync();
|
||||
@@ -806,7 +812,13 @@ async function startWebPreviewWithTunnelAsync(
|
||||
|
||||
try {
|
||||
logger.info(`Waiting for ${serverName} to become ready.`);
|
||||
await waitForWebPreviewReadyAsync({ previewServer, serverName, port, timeoutMs });
|
||||
const device = await waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
});
|
||||
const previewToken = await readPreviewTokenAsync?.(device);
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix: 'web-preview',
|
||||
@@ -816,6 +828,7 @@ async function startWebPreviewWithTunnelAsync(
|
||||
});
|
||||
return {
|
||||
previewUrl: tunnel.url,
|
||||
previewToken,
|
||||
stopAsync: async () => {
|
||||
const results = await Promise.allSettled([tunnel.stopAsync(), previewServer.stopAsync()]);
|
||||
for (const result of results) {
|
||||
@@ -831,6 +844,14 @@ async function startWebPreviewWithTunnelAsync(
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
{
|
||||
@@ -857,6 +878,20 @@ export async function startServeSimWithTunnelAsync(
|
||||
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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
export interface SandboxDaemonOptions {
|
||||
credential: string;
|
||||
serverUrl: string;
|
||||
reconnectDelayMs: number;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
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 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 {
|
||||
socket = new WebSocket(new URL('/sandbox/connect', options.serverUrl), {
|
||||
handshakeTimeout: 10_000,
|
||||
headers: { Authorization: `Bearer ${options.credential}` },
|
||||
});
|
||||
await waitForOpen(socket);
|
||||
options.logger.info('Sandbox MCP server connected.');
|
||||
hasConnected = true;
|
||||
resolveConnected();
|
||||
await waitForClose(socket);
|
||||
} 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 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);
|
||||
});
|
||||
}
|
||||
@@ -13,9 +13,9 @@ const POLL_INTERVAL_MS = 2_000;
|
||||
const MAX_CONSECUTIVE_STREAM_FAILURES_PER_DEVICE = 10;
|
||||
const END_STREAM_TIMEOUT_MS = 2_000;
|
||||
// serve-sim's own per-device registry while serving: `${tmpdir}/serve-sim/server-<udid>.json`.
|
||||
const SERVE_SIM_STATE_DIR = path.join(os.tmpdir(), 'serve-sim');
|
||||
export const SERVE_SIM_STATE_DIR = path.join(os.tmpdir(), 'serve-sim');
|
||||
|
||||
type ServeSimServer = { udid: string; url: string };
|
||||
type ServeSimServer = { udid: string; url: string; token?: string };
|
||||
|
||||
type ServeSimMetricsSession = {
|
||||
logger: bunyan;
|
||||
@@ -130,6 +130,7 @@ async function pollServeSimMetricsAsync(session: ServeSimMetricsSession): Promis
|
||||
logger.info(`Collecting serve-sim metrics for ${server.udid}.`);
|
||||
const donePromise = streamServeSimMetricsToFileAsync({
|
||||
serveSimUrl: server.url,
|
||||
serveSimToken: server.token,
|
||||
filePath,
|
||||
signal: streamSignal,
|
||||
logger,
|
||||
@@ -172,9 +173,14 @@ export async function readServeSimServersAsync(stateDir: string): Promise<ServeS
|
||||
const state = JSON.parse(await readFile(path.join(stateDir, entry), 'utf-8')) as {
|
||||
device?: unknown;
|
||||
url?: unknown;
|
||||
token?: unknown;
|
||||
};
|
||||
if (typeof state.device === 'string' && typeof state.url === 'string') {
|
||||
servers.push({ udid: state.device, url: state.url });
|
||||
servers.push({
|
||||
udid: state.device,
|
||||
url: state.url,
|
||||
...(typeof state.token === 'string' ? { token: state.token } : {}),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
@@ -185,11 +191,13 @@ export async function readServeSimServersAsync(stateDir: string): Promise<ServeS
|
||||
|
||||
export async function streamServeSimMetricsToFileAsync({
|
||||
serveSimUrl,
|
||||
serveSimToken,
|
||||
filePath,
|
||||
signal,
|
||||
logger,
|
||||
}: {
|
||||
serveSimUrl: string;
|
||||
serveSimToken?: string;
|
||||
filePath: string;
|
||||
signal: AbortSignal;
|
||||
logger: bunyan;
|
||||
@@ -201,7 +209,10 @@ export async function streamServeSimMetricsToFileAsync({
|
||||
let receivedData = false;
|
||||
let metadata: Record<string, unknown> | undefined;
|
||||
try {
|
||||
const response = await fetch(new URL('/metrics', serveSimUrl).toString(), { signal });
|
||||
const response = await fetch(new URL('/metrics', serveSimUrl).toString(), {
|
||||
signal,
|
||||
...(serveSimToken ? { headers: { Authorization: `Bearer ${serveSimToken}` } } : {}),
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
logger.warn(`serve-sim /metrics responded ${response.status} for ${serveSimUrl}.`);
|
||||
return { receivedData, metadata };
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "24.0.0",
|
||||
"version": "24.3.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "24.0.0"
|
||||
"@expo/steps": "24.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "24.0.0",
|
||||
"version": "24.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -240,7 +240,6 @@ describe('Android.JobSchema', () => {
|
||||
username: 'turtle-tutorial',
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'http://localhost:3000',
|
||||
gitRef: 'master',
|
||||
gitCommitHash: '1b57db5b1cd12638aba0d12da71a2d691416700d',
|
||||
},
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import {
|
||||
ArchiveSource,
|
||||
ArchiveSourceSchema,
|
||||
ArchiveSourceSchemaZ,
|
||||
ArchiveSourceType,
|
||||
EasCliVersionsFetchTimeoutError,
|
||||
EnvSchema,
|
||||
SshSettingsZ,
|
||||
@@ -675,3 +679,36 @@ describe('fetchEasCliVersionsAsync', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Git archive sources', () => {
|
||||
const source: ArchiveSource = {
|
||||
type: ArchiveSourceType.GIT,
|
||||
gitRef: null,
|
||||
gitCommitHash: '1234567890',
|
||||
};
|
||||
|
||||
it('accepts Git job sources without a repository URL', () => {
|
||||
expect(ArchiveSourceSchema.validate(source)).toMatchObject({ value: source });
|
||||
expect(ArchiveSourceSchema.validate(source).error).toBeUndefined();
|
||||
expect(ArchiveSourceSchemaZ.parse(source)).toEqual(source);
|
||||
});
|
||||
|
||||
it('strips the legacy repository URL with the job validation options', () => {
|
||||
const archive = {
|
||||
...source,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
};
|
||||
const result = ArchiveSourceSchema.validate(archive, { stripUnknown: true });
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value).toEqual(source);
|
||||
expect(ArchiveSourceSchemaZ.parse(archive)).toEqual(source);
|
||||
});
|
||||
|
||||
it('still requires the commit and ref', () => {
|
||||
for (const field of ['gitCommitHash', 'gitRef'] as const) {
|
||||
const archive = { ...source, [field]: undefined };
|
||||
expect(ArchiveSourceSchema.validate(archive).error).toBeDefined();
|
||||
expect(ArchiveSourceSchemaZ.safeParse(archive).success).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,6 @@ describe('Generic.JobZ', () => {
|
||||
const job: Generic.Job = {
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://github.com/expo/expo.git',
|
||||
gitCommitHash: '1234567890',
|
||||
gitRef: null,
|
||||
},
|
||||
@@ -51,7 +50,6 @@ describe('Generic.JobZ', () => {
|
||||
const job: Generic.Job = {
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://github.com/expo/expo.git',
|
||||
gitCommitHash: '1234567890',
|
||||
gitRef: null,
|
||||
},
|
||||
@@ -96,7 +94,6 @@ describe('Generic.JobZ', () => {
|
||||
const job: Generic.Job = {
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://github.com/expo/expo.git',
|
||||
gitCommitHash: '1234567890',
|
||||
gitRef: null,
|
||||
},
|
||||
@@ -131,7 +128,6 @@ describe('Generic.JobZ', () => {
|
||||
const job: Generic.Job = {
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://github.com/expo/expo.git',
|
||||
gitCommitHash: '1234567890',
|
||||
gitRef: null,
|
||||
},
|
||||
@@ -183,7 +179,6 @@ describe('Generic.JobZ', () => {
|
||||
const job = {
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://github.com/expo/expo.git',
|
||||
gitCommitHash: '1234567890',
|
||||
gitRef: null,
|
||||
},
|
||||
@@ -232,7 +227,6 @@ describe('Generic.JobZ', () => {
|
||||
const job: Omit<Generic.Job, 'customBuildConfig' | 'steps'> = {
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://github.com/expo/expo.git',
|
||||
gitCommitHash: '1234567890',
|
||||
gitRef: null,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { SandboxDaemonCommands, SandboxDaemonRequestZ, SandboxDaemonResponseZ } from '../sandbox';
|
||||
|
||||
describe('sandbox daemon protocol', () => {
|
||||
it('validates command parameters', () => {
|
||||
expect(
|
||||
SandboxDaemonCommands.execCommand.params.parse({
|
||||
cmd: 'pwd',
|
||||
workdir: '/tmp',
|
||||
tty: true,
|
||||
yieldTimeMs: 10,
|
||||
})
|
||||
).toEqual({ cmd: 'pwd', workdir: '/tmp', tty: true, yieldTimeMs: 10 });
|
||||
expect(
|
||||
SandboxDaemonCommands.writeStdin.params.parse({
|
||||
sessionId: 1,
|
||||
chars: '\u0003',
|
||||
yieldTimeMs: 0,
|
||||
})
|
||||
).toEqual({ sessionId: 1, chars: '\u0003', yieldTimeMs: 0 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ output: '', wallTimeSeconds: 0, exitCode: 0 },
|
||||
{ output: '', wallTimeSeconds: 0, terminationSignal: 'SIGTERM' },
|
||||
{ output: '', wallTimeSeconds: 0, sessionId: 1 },
|
||||
])('validates a command result with one terminal state', result => {
|
||||
expect(SandboxDaemonCommands.execCommand.result.parse(result)).toEqual(result);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ output: '', wallTimeSeconds: 0 },
|
||||
{ output: '', wallTimeSeconds: 0, exitCode: 0, sessionId: 1 },
|
||||
])('rejects a command result without exactly one terminal state', result => {
|
||||
expect(() => SandboxDaemonCommands.writeStdin.result.parse(result)).toThrow();
|
||||
});
|
||||
|
||||
it('validates success and error response envelopes', () => {
|
||||
expect(
|
||||
SandboxDaemonRequestZ.parse({
|
||||
jsonrpc: '2.0',
|
||||
id: 'request-id',
|
||||
method: 'execCommand',
|
||||
params: { cmd: 'pwd' },
|
||||
})
|
||||
).toEqual({
|
||||
jsonrpc: '2.0',
|
||||
id: 'request-id',
|
||||
method: 'execCommand',
|
||||
params: { cmd: 'pwd' },
|
||||
});
|
||||
expect(
|
||||
SandboxDaemonResponseZ.parse({ jsonrpc: '2.0', id: 'request-id', result: { output: '' } })
|
||||
).toEqual({ jsonrpc: '2.0', id: 'request-id', result: { output: '' } });
|
||||
expect(
|
||||
SandboxDaemonResponseZ.parse({
|
||||
jsonrpc: '2.0',
|
||||
id: 'request-id',
|
||||
error: { code: -32603, message: 'Command failed' },
|
||||
})
|
||||
).toEqual({
|
||||
jsonrpc: '2.0',
|
||||
id: 'request-id',
|
||||
error: { code: -32603, message: 'Command failed' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -45,11 +45,6 @@ export type ArchiveSource =
|
||||
| { type: ArchiveSourceType.PATH; path: string }
|
||||
| {
|
||||
type: ArchiveSourceType.GIT;
|
||||
/**
|
||||
* Url that can be used to clone repository.
|
||||
* It should contain embedded credentials for private registries.
|
||||
*/
|
||||
repositoryUrl: string;
|
||||
/** A Git ref - points to a branch head, tag head or a branch name. */
|
||||
gitRef: string | null;
|
||||
/**
|
||||
@@ -79,7 +74,6 @@ export const ArchiveSourceSchema = Joi.object<ArchiveSource>({
|
||||
.when(Joi.object({ type: ArchiveSourceType.GIT }).unknown(), {
|
||||
then: Joi.object({
|
||||
type: Joi.string().valid(ArchiveSourceType.GIT).required(),
|
||||
repositoryUrl: Joi.string().required(),
|
||||
gitCommitHash: Joi.string().required(),
|
||||
gitRef: Joi.string().allow(null).required(),
|
||||
}),
|
||||
@@ -94,7 +88,6 @@ export const ArchiveSourceSchema = Joi.object<ArchiveSource>({
|
||||
export const ArchiveSourceSchemaZ = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal(ArchiveSourceType.GIT),
|
||||
repositoryUrl: z.string().url(),
|
||||
gitRef: z.string().nullable(),
|
||||
gitCommitHash: z.string(),
|
||||
}),
|
||||
|
||||
@@ -34,6 +34,7 @@ export * from './compositeFunction';
|
||||
export * from './submission-config';
|
||||
export * from './projectPackage';
|
||||
export * from './deviceRunSession';
|
||||
export * from './sandbox';
|
||||
|
||||
const version = require('../package.json').version;
|
||||
export { version };
|
||||
@@ -0,0 +1,83 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const SandboxDaemonCommands = {
|
||||
execCommand: {
|
||||
params: z.object({
|
||||
cmd: z.string().min(1),
|
||||
workdir: z.string().optional(),
|
||||
tty: z.boolean().optional(),
|
||||
yieldTimeMs: z.number().int().min(0).max(30_000).optional(),
|
||||
}),
|
||||
result: z.union([
|
||||
z.strictObject({
|
||||
output: z.string(),
|
||||
wallTimeSeconds: z.number().nonnegative(),
|
||||
exitCode: z.number().int(),
|
||||
}),
|
||||
z.strictObject({
|
||||
output: z.string(),
|
||||
wallTimeSeconds: z.number().nonnegative(),
|
||||
terminationSignal: z.string().min(1),
|
||||
}),
|
||||
z.strictObject({
|
||||
output: z.string(),
|
||||
wallTimeSeconds: z.number().nonnegative(),
|
||||
sessionId: z.number().int().positive(),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
writeStdin: {
|
||||
params: z.object({
|
||||
sessionId: z.number().int().positive(),
|
||||
chars: z.string().optional(),
|
||||
yieldTimeMs: z.number().int().min(0).max(30_000).optional(),
|
||||
}),
|
||||
result: z.union([
|
||||
z.strictObject({
|
||||
output: z.string(),
|
||||
wallTimeSeconds: z.number().nonnegative(),
|
||||
exitCode: z.number().int(),
|
||||
}),
|
||||
z.strictObject({
|
||||
output: z.string(),
|
||||
wallTimeSeconds: z.number().nonnegative(),
|
||||
terminationSignal: z.string().min(1),
|
||||
}),
|
||||
z.strictObject({
|
||||
output: z.string(),
|
||||
wallTimeSeconds: z.number().nonnegative(),
|
||||
sessionId: z.number().int().positive(),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
} as const satisfies Record<string, { params: z.ZodType; result: z.ZodType }>;
|
||||
|
||||
export type SandboxDaemonMethod = keyof typeof SandboxDaemonCommands;
|
||||
|
||||
export type SandboxDaemonCommandParams<Method extends SandboxDaemonMethod> = z.output<
|
||||
(typeof SandboxDaemonCommands)[Method]['params']
|
||||
>;
|
||||
|
||||
export type SandboxDaemonCommandResult<Method extends SandboxDaemonMethod> = z.output<
|
||||
(typeof SandboxDaemonCommands)[Method]['result']
|
||||
>;
|
||||
|
||||
export const SandboxDaemonRequestZ = z.object({
|
||||
jsonrpc: z.literal('2.0'),
|
||||
id: z.string(),
|
||||
method: z.string(),
|
||||
params: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export type SandboxDaemonRequest = z.output<typeof SandboxDaemonRequestZ>;
|
||||
|
||||
export const SandboxDaemonResponseZ = z.union([
|
||||
z.strictObject({ jsonrpc: z.literal('2.0'), id: z.string(), result: z.unknown().nonoptional() }),
|
||||
z.strictObject({
|
||||
jsonrpc: z.literal('2.0'),
|
||||
id: z.string(),
|
||||
error: z.strictObject({ code: z.number().int(), message: z.string() }),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type SandboxDaemonResponse = z.output<typeof SandboxDaemonResponseZ>;
|
||||
@@ -299,4 +299,4 @@ describe('MyCommand', () => {
|
||||
- **README**: Auto-generated from oclif - update via `yarn oclif readme`
|
||||
- **Manifest**: Generated during `prepack` step
|
||||
- **Templates**: `src/commandUtils/new/templates` are copied to `build/` during build
|
||||
- **Node Version**: >= 18.0.0
|
||||
- **Node Version**: ^20.18.3 or >= 22.0.0
|
||||
+496
-257
File diff suppressed because it is too large.
Load diff
Generated
+290
-9
@@ -6632,6 +6632,54 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "egressFingerprint",
|
||||
"description": "Fingerprint of the reverse-tunnel server key for the client to pin. Null when\nthe session does not use local egress.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "egressPort",
|
||||
"description": "Loopback port on the device host that the local egress client must serve\nthrough the tunnel. Null when the session does not use local egress.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "egressToken",
|
||||
"description": "Secret the local egress client presents to the reverse-tunnel endpoint. Null\nwhen the session does not use local egress.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "egressUrl",
|
||||
"description": "Reverse-tunnel endpoint the local egress client connects to. Null when the\nsession does not use local egress.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "webPreviewToken",
|
||||
"description": "Session token gating the web preview. Null when the preview runs ungated.",
|
||||
@@ -33311,6 +33359,54 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "egressFingerprint",
|
||||
"description": "Fingerprint of the reverse-tunnel server key for the client to pin. Null when\nthe session does not use local egress.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "egressPort",
|
||||
"description": "Loopback port on the device host that the local egress client must serve\nthrough the tunnel. Null when the session does not use local egress.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "egressToken",
|
||||
"description": "Secret the local egress client presents to the reverse-tunnel endpoint. Null\nwhen the session does not use local egress.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "egressUrl",
|
||||
"description": "Reverse-tunnel endpoint the local egress client connects to. Null when the\nsession does not use local egress.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "webPreviewToken",
|
||||
"description": "Session token gating the web preview. Null when the preview runs ungated.",
|
||||
@@ -36124,6 +36220,54 @@
|
||||
"name": "ArgentRunSessionRemoteConfig",
|
||||
"description": null,
|
||||
"fields": [
|
||||
{
|
||||
"name": "egressFingerprint",
|
||||
"description": "Fingerprint of the reverse-tunnel server key for the client to pin. Null when\nthe session does not use local egress.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "egressPort",
|
||||
"description": "Loopback port on the device host that the local egress client must serve\nthrough the tunnel. Null when the session does not use local egress.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "egressToken",
|
||||
"description": "Secret the local egress client presents to the reverse-tunnel endpoint. Null\nwhen the session does not use local egress.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "egressUrl",
|
||||
"description": "Reverse-tunnel endpoint the local egress client connects to. Null when the\nsession does not use local egress.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "toolsAuthToken",
|
||||
"description": null,
|
||||
@@ -42496,6 +42640,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "IS_WORKFLOW_BUILD",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "LOCAL_BUILDS_NOT_RETRYABLE",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "NOT_COMPLETED_YET",
|
||||
"description": null,
|
||||
@@ -45010,6 +45166,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "egress",
|
||||
"description": "Where the virtual device's proxied network traffic exits to the internet. If\nomitted, all traffic exits from EAS infrastructure. LOCAL is only supported for\nsessions on IOS.",
|
||||
"type": {
|
||||
"kind": "ENUM",
|
||||
"name": "DeviceRunSessionEgress",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "expoGo",
|
||||
"description": "Install and launch Expo Go before the simulator session becomes available. The server resolves\nthe platform-specific application archive. Mutually exclusive with buildId, buildFingerprint,\nand applicationArchiveUrl.",
|
||||
@@ -50362,6 +50530,23 @@
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "ENUM",
|
||||
"name": "DeviceRunSessionEgress",
|
||||
"description": "Where the virtual device's proxied network traffic exits to the internet. When\nunset, all traffic exits from EAS infrastructure.",
|
||||
"fields": null,
|
||||
"inputFields": null,
|
||||
"interfaces": null,
|
||||
"enumValues": [
|
||||
{
|
||||
"name": "LOCAL",
|
||||
"description": "HTTP(S) requests that honor the device's system proxy exit through a reverse\ntunnel to the machine running the EAS CLI egress client, so third parties see\nthat machine's public IP. Those requests fail while the client is disconnected.\nRequests from libraries that bypass the system proxy are not covered and exit\nfrom EAS infrastructure. The client must stay connected for the life of the\nsession.",
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "DeviceRunSessionEventLogUploadSession",
|
||||
@@ -78421,6 +78606,54 @@
|
||||
"name": "ServeSimRunSessionRemoteConfig",
|
||||
"description": "Legacy remote config for SERVE_SIM sessions. Use WEB_PREVIEW_ONLY sessions and\nWebPreviewOnlyRunSessionRemoteConfig instead.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "egressFingerprint",
|
||||
"description": "Fingerprint of the reverse-tunnel server key for the client to pin. Null when\nthe session does not use local egress.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "egressPort",
|
||||
"description": "Loopback port on the device host that the local egress client must serve\nthrough the tunnel. Null when the session does not use local egress.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "egressToken",
|
||||
"description": "Secret the local egress client presents to the reverse-tunnel endpoint. Null\nwhen the session does not use local egress.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "egressUrl",
|
||||
"description": "Reverse-tunnel endpoint the local egress client connects to. Null when the\nsession does not use local egress.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "previewToken",
|
||||
"description": "Session token gating the preview. Null when the preview runs ungated.",
|
||||
@@ -92896,17 +93129,49 @@
|
||||
"description": null,
|
||||
"fields": [
|
||||
{
|
||||
"name": "webPreviewUrl",
|
||||
"description": null,
|
||||
"name": "egressFingerprint",
|
||||
"description": "Fingerprint of the reverse-tunnel server key for the client to pin. Null when\nthe session does not use local egress.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "egressPort",
|
||||
"description": "Loopback port on the device host that the local egress client must serve\nthrough the tunnel. Null when the session does not use local egress.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "egressToken",
|
||||
"description": "Secret the local egress client presents to the reverse-tunnel endpoint. Null\nwhen the session does not use local egress.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "egressUrl",
|
||||
"description": "Reverse-tunnel endpoint the local egress client connects to. Null when the\nsession does not use local egress.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
@@ -92922,6 +93187,22 @@
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "webPreviewUrl",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "24.0.0",
|
||||
"version": "24.4.2",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -44,12 +44,12 @@
|
||||
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/apple-utils": "2.2.0",
|
||||
"@expo/apple-utils": "2.2.1",
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "24.0.0",
|
||||
"@expo/eas-json": "24.0.0",
|
||||
"@expo/eas-build-job": "24.3.0",
|
||||
"@expo/eas-json": "24.3.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "24.0.0",
|
||||
@@ -64,7 +64,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "24.0.0",
|
||||
"@expo/steps": "24.3.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -266,6 +266,6 @@
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": "^20.18.3 || >=22.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { ChannelRuntimeInsights } from '../../graphql/queries/ChannelInsightsQuery';
|
||||
import { formatTimespan } from '../../insights/formatTimespan';
|
||||
import { formatTimespan, toTimespanJson } from '../../insights/formatTimespan';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import renderTextTable from '../../utils/renderTextTable';
|
||||
|
||||
@@ -90,11 +90,7 @@ export function buildChannelInsightsJson(summary: ChannelInsightsSummary): objec
|
||||
return {
|
||||
channel: summary.channelName,
|
||||
runtimeVersion: summary.runtimeVersion,
|
||||
timespan: {
|
||||
start: summary.startTime,
|
||||
end: summary.endTime,
|
||||
...(summary.daysBack !== undefined ? { daysBack: summary.daysBack } : {}),
|
||||
},
|
||||
timespan: toTimespanJson(summary),
|
||||
embeddedUpdateTotalUniqueUsers: summary.embeddedUpdateTotalUniqueUsers,
|
||||
otaTotalUniqueUsers: summary.otaTotalUniqueUsers,
|
||||
mostPopularUpdates: summary.mostPopularUpdates,
|
||||
|
||||
@@ -57,6 +57,7 @@ const BASE_GRAPHQL_ERROR_MESSAGE: string = 'GraphQL request failed.';
|
||||
interface BaseGetContextAsyncArgs {
|
||||
nonInteractive: boolean;
|
||||
vcsClientOverride?: Client;
|
||||
projectIdOverride?: string;
|
||||
}
|
||||
|
||||
interface GetContextAsyncArgsWithRequiredServerSideEnvironmentArgument extends BaseGetContextAsyncArgs {
|
||||
@@ -183,6 +184,7 @@ export default abstract class EasCommand extends Command {
|
||||
{
|
||||
nonInteractive,
|
||||
vcsClientOverride,
|
||||
projectIdOverride,
|
||||
// if specified and not null, the env vars from the selected environment will be fetched from the server
|
||||
// to resolve dynamic config (if dynamic config context is used) and enable getServerSideEnvironmentVariablesAsync function (if server side environment variables context is used)
|
||||
withServerSideEnvironment,
|
||||
@@ -206,6 +208,7 @@ export default abstract class EasCommand extends Command {
|
||||
sessionManager: this.sessionManager,
|
||||
analytics: this.analytics,
|
||||
vcsClientOverride,
|
||||
projectIdOverride,
|
||||
withServerSideEnvironment,
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Config } from '@oclif/core';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { AnalyticsWithOrchestration } from '../../analytics/AnalyticsManager';
|
||||
@@ -24,17 +25,24 @@ jest.mock('../../analytics/AnalyticsManager', () => {
|
||||
});
|
||||
jest.mock('../../log');
|
||||
|
||||
let originalProcessArgv: string[];
|
||||
const mockRequestId = uuidv4();
|
||||
|
||||
beforeAll(() => {
|
||||
originalProcessArgv = process.argv;
|
||||
process.argv = [];
|
||||
});
|
||||
/**
|
||||
* A Config that is never loaded. Letting oclif load one discovers and requires
|
||||
* every command in the package, which takes seconds under coverage with a cold
|
||||
* transform cache and has timed out on slow CI runners. Nothing here needs the
|
||||
* command table or hooks.
|
||||
*/
|
||||
function getMockOclifConfig(): Config {
|
||||
const config = new Config({ root: __dirname });
|
||||
config.runHook = async () => ({
|
||||
failures: [],
|
||||
successes: [],
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
process.argv = originalProcessArgv;
|
||||
});
|
||||
let mockConfig: Config;
|
||||
|
||||
const analytics: AnalyticsWithOrchestration = {
|
||||
logEvent: jest.fn((): void => {}),
|
||||
@@ -44,42 +52,57 @@ const analytics: AnalyticsWithOrchestration = {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
mockConfig = getMockOclifConfig();
|
||||
});
|
||||
|
||||
/**
|
||||
* The config oclif handed to the most recently run command. Command.run returns
|
||||
* a pre-built Config untouched only when it was created by the same @oclif/core
|
||||
* copy the command extends; otherwise it silently builds and fully loads a new
|
||||
* one, bringing back the command discovery this suite avoids.
|
||||
*/
|
||||
let lastCommandConfig: Config | undefined;
|
||||
|
||||
const createTestEasCommand = (): any => {
|
||||
const { createAnalyticsAsync } = jest.requireMock('../../analytics/AnalyticsManager');
|
||||
createAnalyticsAsync.mockResolvedValue(analytics);
|
||||
const EasCommand = require('../EasCommand').default;
|
||||
|
||||
class TestEasCommand extends EasCommand {
|
||||
async runAsync(): Promise<void> {}
|
||||
async runAsync(): Promise<void> {
|
||||
lastCommandConfig = this.config;
|
||||
}
|
||||
}
|
||||
|
||||
TestEasCommand.id = 'testEasCommand'; // normally oclif will assign ids, but b/c this is located outside the commands folder it will not
|
||||
return TestEasCommand;
|
||||
};
|
||||
|
||||
// These tests go through the static Command.run rather than constructing an
|
||||
// instance and calling run() like other command tests, because they cover
|
||||
// oclif's catch and finally wiring, which only runs inside the static path.
|
||||
describe('EasCommand', () => {
|
||||
describe('without exceptions', () => {
|
||||
// The first test in this suite should have an increased timeout
|
||||
// because of the implementation of Command from @oclif/command.
|
||||
// It seems that loading config takes significant amount of time
|
||||
// and I'm not sure how to mock it.
|
||||
//
|
||||
// See https://github.com/oclif/command/blob/master/src/command.ts#L80
|
||||
// and look for "Config.load"
|
||||
it('ensures the user data is read', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
|
||||
const SessionManager = jest.requireMock('../../user/SessionManager').default;
|
||||
const sessionManagerSpy = jest.spyOn(SessionManager.prototype, 'getUserAsync');
|
||||
expect(sessionManagerSpy).toBeCalledTimes(1);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
it('runs with the unloaded config instead of loading one', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
|
||||
expect(lastCommandConfig).toBe(mockConfig);
|
||||
expect(mockConfig.plugins.size).toBe(0);
|
||||
});
|
||||
|
||||
it('initializes analytics', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
|
||||
const { createAnalyticsAsync } = jest.requireMock('../../analytics/AnalyticsManager');
|
||||
expect(createAnalyticsAsync).toHaveBeenCalled();
|
||||
@@ -87,14 +110,14 @@ describe('EasCommand', () => {
|
||||
|
||||
it('flushes analytics', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
|
||||
expect(analytics.flushAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flushes Sentry', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
|
||||
const Sentry = jest.requireMock('../../sentry').default;
|
||||
expect(Sentry.flush).toHaveBeenCalled();
|
||||
@@ -102,7 +125,7 @@ describe('EasCommand', () => {
|
||||
|
||||
it('logs events', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith('action', {
|
||||
action: `eas ${TestEasCommand.id}`,
|
||||
@@ -114,7 +137,7 @@ describe('EasCommand', () => {
|
||||
it('flushes analytics', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
try {
|
||||
await TestEasCommand.run().then(() => {
|
||||
await TestEasCommand.run([], mockConfig).then(() => {
|
||||
throw new Error('foo');
|
||||
});
|
||||
} catch {}
|
||||
@@ -140,7 +163,7 @@ describe('EasCommand', () => {
|
||||
});
|
||||
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch {}
|
||||
|
||||
expect(sentryScope.setTag).toHaveBeenCalledWith('command', TestEasCommand.id);
|
||||
@@ -167,7 +190,7 @@ describe('EasCommand', () => {
|
||||
});
|
||||
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch {}
|
||||
|
||||
expect(Sentry.captureException).toHaveBeenCalledTimes(1);
|
||||
@@ -184,7 +207,7 @@ describe('EasCommand', () => {
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch {}
|
||||
|
||||
expect(logErrorSpy).toBeCalledWith('Unexpected, internal error message');
|
||||
@@ -204,7 +227,7 @@ describe('EasCommand', () => {
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch {}
|
||||
|
||||
expect(logErrorSpy).toBeCalledWith('Unexpected GraphQL error message');
|
||||
@@ -237,7 +260,7 @@ describe('EasCommand', () => {
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch {}
|
||||
|
||||
expect(logErrorSpy).toBeCalledWith(
|
||||
@@ -273,7 +296,7 @@ describe('EasCommand', () => {
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch {}
|
||||
|
||||
expect(logErrorSpy).toBeCalledWith(
|
||||
@@ -294,7 +317,7 @@ describe('EasCommand', () => {
|
||||
throw new Error('Error message');
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch (caughtError) {
|
||||
expect(caughtError).toBeInstanceOf(Error);
|
||||
expect((caughtError as Error).message).toEqual('testEasCommand command failed.');
|
||||
@@ -310,7 +333,7 @@ describe('EasCommand', () => {
|
||||
throw new CombinedError({ graphQLErrors });
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch (caughtError) {
|
||||
expect(caughtError).toBeInstanceOf(Error);
|
||||
expect((caughtError as Error).message).toEqual('GraphQL request failed.');
|
||||
@@ -340,7 +363,7 @@ describe('EasCommand', () => {
|
||||
throw new CombinedError({ graphQLErrors });
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch (caughtError) {
|
||||
expect(caughtError).toBeInstanceOf(Error);
|
||||
expect((caughtError as Error).message).toEqual('GraphQL request failed.');
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { EasPaginatedQueryFlags, getLimitFlagWithCustomValues } from '../pagination';
|
||||
|
||||
async function parseFlagAsync(flag: { parse?: any }, input: string): Promise<number> {
|
||||
return await flag.parse(input, {}, {});
|
||||
}
|
||||
|
||||
describe(getLimitFlagWithCustomValues, () => {
|
||||
const limit = getLimitFlagWithCustomValues({ defaultTo: 50, limit: 100 });
|
||||
|
||||
it('accepts integers within the range', async () => {
|
||||
await expect(parseFlagAsync(limit, '1')).resolves.toBe(1);
|
||||
await expect(parseFlagAsync(limit, '100')).resolves.toBe(100);
|
||||
});
|
||||
|
||||
it('rejects values outside the range', async () => {
|
||||
await expect(parseFlagAsync(limit, '0')).rejects.toThrow('--limit must be between 1 and 100');
|
||||
await expect(parseFlagAsync(limit, '101')).rejects.toThrow('--limit must be between 1 and 100');
|
||||
});
|
||||
|
||||
it('rejects non-integer input instead of passing it to the server', async () => {
|
||||
await expect(parseFlagAsync(limit, '1.5')).rejects.toThrow('Unable to parse 1.5 as an integer');
|
||||
await expect(parseFlagAsync(limit, 'ten')).rejects.toThrow('Unable to parse ten as an integer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EasPaginatedQueryFlags.offset', () => {
|
||||
it('accepts zero and rejects non-integers', async () => {
|
||||
await expect(parseFlagAsync(EasPaginatedQueryFlags.offset, '0')).resolves.toBe(0);
|
||||
await expect(parseFlagAsync(EasPaginatedQueryFlags.offset, '2.5')).rejects.toThrow(
|
||||
'Unable to parse 2.5 as an integer'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,8 @@ export interface ContextOptions {
|
||||
analytics: Analytics;
|
||||
nonInteractive: boolean;
|
||||
vcsClientOverride?: Client;
|
||||
/** Short-circuits project ID resolution. */
|
||||
projectIdOverride?: string;
|
||||
/**
|
||||
* If specified, env variables from the selected environment will be fetched from the server and used to evaluate the dynamic config.
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,14 @@ import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
|
||||
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
|
||||
|
||||
export class ProjectIdContextField extends ContextField<string> {
|
||||
async getValueAsync({ nonInteractive, sessionManager }: ContextOptions): Promise<string> {
|
||||
async getValueAsync({
|
||||
nonInteractive,
|
||||
sessionManager,
|
||||
projectIdOverride,
|
||||
}: ContextOptions): Promise<string> {
|
||||
if (projectIdOverride) {
|
||||
return projectIdOverride;
|
||||
}
|
||||
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
|
||||
const expBefore = await getPrivateExpoConfigAsync(projectDir);
|
||||
const projectId = await getProjectIdAsync(sessionManager, expBefore, {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ContextOptions } from '../ContextField';
|
||||
import { ProjectIdContextField } from '../ProjectIdContextField';
|
||||
import { findProjectDirAndVerifyProjectSetupAsync } from '../contextUtils/findProjectDirAndVerifyProjectSetupAsync';
|
||||
|
||||
jest.mock('../contextUtils/findProjectDirAndVerifyProjectSetupAsync');
|
||||
|
||||
describe(ProjectIdContextField, () => {
|
||||
it('returns the override without reading the project directory', async () => {
|
||||
const projectId = await new ProjectIdContextField().getValueAsync({
|
||||
projectIdOverride: 'override-project-id',
|
||||
} as ContextOptions);
|
||||
|
||||
expect(projectId).toBe('override-project-id');
|
||||
expect(findProjectDirAndVerifyProjectSetupAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,12 @@ export const EasNonInteractiveAndJsonFlags = {
|
||||
}),
|
||||
};
|
||||
|
||||
export const EasProjectIdFlag = {
|
||||
'project-id': Flags.string({
|
||||
description: 'EAS project ID (defaults to the project ID of the current directory)',
|
||||
}),
|
||||
};
|
||||
|
||||
export function resolveNonInteractiveAndJsonFlags(flags: {
|
||||
json?: boolean;
|
||||
'non-interactive'?: boolean;
|
||||
|
||||
@@ -23,8 +23,8 @@ const parseFlagInputStringAsInteger = (
|
||||
upperLimit: number
|
||||
): number => {
|
||||
const inputAsNumber = Number(input);
|
||||
if (isNaN(inputAsNumber)) {
|
||||
throw new Error(`Unable to parse ${input} as a number`);
|
||||
if (!Number.isInteger(inputAsNumber)) {
|
||||
throw new Error(`Unable to parse ${input} as an integer`);
|
||||
}
|
||||
if (inputAsNumber < lowerLimit || inputAsNumber > upperLimit) {
|
||||
throw new Error(`--${flagName} must be between ${lowerLimit} and ${upperLimit}`);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { GraphqlError } from '../graphql/client';
|
||||
|
||||
export function findPlanGateError(
|
||||
error: unknown,
|
||||
errorCodes: ReadonlySet<string>
|
||||
): GraphQLError | undefined {
|
||||
if (!(error instanceof GraphqlError)) {
|
||||
return undefined;
|
||||
}
|
||||
return error.graphQLErrors.find(e => errorCodes.has(e?.extensions?.errorCode as string));
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { ChannelInsightsQuery } from '../../graphql/queries/ChannelInsightsQuery';
|
||||
import { resolveInsightsTimeRange } from '../../insights/timeRange';
|
||||
import { InsightsTimeRangeFlags, resolveInsightsTimeRange } from '../../insights/timeRange';
|
||||
import Log from '../../log';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
@@ -28,20 +28,7 @@ export default class ChannelInsights extends EasCommand {
|
||||
description: 'Runtime version to query insights for.',
|
||||
required: true,
|
||||
}),
|
||||
days: Flags.integer({
|
||||
description:
|
||||
'Show insights from the last N days (default 7, mutually exclusive with --start/--end).',
|
||||
min: 1,
|
||||
exclusive: ['start', 'end'],
|
||||
}),
|
||||
start: Flags.string({
|
||||
description: 'Start of insights time range (ISO date).',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
end: Flags.string({
|
||||
description: 'End of insights time range (ISO date).',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
...InsightsTimeRangeFlags,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
|
||||
@@ -40,11 +40,12 @@ describe(ObserveVersions, () => {
|
||||
|
||||
function createCommand(argv: string[]): ObserveVersions {
|
||||
const command = new ObserveVersions(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is a protected method
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
jest
|
||||
.spyOn(command as any, 'getContextAsync')
|
||||
.mockImplementation(async (_commandClass: any, { projectIdOverride }: any) => ({
|
||||
projectId: projectIdOverride ?? projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
}));
|
||||
return command;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Flags } from '@oclif/core';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
EasProjectIdFlag,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
@@ -18,7 +19,6 @@ import {
|
||||
ObserveBuildNumberFlag,
|
||||
ObserveEnvironmentFlag,
|
||||
ObservePlatformFlag,
|
||||
ObserveProjectIdFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
ObserveUpdateIdFlag,
|
||||
} from '../../observe/flags';
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
} from '../../observe/formatErrors';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { observePlatformsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
@@ -67,7 +66,7 @@ export default class ObserveErrors extends EasCommand {
|
||||
...ObserveBuildNumberFlag,
|
||||
...ObserveUpdateIdFlag,
|
||||
...ObserveEnvironmentFlag,
|
||||
...ObserveProjectIdFlag,
|
||||
...EasProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
@@ -76,20 +75,16 @@ export default class ObserveErrors extends EasCommand {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private static loggedInOnlyContextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ObserveErrors);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveErrors,
|
||||
loggedInOnlyContextDefinition: ObserveErrors.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ObserveErrors, {
|
||||
nonInteractive,
|
||||
projectIdOverride: flags['project-id'],
|
||||
});
|
||||
|
||||
if (!flags.fingerprint && (flags.after || flags.limit)) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasCommandError } from '../../commandUtils/errors';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
EasProjectIdFlag,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { GraphqlError } from '../../graphql/client';
|
||||
@@ -16,9 +17,7 @@ import {
|
||||
} from '../../observe/formatCustomEvents';
|
||||
import { buildObserveErrorDetail, buildObserveErrorJson } from '../../observe/formatErrors';
|
||||
import { buildObserveEventDetail, buildObserveEventJson } from '../../observe/formatEvents';
|
||||
import { ObserveProjectIdFlag } from '../../observe/flags';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class ObserveEvent extends EasCommand {
|
||||
@@ -33,7 +32,7 @@ export default class ObserveEvent extends EasCommand {
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
...ObserveProjectIdFlag,
|
||||
...EasProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
@@ -42,20 +41,16 @@ export default class ObserveEvent extends EasCommand {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private static loggedInOnlyContextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(ObserveEvent);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveEvent,
|
||||
loggedInOnlyContextDefinition: ObserveEvent.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ObserveEvent, {
|
||||
nonInteractive,
|
||||
projectIdOverride: flags['project-id'],
|
||||
});
|
||||
|
||||
if (json) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Args, Flags } from '@oclif/core';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
EasProjectIdFlag,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
ObserveBuildNumberFlag,
|
||||
ObserveEnvironmentFlag,
|
||||
ObservePlatformFlag,
|
||||
ObserveProjectIdFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
ObserveUpdateIdFlag,
|
||||
} from '../../observe/flags';
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
} from '../../observe/formatCustomEvents';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { observePlatformsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
@@ -67,7 +66,7 @@ export default class ObserveEvents extends EasCommand {
|
||||
'When no event name argument is provided, list all events across all event names instead of a summary of event names + counts.',
|
||||
default: false,
|
||||
}),
|
||||
...ObserveProjectIdFlag,
|
||||
...EasProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
@@ -76,10 +75,6 @@ export default class ObserveEvents extends EasCommand {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private static loggedInOnlyContextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(ObserveEvents);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
@@ -90,12 +85,12 @@ export default class ObserveEvents extends EasCommand {
|
||||
);
|
||||
}
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveEvents,
|
||||
loggedInOnlyContextDefinition: ObserveEvents.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ObserveEvents, {
|
||||
nonInteractive,
|
||||
projectIdOverride: flags['project-id'],
|
||||
});
|
||||
|
||||
if (json) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Flags } from '@oclif/core';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
EasProjectIdFlag,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import Log from '../../log';
|
||||
@@ -10,7 +11,6 @@ import { fetchObserveMetricsAsync } from '../../observe/fetchMetrics';
|
||||
import {
|
||||
ObserveEnvironmentFlag,
|
||||
ObservePlatformFlag,
|
||||
ObserveProjectIdFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
} from '../../observe/flags';
|
||||
import {
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
import { METRIC_ALIASES, resolveMetricName } from '../../observe/metricNames';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { observePlatformTargetsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
@@ -64,7 +63,7 @@ export default class ObserveMetricsSummary extends EasCommand {
|
||||
})(),
|
||||
...ObserveTimeRangeFlags,
|
||||
...ObserveEnvironmentFlag,
|
||||
...ObserveProjectIdFlag,
|
||||
...EasProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
@@ -73,20 +72,16 @@ export default class ObserveMetricsSummary extends EasCommand {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private static loggedInOnlyContextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ObserveMetricsSummary);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveMetricsSummary,
|
||||
loggedInOnlyContextDefinition: ObserveMetricsSummary.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ObserveMetricsSummary, {
|
||||
nonInteractive,
|
||||
projectIdOverride: flags['project-id'],
|
||||
});
|
||||
|
||||
if (json) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasCommandError } from '../../commandUtils/errors';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
EasProjectIdFlag,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
ObserveBuildNumberFlag,
|
||||
ObserveEnvironmentFlag,
|
||||
ObservePlatformFlag,
|
||||
ObserveProjectIdFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
ObserveUpdateIdFlag,
|
||||
} from '../../observe/flags';
|
||||
@@ -28,7 +28,6 @@ import { METRIC_ALIASES, METRIC_SHORT_NAMES, resolveMetricName } from '../../obs
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { buildObserveEventsJson, buildObserveEventsTable } from '../../observe/formatEvents';
|
||||
import { observePlatformTargetsFromFlag, observePlatformsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { selectAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
@@ -64,7 +63,7 @@ export default class ObserveMetrics extends EasCommand {
|
||||
...ObserveBuildNumberFlag,
|
||||
...ObserveUpdateIdFlag,
|
||||
...ObserveEnvironmentFlag,
|
||||
...ObserveProjectIdFlag,
|
||||
...EasProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
@@ -73,20 +72,16 @@ export default class ObserveMetrics extends EasCommand {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private static loggedInOnlyContextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(ObserveMetrics);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveMetrics,
|
||||
loggedInOnlyContextDefinition: ObserveMetrics.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ObserveMetrics, {
|
||||
nonInteractive,
|
||||
projectIdOverride: flags['project-id'],
|
||||
});
|
||||
|
||||
if (json) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Flags } from '@oclif/core';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
EasProjectIdFlag,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
ObserveBuildNumberFlag,
|
||||
ObserveEnvironmentFlag,
|
||||
ObservePlatformFlag,
|
||||
ObserveProjectIdFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
ObserveUpdateIdFlag,
|
||||
} from '../../observe/flags';
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
import { NAVIGATION_METRIC_ALIASES, resolveNavigationMetricName } from '../../observe/metricNames';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { observePlatformTargetsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
@@ -71,7 +70,7 @@ export default class ObserveRoutes extends EasCommand {
|
||||
multiple: true,
|
||||
}),
|
||||
...ObserveEnvironmentFlag,
|
||||
...ObserveProjectIdFlag,
|
||||
...EasProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
@@ -80,20 +79,16 @@ export default class ObserveRoutes extends EasCommand {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private static loggedInOnlyContextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ObserveRoutes);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveRoutes,
|
||||
loggedInOnlyContextDefinition: ObserveRoutes.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ObserveRoutes, {
|
||||
nonInteractive,
|
||||
projectIdOverride: flags['project-id'],
|
||||
});
|
||||
|
||||
if (json) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasCommandError } from '../../commandUtils/errors';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
EasProjectIdFlag,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
@@ -16,11 +17,7 @@ import {
|
||||
fetchSessionMetricCandidatesAsync,
|
||||
verifyObserveSessionAccessAsync,
|
||||
} from '../../observe/fetchSessions';
|
||||
import {
|
||||
ObserveEnvironmentFlag,
|
||||
ObserveProjectIdFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
} from '../../observe/flags';
|
||||
import { ObserveEnvironmentFlag, ObserveTimeRangeFlags } from '../../observe/flags';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import {
|
||||
buildObserveSessionEventsJson,
|
||||
@@ -33,7 +30,6 @@ import {
|
||||
isKnownMetricName,
|
||||
resolveMetricName,
|
||||
} from '../../observe/metricNames';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { ExpoChoice, selectAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
@@ -71,7 +67,7 @@ export default class ObserveSession extends EasCommand {
|
||||
}),
|
||||
...ObserveTimeRangeFlags,
|
||||
...ObserveEnvironmentFlag,
|
||||
...ObserveProjectIdFlag,
|
||||
...EasProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
@@ -80,20 +76,16 @@ export default class ObserveSession extends EasCommand {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private static loggedInOnlyContextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(ObserveSession);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveSession,
|
||||
loggedInOnlyContextDefinition: ObserveSession.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ObserveSession, {
|
||||
nonInteractive,
|
||||
projectIdOverride: flags['project-id'],
|
||||
});
|
||||
|
||||
if (json) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
EasProjectIdFlag,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import Log from '../../log';
|
||||
@@ -8,12 +9,10 @@ import { fetchObserveVersionsAsync } from '../../observe/fetchVersions';
|
||||
import {
|
||||
ObserveEnvironmentFlag,
|
||||
ObservePlatformFlag,
|
||||
ObserveProjectIdFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
} from '../../observe/flags';
|
||||
import { buildObserveVersionsJson, buildObserveVersionsTable } from '../../observe/formatVersions';
|
||||
import { observePlatformTargetsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
@@ -24,7 +23,7 @@ export default class ObserveVersions extends EasCommand {
|
||||
...ObservePlatformFlag,
|
||||
...ObserveTimeRangeFlags,
|
||||
...ObserveEnvironmentFlag,
|
||||
...ObserveProjectIdFlag,
|
||||
...EasProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
@@ -33,20 +32,16 @@ export default class ObserveVersions extends EasCommand {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private static loggedInOnlyContextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ObserveVersions);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveVersions,
|
||||
loggedInOnlyContextDefinition: ObserveVersions.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ObserveVersions, {
|
||||
nonInteractive,
|
||||
projectIdOverride: flags['project-id'],
|
||||
});
|
||||
|
||||
if (json) {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import Log from '../../../log';
|
||||
import { runLocalEgressAsync } from '../../../simulator/egress';
|
||||
import { loadSimulatorEnvAsync } from '../../../simulator/env';
|
||||
import SimulatorEgress from '../egress';
|
||||
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../simulator/env', () => ({
|
||||
...jest.requireActual('../../../simulator/env'),
|
||||
loadSimulatorEnvAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../simulator/egress', () => ({
|
||||
...jest.requireActual('../../../simulator/egress'),
|
||||
runLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const shellSession = {
|
||||
EAS_SIMULATOR_SESSION_ID: 'session-b',
|
||||
EAS_SIMULATOR_EGRESS_ALLOW: '',
|
||||
EAS_SIMULATOR_EGRESS_URL: 'https://egress-b.example.test',
|
||||
EAS_SIMULATOR_EGRESS_TOKEN: 'token-b',
|
||||
EAS_SIMULATOR_EGRESS_FINGERPRINT: 'fingerprint-b',
|
||||
EAS_SIMULATOR_EGRESS_PORT: '8899',
|
||||
};
|
||||
const fileSession = {
|
||||
EAS_SIMULATOR_SESSION_ID: 'session-a',
|
||||
EAS_SIMULATOR_EGRESS_ALLOW: 'localhost:3000',
|
||||
EAS_SIMULATOR_EGRESS_URL: 'https://egress-a.example.test',
|
||||
EAS_SIMULATOR_EGRESS_TOKEN: 'token-a',
|
||||
EAS_SIMULATOR_EGRESS_FINGERPRINT: 'fingerprint-a',
|
||||
EAS_SIMULATOR_EGRESS_PORT: '8898',
|
||||
};
|
||||
|
||||
function createCommand(argv: string[]): SimulatorEgress {
|
||||
const config = new Config({ root: __dirname });
|
||||
config.runHook = async () => ({ failures: [], successes: [] });
|
||||
const command = new SimulatorEgress(argv, config);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({ projectDir: '/test/project' });
|
||||
return command;
|
||||
}
|
||||
|
||||
describe(SimulatorEgress, () => {
|
||||
const previousEnv = Object.fromEntries(
|
||||
Object.keys(shellSession).map(key => [key, process.env[key]])
|
||||
);
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
Object.assign(process.env, shellSession);
|
||||
jest.mocked(loadSimulatorEnvAsync).mockImplementation(async () => {
|
||||
Object.assign(process.env, fileSession);
|
||||
});
|
||||
jest.mocked(runLocalEgressAsync).mockResolvedValue();
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const [key, value] of Object.entries(previousEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the complete exported session tuple without loading a stale dotenv session in env mode', async () => {
|
||||
await createCommand(['--config-type', 'env']).runAsync();
|
||||
|
||||
expect(loadSimulatorEnvAsync).not.toHaveBeenCalled();
|
||||
expect(runLocalEgressAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: shellSession.EAS_SIMULATOR_EGRESS_URL,
|
||||
token: shellSession.EAS_SIMULATOR_EGRESS_TOKEN,
|
||||
fingerprint: shellSession.EAS_SIMULATOR_EGRESS_FINGERPRINT,
|
||||
port: 8899,
|
||||
allow: [],
|
||||
})
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('session-b'));
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('eas simulator:stop --id session-b')
|
||||
);
|
||||
});
|
||||
|
||||
it.each([[], ['--config-type', 'dotenv']])(
|
||||
'loads the managed session by default or with explicit dotenv selection (%j)',
|
||||
async (...argv) => {
|
||||
await createCommand(argv).runAsync();
|
||||
|
||||
expect(loadSimulatorEnvAsync).toHaveBeenCalledWith('/test/project');
|
||||
expect(runLocalEgressAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: fileSession.EAS_SIMULATOR_EGRESS_URL,
|
||||
token: fileSession.EAS_SIMULATOR_EGRESS_TOKEN,
|
||||
fingerprint: fileSession.EAS_SIMULATOR_EGRESS_FINGERPRINT,
|
||||
port: 8898,
|
||||
allow: ['localhost:3000'],
|
||||
})
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('session-a'));
|
||||
}
|
||||
);
|
||||
|
||||
it('does not fill missing exported credentials from a stale file in env mode', async () => {
|
||||
delete process.env.EAS_SIMULATOR_EGRESS_TOKEN;
|
||||
|
||||
await expect(createCommand(['--config-type', 'env']).runAsync()).rejects.toThrow(
|
||||
'no egress client to run'
|
||||
);
|
||||
|
||||
expect(loadSimulatorEnvAsync).not.toHaveBeenCalled();
|
||||
expect(runLocalEgressAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
AppPlatform,
|
||||
CreateDeviceRunSessionMutation,
|
||||
DeviceRunSessionByIdQuery,
|
||||
DeviceRunSessionEgress,
|
||||
DeviceRunSessionResourceClass,
|
||||
DeviceRunSessionStatus,
|
||||
DeviceRunSessionType,
|
||||
@@ -17,6 +18,7 @@ import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSession
|
||||
import Log from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import { runLocalEgressAsync } from '../../../simulator/egress';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_HEADER,
|
||||
@@ -38,6 +40,8 @@ jest.mock('../../../log', () => ({
|
||||
log: jest.fn(),
|
||||
newLine: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
succeed: jest.fn(),
|
||||
withTick: jest.fn(),
|
||||
},
|
||||
link: jest.fn((url: string) => url),
|
||||
@@ -49,6 +53,10 @@ jest.mock('../../../simulator/env', () => ({
|
||||
}));
|
||||
jest.mock('../../../simulator/expoGo');
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../simulator/egress', () => ({
|
||||
...jest.requireActual('../../../simulator/egress'),
|
||||
runLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../ora', () => ({
|
||||
ora: jest.fn(() => {
|
||||
const spinner = {
|
||||
@@ -856,6 +864,239 @@ describe(Simulator, () => {
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
const localEgressSessions: [
|
||||
string,
|
||||
DeviceRunSessionType,
|
||||
NonNullable<DeviceRunSessionById['remoteConfig']>,
|
||||
][] = [
|
||||
[
|
||||
'agent-device',
|
||||
DeviceRunSessionType.AgentDevice,
|
||||
{
|
||||
__typename: 'AgentDeviceRunSessionRemoteConfig',
|
||||
agentDeviceRemoteSessionUrl: 'https://agent.example.com',
|
||||
agentDeviceRemoteSessionToken: 'token',
|
||||
},
|
||||
],
|
||||
[
|
||||
'appium',
|
||||
DeviceRunSessionType.Appium,
|
||||
{
|
||||
__typename: 'AppiumRunSessionRemoteConfig',
|
||||
appiumUrl: 'https://appium.example.com',
|
||||
capabilities: {},
|
||||
},
|
||||
],
|
||||
[
|
||||
'argent',
|
||||
DeviceRunSessionType.Argent,
|
||||
{
|
||||
__typename: 'ArgentRunSessionRemoteConfig',
|
||||
toolsUrl: 'https://argent.example.com',
|
||||
},
|
||||
],
|
||||
[
|
||||
'web-preview-only',
|
||||
DeviceRunSessionType.WebPreviewOnly,
|
||||
{
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
|
||||
previewUrl: 'https://preview.example.com',
|
||||
},
|
||||
],
|
||||
[
|
||||
'web-preview-only',
|
||||
DeviceRunSessionType.ServeSim,
|
||||
{
|
||||
__typename: 'ServeSimRunSessionRemoteConfig',
|
||||
previewUrl: 'https://preview.example.com',
|
||||
},
|
||||
],
|
||||
];
|
||||
const localEgressFields = {
|
||||
egressUrl: 'https://egress.example.com',
|
||||
egressToken: 'pw',
|
||||
egressFingerprint: 'fp',
|
||||
egressPort: 8899,
|
||||
};
|
||||
|
||||
it.each(localEgressSessions)(
|
||||
'creates iOS %s sessions with local egress and saves connection details',
|
||||
async (typeFlag, type, remoteConfig) => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({
|
||||
type,
|
||||
remoteConfig: { ...remoteConfig, ...localEgressFields },
|
||||
})
|
||||
);
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--type',
|
||||
typeFlag,
|
||||
'--egress',
|
||||
'local',
|
||||
'--non-interactive',
|
||||
]);
|
||||
await command.runAsync();
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({
|
||||
platform: AppPlatform.Ios,
|
||||
egress: DeviceRunSessionEgress.Local,
|
||||
})
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
simulatorDotenvPath,
|
||||
expect.stringContaining("EAS_SIMULATOR_EGRESS_TOKEN='pw'")
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('eas simulator:egress'));
|
||||
expect(runLocalEgressAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('prints the shell credential command when local egress uses env output', async () => {
|
||||
const [typeFlag, type, remoteConfig] = localEgressSessions[1];
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({
|
||||
type,
|
||||
remoteConfig: { ...remoteConfig, ...localEgressFields },
|
||||
})
|
||||
);
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--type',
|
||||
typeFlag,
|
||||
'--egress',
|
||||
'local',
|
||||
'--out-config-type',
|
||||
'env',
|
||||
'--non-interactive',
|
||||
]);
|
||||
await command.runAsync();
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Run the egress client to connect the tunnel:\n\neas simulator:egress --config-type env\n'
|
||||
)
|
||||
);
|
||||
expect(Log.log).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Run the egress client to connect the tunnel:\n\neas simulator:egress\n'
|
||||
)
|
||||
);
|
||||
// The instructions block is the only place the command is mentioned.
|
||||
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('in another process'));
|
||||
});
|
||||
|
||||
it.each(localEgressSessions)(
|
||||
'rejects local egress for Android %s before creating a session',
|
||||
async typeFlag => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'android',
|
||||
'--type',
|
||||
typeFlag,
|
||||
'--egress',
|
||||
'local',
|
||||
'--non-interactive',
|
||||
]);
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'--egress local is only supported with --platform ios.'
|
||||
);
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(localEgressSessions)(
|
||||
'stops %s after an interactive egress failure and removes its interrupt handler',
|
||||
async (typeFlag, type, remoteConfig) => {
|
||||
const failure = new Error('listen EADDRINUSE');
|
||||
jest.mocked(runLocalEgressAsync).mockRejectedValueOnce(failure);
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({
|
||||
type,
|
||||
remoteConfig: { ...remoteConfig, ...localEgressFields },
|
||||
})
|
||||
);
|
||||
const listeners = process.listeners('SIGINT');
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--type',
|
||||
typeFlag,
|
||||
'--egress',
|
||||
'local',
|
||||
]);
|
||||
await expect(command.runAsync()).rejects.toBe(failure);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
}
|
||||
);
|
||||
|
||||
it('passes normalized --egress-allow destinations to the egress client', async () => {
|
||||
const session = makeDeviceRunSession({
|
||||
remoteConfig: {
|
||||
__typename: 'AgentDeviceRunSessionRemoteConfig',
|
||||
agentDeviceRemoteSessionUrl: 'https://agent.example.com',
|
||||
agentDeviceRemoteSessionToken: 'token',
|
||||
egressUrl: 'https://egress.example.com',
|
||||
egressToken: 'pw',
|
||||
egressFingerprint: 'fp',
|
||||
egressPort: 8899,
|
||||
},
|
||||
});
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(session)
|
||||
.mockResolvedValueOnce({ ...session, status: DeviceRunSessionStatus.Stopped });
|
||||
jest.mocked(runLocalEgressAsync).mockResolvedValueOnce(undefined);
|
||||
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--egress',
|
||||
'local',
|
||||
'--egress-allow',
|
||||
'localhost:3000',
|
||||
'--egress-allow',
|
||||
'LOCALHOST:3000',
|
||||
'--egress-allow',
|
||||
'192.168.1.20:8080',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(runLocalEgressAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ allow: ['localhost:3000', '192.168.1.20:8080'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects malformed --egress-allow values before creating a session', async () => {
|
||||
// If validation were skipped, fail fast instead of polling a live session.
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({ status: DeviceRunSessionStatus.Stopped })
|
||||
);
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--egress',
|
||||
'local',
|
||||
'--egress-allow',
|
||||
'localhost',
|
||||
]);
|
||||
await expect(command.runAsync()).rejects.toThrow('Invalid --egress-allow value "localhost"');
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires --egress for --egress-allow', async () => {
|
||||
const { command } = createCommand(['--platform', 'ios', '--egress-allow', 'localhost:3000']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/--egress/);
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prompts to select the platform when --platform is omitted', async () => {
|
||||
mockPromptAsync.mockResolvedValueOnce({ selectedPlatform: AppPlatform.Android });
|
||||
mockByIdAsync
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import Log from '../../log';
|
||||
import { readLocalEgressConfigFromEnv, runLocalEgressAsync } from '../../simulator/egress';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
loadSimulatorEnvAsync,
|
||||
} from '../../simulator/env';
|
||||
|
||||
export default class SimulatorEgress extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override aliases = ['sim:egress'];
|
||||
static override description = `[EXPERIMENTAL] run the local egress client for a simulator session to route proxied HTTP(S) requests through this machine`;
|
||||
|
||||
static override flags = {
|
||||
'config-type': Flags.option({
|
||||
description: `Read session credentials from ${SIMULATOR_DOTENV_FILE_NAME} (dotenv) or the current shell environment (env).`,
|
||||
options: ['dotenv', 'env'] as const,
|
||||
default: 'dotenv',
|
||||
})(),
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectDir,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(SimulatorEgress);
|
||||
const { projectDir } = await this.getContextAsync(SimulatorEgress, {
|
||||
nonInteractive: true,
|
||||
});
|
||||
if (flags['config-type'] === 'dotenv') {
|
||||
await loadSimulatorEnvAsync(projectDir);
|
||||
}
|
||||
|
||||
const egress = readLocalEgressConfigFromEnv(process.env);
|
||||
const deviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
|
||||
Log.log(
|
||||
`Starting the local egress client${
|
||||
deviceRunSessionId ? ` for simulator session ${deviceRunSessionId}` : ''
|
||||
}. When connected, proxied HTTP(S) requests can use this machine's network.`
|
||||
);
|
||||
Log.log(
|
||||
'Press Ctrl+C to stop the egress client. The simulator session keeps running; ' +
|
||||
`proxied HTTP(S) requests are unavailable until the tunnel reconnects. Stop the session with \`eas simulator:stop${deviceRunSessionId ? ` --id ${deviceRunSessionId}` : ''}\`.`
|
||||
);
|
||||
Log.newLine();
|
||||
|
||||
const abortController = new AbortController();
|
||||
const handleSigint = (): void => {
|
||||
abortController.abort();
|
||||
};
|
||||
process.on('SIGINT', handleSigint);
|
||||
try {
|
||||
await runLocalEgressAsync({
|
||||
...egress,
|
||||
signal: abortController.signal,
|
||||
onConnected: () => {
|
||||
Log.succeed('Egress tunnel connected.');
|
||||
},
|
||||
onDisconnected: () => {
|
||||
Log.warn(
|
||||
'Egress tunnel disconnected; reconnecting. Proxied HTTP(S) requests are unavailable until it reconnects.'
|
||||
);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
process.removeListener('SIGINT', handleSigint);
|
||||
}
|
||||
Log.log('Egress client stopped.');
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,11 @@ export default class SimulatorGet extends EasCommand {
|
||||
if (session.status === DeviceRunSessionStatus.InProgress) {
|
||||
Log.newLine();
|
||||
if (session.remoteConfig) {
|
||||
Log.log(formatRemoteSessionInstructions(session.remoteConfig, 'env'));
|
||||
Log.log(
|
||||
formatRemoteSessionInstructions(session.remoteConfig, 'env', {
|
||||
sessionUrl: deviceRunSessionUrl,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
Log.log(
|
||||
'⏳ Session is starting up — remote config is not available yet. Re-run this command in a moment.'
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '../../commandUtils/flags';
|
||||
import {
|
||||
AppPlatform,
|
||||
DeviceRunSessionEgress,
|
||||
DeviceRunSessionStatus,
|
||||
DeviceRunSessionType,
|
||||
JobRunStatus,
|
||||
@@ -21,6 +22,7 @@ import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQue
|
||||
import Log, { link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { parseEgressAllowList, runLocalEgressAsync } from '../../simulator/egress';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
@@ -37,6 +39,7 @@ import {
|
||||
DeviceRunSessionRemoteConfig,
|
||||
formatRemoteSessionInstructions,
|
||||
formatSimulatorUnavailableMessage,
|
||||
getLocalEgressConfig,
|
||||
getRemoteSessionEnvironmentVariables,
|
||||
sanitizeRemoteConfigForJson,
|
||||
} from '../../simulator/utils';
|
||||
@@ -51,6 +54,7 @@ const OUT_CONFIG_TYPE_VALUES = {
|
||||
} as const;
|
||||
const PLATFORM_FLAG_VALUES = ['android', 'ios'] as const;
|
||||
type PlatformFlagValue = (typeof PLATFORM_FLAG_VALUES)[number];
|
||||
const EGRESS_FLAG_VALUES = ['local'] as const;
|
||||
const APP_PLATFORM_BY_FLAG_VALUE: Record<PlatformFlagValue, AppPlatform> = {
|
||||
android: AppPlatform.Android,
|
||||
ios: AppPlatform.Ios,
|
||||
@@ -133,6 +137,17 @@ export default class Simulator extends EasCommand {
|
||||
hidden: true,
|
||||
options: Object.values(DEVICE_RUN_SESSION_RESOURCE_CLASS_FLAG_VALUES),
|
||||
})(),
|
||||
egress: Flags.option({
|
||||
description:
|
||||
'With "local", the simulator system proxy points at this machine: HTTP(S) and WebSocket requests that honor it (WebKit, URLSession) and clients that read proxy environment variables (gRPC, libcurl) exit from this machine and fail while the egress client is disconnected. Connections that ignore both are refused inside the simulator and listed, with the library that tried, in the Logs section of the session page on expo.dev. The egress client must keep running for the life of the session. Only supported with --platform ios.',
|
||||
options: EGRESS_FLAG_VALUES,
|
||||
})(),
|
||||
'egress-allow': Flags.string({
|
||||
description:
|
||||
'Destination on this machine or its network that the simulator may reach through local egress, as an exact host:port (for example localhost:3000). Repeat for multiple destinations. A localhost or 127.0.0.1 entry also forwards that port from the simulator host to this machine (like adb reverse), so dev server URLs that use 127.0.0.1 work. Requires --egress local.',
|
||||
multiple: true,
|
||||
dependsOn: ['egress'],
|
||||
}),
|
||||
force: Flags.boolean({
|
||||
description:
|
||||
'[default: true] Create a new simulator session even when an existing simulator session is present in the environment.',
|
||||
@@ -219,6 +234,16 @@ export default class Simulator extends EasCommand {
|
||||
}
|
||||
|
||||
const platform = await resolvePlatformAsync(flags.platform, nonInteractive);
|
||||
const egress = flags.egress === 'local' ? DeviceRunSessionEgress.Local : undefined;
|
||||
if (egress && platform !== AppPlatform.Ios) {
|
||||
throw new EasCommandError('--egress local is only supported with --platform ios.');
|
||||
}
|
||||
let egressAllow: string[] = [];
|
||||
try {
|
||||
egressAllow = parseEgressAllowList(flags['egress-allow'] ?? []);
|
||||
} catch (err) {
|
||||
throw new EasCommandError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
if (platform === AppPlatform.Android) {
|
||||
Log.warn(
|
||||
'Android emulator support in EAS Simulator is still in development. Some features available on iOS may not work on Android yet. Full parity with iOS is coming soon.'
|
||||
@@ -263,6 +288,7 @@ export default class Simulator extends EasCommand {
|
||||
...(launchArgs?.length ? { launchArgs } : {}),
|
||||
...(openUrl ? { openUrl } : {}),
|
||||
...(resourceClass ? { resourceClass } : {}),
|
||||
...(egress ? { egress } : {}),
|
||||
maxRunTimeMinutes: flags['max-duration-minutes'],
|
||||
maxIdleTimeMinutes: flags['max-idle-time-minutes'],
|
||||
});
|
||||
@@ -363,7 +389,7 @@ export default class Simulator extends EasCommand {
|
||||
|
||||
if (flags['out-config-type'] === OUT_CONFIG_TYPE_VALUES.Dotenv) {
|
||||
await writeSimulatorEnvSafelyAsync(projectDir, {
|
||||
...getRemoteSessionEnvironmentVariables(remoteConfig),
|
||||
...getRemoteSessionEnvironmentVariables(remoteConfig, { egressAllow }),
|
||||
[EAS_SIMULATOR_SESSION_ID]: deviceRunSessionId,
|
||||
});
|
||||
}
|
||||
@@ -392,24 +418,74 @@ export default class Simulator extends EasCommand {
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
Log.log(formatRemoteSessionInstructions(remoteConfig, flags['out-config-type']));
|
||||
Log.log(
|
||||
formatRemoteSessionInstructions(remoteConfig, flags['out-config-type'], {
|
||||
egressAllow,
|
||||
egressClientRunsInline: !nonInteractive,
|
||||
sessionUrl: deviceRunSessionUrl,
|
||||
})
|
||||
);
|
||||
Log.newLine();
|
||||
|
||||
const localEgress = getLocalEgressConfig(remoteConfig, egressAllow);
|
||||
|
||||
if (nonInteractive) {
|
||||
sessionInterrupt.dispose();
|
||||
// The instructions above already tell the reader to run eas simulator:egress.
|
||||
Log.log(
|
||||
`When you are done, stop the session with: eas simulator:stop --id ${deviceRunSessionId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await waitForSessionEndOrInterruptAsync({
|
||||
graphqlClient,
|
||||
deviceRunSessionId,
|
||||
deviceRunSessionUrl,
|
||||
projectDir,
|
||||
sessionInterrupt,
|
||||
});
|
||||
// In interactive mode the egress client runs right here, for as long as the
|
||||
// session does. A session with a disconnected tunnel still bills, so Ctrl+C stops both.
|
||||
let egressPromise: Promise<void> | undefined;
|
||||
let egressError: Error | undefined;
|
||||
const egressAbortController = new AbortController();
|
||||
if (localEgress) {
|
||||
sessionInterrupt.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
egressAbortController.abort();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
egressPromise = runLocalEgressAsync({
|
||||
...localEgress,
|
||||
signal: egressAbortController.signal,
|
||||
onConnected: () => {
|
||||
Log.succeed('Egress tunnel connected.');
|
||||
},
|
||||
onDisconnected: () => {
|
||||
Log.warn(
|
||||
'Egress tunnel disconnected; reconnecting. Proxied HTTP(S) requests are unavailable until it reconnects.'
|
||||
);
|
||||
},
|
||||
}).catch(err => {
|
||||
egressError = err instanceof Error ? err : new Error(String(err));
|
||||
Log.error(
|
||||
`${err instanceof Error ? err.message : String(err)} Stopping the simulator session.`
|
||||
);
|
||||
sessionInterrupt.abort();
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForSessionEndOrInterruptAsync({
|
||||
graphqlClient,
|
||||
deviceRunSessionId,
|
||||
deviceRunSessionUrl,
|
||||
projectDir,
|
||||
sessionInterrupt,
|
||||
});
|
||||
} finally {
|
||||
egressAbortController.abort();
|
||||
await egressPromise;
|
||||
}
|
||||
if (egressError) {
|
||||
throw egressError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,6 +603,8 @@ async function waitForSessionEndOrInterruptAsync({
|
||||
type SessionInterrupt = {
|
||||
signal: AbortSignal;
|
||||
abortPromise: Promise<void>;
|
||||
/** Stop the session as if the user had pressed Ctrl+C. */
|
||||
abort: () => void;
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
@@ -558,6 +636,9 @@ function registerSessionInterrupt(deviceRunSessionId: string): SessionInterrupt
|
||||
return {
|
||||
signal,
|
||||
abortPromise,
|
||||
abort: () => {
|
||||
abortController.abort();
|
||||
},
|
||||
dispose: () => process.removeListener('SIGINT', sigintHandler),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { UpdateInsightsQuery } from '../../graphql/queries/UpdateInsightsQuery';
|
||||
import { INSIGHTS_DEFAULT_DAYS_BACK, resolveInsightsTimeRange } from '../../insights/timeRange';
|
||||
import { InsightsTimeRangeFlags, resolveInsightsTimeRange } from '../../insights/timeRange';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
buildUpdateInsightsJson,
|
||||
@@ -31,19 +31,7 @@ export default class UpdateInsights extends EasCommand {
|
||||
description: 'Filter to a single platform.',
|
||||
options: ['ios', 'android'] as const,
|
||||
})(),
|
||||
days: Flags.integer({
|
||||
description: `Show insights from the last N days (default ${INSIGHTS_DEFAULT_DAYS_BACK}, mutually exclusive with --start/--end).`,
|
||||
min: 1,
|
||||
exclusive: ['start', 'end'],
|
||||
}),
|
||||
start: Flags.string({
|
||||
description: 'Start of insights time range (ISO date).',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
end: Flags.string({
|
||||
description: 'End of insights time range (ISO date).',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
...InsightsTimeRangeFlags,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { WorkflowDeviceTestCaseStatus } from '../../../graphql/generated';
|
||||
import { WorkflowDeviceTestCaseInsightsQuery } from '../../../graphql/queries/WorkflowDeviceTestCaseInsightsQuery';
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import WorkflowInsightsMaestro from '../insights/maestro';
|
||||
|
||||
jest.mock('../../../graphql/queries/WorkflowDeviceTestCaseInsightsQuery', () => ({
|
||||
WorkflowDeviceTestCaseInsightsQuery: {
|
||||
insightsByAppIdAsync: jest.fn(),
|
||||
historyByAppIdAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('../../../graphql/queries/AppQuery');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockInsightsByAppIdAsync = jest.mocked(
|
||||
WorkflowDeviceTestCaseInsightsQuery.insightsByAppIdAsync
|
||||
);
|
||||
const mockHistoryByAppIdAsync = jest.mocked(
|
||||
WorkflowDeviceTestCaseInsightsQuery.historyByAppIdAsync
|
||||
);
|
||||
const mockByIdWorkflowFileNamesAsync = jest.mocked(AppQuery.byIdWorkflowFileNamesAsync);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
function metric(currentValue: number, previousValue: number): any {
|
||||
return { __typename: 'WorkflowDeviceTestCaseInsightsMetric', currentValue, previousValue };
|
||||
}
|
||||
|
||||
const totals: any = {
|
||||
__typename: 'WorkflowDeviceTestCaseInsightsTotals',
|
||||
totalRuns: metric(200, 160),
|
||||
passedCleanCount: metric(170, 140),
|
||||
flakyCount: metric(10, 4),
|
||||
failedCount: metric(20, 16),
|
||||
distinctFlakyTestCount: metric(3, 2),
|
||||
avgDurationMs: { currentValue: 42100, previousValue: 45000 },
|
||||
p90DurationMs: { currentValue: 72000, previousValue: null },
|
||||
};
|
||||
|
||||
const timeSeries: any[] = [
|
||||
{ bucketStartAt: '2026-09-01T00:00:00.000Z', passedClean: 100, flaky: 5, failed: 10 },
|
||||
];
|
||||
|
||||
const insightsResponse: any = {
|
||||
__typename: 'App',
|
||||
id: 'app-1',
|
||||
fullName: '@acme/app',
|
||||
workflowDeviceTestCaseInsights: {
|
||||
totals,
|
||||
timeSeries,
|
||||
tests: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
path: 'flows/login.yaml',
|
||||
name: 'Login',
|
||||
totalRuns: 120,
|
||||
passedCleanCount: 100,
|
||||
flakyCount: 8,
|
||||
failedCount: 12,
|
||||
avgDurationMs: 40000,
|
||||
p90DurationMs: 70000,
|
||||
lastRunAt: '2026-09-02T10:30:00.000Z',
|
||||
lastRunStatus: WorkflowDeviceTestCaseStatus.Failed,
|
||||
lastRunIsFlaky: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
pageInfo: { hasNextPage: false },
|
||||
totalCount: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const historyResponse: any = {
|
||||
__typename: 'App',
|
||||
id: 'app-1',
|
||||
fullName: '@acme/app',
|
||||
workflowDeviceTestCaseHistory: {
|
||||
totals,
|
||||
timeSeries,
|
||||
errorPatterns: [
|
||||
{
|
||||
sampleMessage: 'Element not found',
|
||||
count: 7,
|
||||
},
|
||||
],
|
||||
recentRuns: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
id: 'r1',
|
||||
status: WorkflowDeviceTestCaseStatus.Failed,
|
||||
durationMs: 41000,
|
||||
isFlaky: false,
|
||||
createdAt: '2026-09-02T10:30:00.000Z',
|
||||
workflowRunId: 'wr-1',
|
||||
workflowRunName: 'E2E #42',
|
||||
gitRef: 'refs/heads/main',
|
||||
commitSha: 'abcdef1234567',
|
||||
},
|
||||
},
|
||||
],
|
||||
pageInfo: { hasNextPage: false },
|
||||
totalCount: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe(WorkflowInsightsMaestro, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const now = new Date('2026-09-08T12:00:00.000Z');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers({ now });
|
||||
mockInsightsByAppIdAsync.mockResolvedValue(insightsResponse);
|
||||
mockHistoryByAppIdAsync.mockResolvedValue(historyResponse);
|
||||
mockByIdWorkflowFileNamesAsync.mockResolvedValue([
|
||||
{ id: 'id-of-e2e.yml', fileName: 'e2e.yml' },
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): {
|
||||
command: WorkflowInsightsMaestro;
|
||||
getContextAsync: jest.SpyInstance;
|
||||
} {
|
||||
const command = new WorkflowInsightsMaestro(argv, mockConfig);
|
||||
const getContextAsync = jest
|
||||
.spyOn(command as any, 'getContextAsync')
|
||||
.mockImplementation(async (_commandClass: any, { projectIdOverride }: any) => ({
|
||||
projectId: projectIdOverride ?? 'app-1',
|
||||
loggedIn: { graphqlClient },
|
||||
}));
|
||||
return { command, getContextAsync };
|
||||
}
|
||||
|
||||
it('queries the overview for the last 7 days sorted by fails with the default limit', async () => {
|
||||
const { command } = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockInsightsByAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: 'app-1',
|
||||
startTime: '2026-09-01T00:00:00.000Z',
|
||||
endTime: '2026-09-09T00:00:00.000Z',
|
||||
filters: undefined,
|
||||
granularity: 'DAY',
|
||||
sortField: 'FAILS',
|
||||
sortDirection: 'DESC',
|
||||
search: undefined,
|
||||
first: 50,
|
||||
});
|
||||
expect(mockHistoryByAppIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes filters, search, sort, and limit to the server', async () => {
|
||||
const { command } = createCommand([
|
||||
'--workflow',
|
||||
'e2e.yml',
|
||||
'--status',
|
||||
'PASSED',
|
||||
'--status',
|
||||
'FLAKY',
|
||||
'--tag',
|
||||
'smoke',
|
||||
'--git-ref',
|
||||
'main',
|
||||
'--search',
|
||||
'login',
|
||||
'--sort',
|
||||
'pass-rate',
|
||||
'--sort-direction',
|
||||
'asc',
|
||||
'--limit',
|
||||
'10',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockByIdWorkflowFileNamesAsync).toHaveBeenCalledWith(graphqlClient, 'app-1');
|
||||
expect(mockInsightsByAppIdAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({
|
||||
filters: {
|
||||
workflowIds: ['id-of-e2e.yml'],
|
||||
statuses: ['PASSED_CLEAN', 'FLAKY'],
|
||||
tags: ['smoke'],
|
||||
gitRefs: ['refs/heads/main'],
|
||||
},
|
||||
search: 'login',
|
||||
sortField: 'PASS_RATE',
|
||||
sortDirection: 'ASC',
|
||||
first: 10,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('queries one flow history with --flow, keeping only the workflow and git ref filters', async () => {
|
||||
const { command } = createCommand([
|
||||
'--flow',
|
||||
'flows/login.yaml',
|
||||
'--workflow',
|
||||
'e2e.yml',
|
||||
'--git-ref',
|
||||
'main',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockHistoryByAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: 'app-1',
|
||||
path: 'flows/login.yaml',
|
||||
startTime: '2026-09-01T00:00:00.000Z',
|
||||
endTime: '2026-09-09T00:00:00.000Z',
|
||||
filters: { workflowIds: ['id-of-e2e.yml'], gitRefs: ['refs/heads/main'] },
|
||||
granularity: 'DAY',
|
||||
errorPatternsFirst: 5,
|
||||
recentRunsFirst: 50,
|
||||
});
|
||||
expect(mockInsightsByAppIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects overview-only flags together with --flow', async () => {
|
||||
const { command } = createCommand(['--flow', 'flows/login.yaml', '--status', 'FAILED']);
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
expect(mockHistoryByAppIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects unknown statuses', async () => {
|
||||
const { command } = createCommand(['--status', 'BROKEN']);
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('skips the project directory when --project-id is given', async () => {
|
||||
const { command, getContextAsync } = createCommand(['--project-id', 'other-app']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(getContextAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ projectIdOverride: 'other-app' })
|
||||
);
|
||||
expect(mockInsightsByAppIdAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ appId: 'other-app' })
|
||||
);
|
||||
});
|
||||
|
||||
it('emits the overview as JSON', async () => {
|
||||
const { command } = createCommand(['--json', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
const json = mockPrintJsonOnlyOutput.mock.calls[0][0] as any;
|
||||
expect(json.project).toBe('@acme/app');
|
||||
expect(json.totals.passRatePercent).toEqual({ current: 90, previous: 90 });
|
||||
expect(json.totals.avgDurationMs).toEqual({ current: 42100, previous: 45000 });
|
||||
expect(json.flows[0].path).toBe('flows/login.yaml');
|
||||
expect(json.sort).toEqual({ field: 'fails', direction: 'desc' });
|
||||
});
|
||||
|
||||
it('emits a flow history as JSON', async () => {
|
||||
const { command } = createCommand([
|
||||
'--flow',
|
||||
'flows/login.yaml',
|
||||
'--json',
|
||||
'--non-interactive',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
const json = mockPrintJsonOnlyOutput.mock.calls[0][0] as any;
|
||||
expect(json.flow).toBe('flows/login.yaml');
|
||||
expect(json.errorPatterns).toHaveLength(1);
|
||||
expect(json.recentRuns[0].workflowRunId).toBe('wr-1');
|
||||
});
|
||||
|
||||
it('turns a plan-gate rejection into a readable error', async () => {
|
||||
const graphQLError = new GraphQLError(
|
||||
'Maestro insights are not available for your current plan.',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{ errorCode: 'EAS_WORKFLOW_DEVICE_TEST_CASE_INSIGHTS_NOT_AVAILABLE' }
|
||||
);
|
||||
mockInsightsByAppIdAsync.mockRejectedValue(
|
||||
new CombinedError({ graphQLErrors: [graphQLError] })
|
||||
);
|
||||
|
||||
const { command } = createCommand([]);
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'Maestro insights are not available for your current plan.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import { WorkflowsInsightsQuery } from '../../../graphql/queries/WorkflowsInsightsQuery';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import WorkflowInsights from '../insights';
|
||||
|
||||
jest.mock('../../../graphql/queries/WorkflowsInsightsQuery', () => ({
|
||||
WorkflowsInsightsQuery: { byAppIdAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../../graphql/queries/AppQuery');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockByAppIdAsync = jest.mocked(WorkflowsInsightsQuery.byAppIdAsync);
|
||||
const mockByIdWorkflowFileNamesAsync = jest.mocked(AppQuery.byIdWorkflowFileNamesAsync);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
function metric(currentValue: number, previousValue: number): any {
|
||||
return { __typename: 'WorkflowsInsightsMetric', currentValue, previousValue };
|
||||
}
|
||||
|
||||
const workflows = [{ __typename: 'Workflow', id: 'wf-build', fileName: 'build.yml' }];
|
||||
|
||||
const appResponse: any = {
|
||||
__typename: 'App',
|
||||
id: 'app-1',
|
||||
fullName: '@acme/app',
|
||||
workflows,
|
||||
workflowsInsights: {
|
||||
__typename: 'AppWorkflowsInsights',
|
||||
overviewMetrics: {
|
||||
__typename: 'WorkflowsInsightsOverviewMetrics',
|
||||
totalRuns: metric(100, 80),
|
||||
successfulRuns: metric(75, 64),
|
||||
failedRuns: metric(20, 10),
|
||||
activeWorkflows: metric(3, 3),
|
||||
},
|
||||
runsOverTime: {
|
||||
__typename: 'WorkflowsInsightsRunsOverTimeData',
|
||||
lineChart: {
|
||||
__typename: 'LineChartData',
|
||||
labels: ['2026-09-01T00:00:00.000Z'],
|
||||
datasets: [
|
||||
{ id: 'WorkflowsInsightsRunsOverTimeDataset:total', label: 'Total Runs', data: [100] },
|
||||
{
|
||||
id: 'WorkflowsInsightsRunsOverTimeDataset:success',
|
||||
data: [75],
|
||||
},
|
||||
{ id: 'WorkflowsInsightsRunsOverTimeDataset:failure', label: 'Failed Runs', data: [20] },
|
||||
{
|
||||
id: 'WorkflowsInsightsRunsOverTimeDataset:canceled',
|
||||
data: [5],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
workflows: {
|
||||
__typename: 'WorkflowsInsightsWorkflowConnection',
|
||||
edges: [
|
||||
{
|
||||
__typename: 'WorkflowsInsightsWorkflowEdge',
|
||||
node: {
|
||||
__typename: 'WorkflowsInsightsWorkflowNode',
|
||||
workflowId: 'wf-build',
|
||||
name: 'Build',
|
||||
totalRuns: 100,
|
||||
successfulRuns: 75,
|
||||
failedRuns: 20,
|
||||
canceledRuns: 5,
|
||||
lastRunAt: '2026-09-01T10:00:00.000Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
pageInfo: { __typename: 'PageInfo', hasNextPage: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe(WorkflowInsights, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const now = new Date('2026-09-08T12:00:00.000Z');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers({ now });
|
||||
mockByAppIdAsync.mockResolvedValue(appResponse);
|
||||
mockByIdWorkflowFileNamesAsync.mockResolvedValue(workflows);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): {
|
||||
command: WorkflowInsights;
|
||||
getContextAsync: jest.SpyInstance;
|
||||
} {
|
||||
const command = new WorkflowInsights(argv, mockConfig);
|
||||
const getContextAsync = jest
|
||||
.spyOn(command as any, 'getContextAsync')
|
||||
.mockImplementation(async (_commandClass: any, { projectIdOverride }: any) => ({
|
||||
projectId: projectIdOverride ?? 'app-1',
|
||||
loggedIn: { graphqlClient },
|
||||
}));
|
||||
return { command, getContextAsync };
|
||||
}
|
||||
|
||||
it('queries the last 7 days by whole days with no filters and the default limit', async () => {
|
||||
const { command } = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockByAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: 'app-1',
|
||||
startTime: '2026-09-01T00:00:00.000Z',
|
||||
endTime: '2026-09-09T00:00:00.000Z',
|
||||
filters: undefined,
|
||||
granularity: 'DAY',
|
||||
first: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('picks hourly buckets for --days 1 and per-minute buckets for a short explicit range', async () => {
|
||||
const { command: dayCommand } = createCommand(['--days', '1']);
|
||||
await dayCommand.runAsync();
|
||||
expect(mockByAppIdAsync).toHaveBeenLastCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ startTime: '2026-09-07T12:00:00.000Z', granularity: 'HOUR' })
|
||||
);
|
||||
|
||||
const { command: rangeCommand } = createCommand([
|
||||
'--start',
|
||||
'2026-09-08T10:00:00.000Z',
|
||||
'--end',
|
||||
'2026-09-08T11:00:00.000Z',
|
||||
]);
|
||||
await rangeCommand.runAsync();
|
||||
expect(mockByAppIdAsync).toHaveBeenLastCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({
|
||||
startTime: '2026-09-08T10:00:00.000Z',
|
||||
endTime: '2026-09-08T11:00:00.000Z',
|
||||
granularity: 'MINUTE',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Four days is exactly the longest window the server accepts hourly buckets for, so widening
|
||||
// to whole hours would push it past that limit.
|
||||
it('drops --days 4 to whole days when the clock is not on the hour', async () => {
|
||||
jest.setSystemTime(new Date('2026-09-08T12:20:30.000Z'));
|
||||
const { command } = createCommand(['--days', '4']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockByAppIdAsync).toHaveBeenLastCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({
|
||||
startTime: '2026-09-04T00:00:00.000Z',
|
||||
endTime: '2026-09-09T00:00:00.000Z',
|
||||
granularity: 'DAY',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// --json redirects stdout by swapping process.stdout.write, so it has to be in place before
|
||||
// anything that can log — including the error this range triggers.
|
||||
it('rejects a backwards explicit range after enabling JSON output and before resolving context', async () => {
|
||||
const { command, getContextAsync } = createCommand([
|
||||
'--json',
|
||||
'--non-interactive',
|
||||
'--start',
|
||||
'2026-09-08T11:00:45.000Z',
|
||||
'--end',
|
||||
'2026-09-08T11:00:15.000Z',
|
||||
]);
|
||||
await expect(command.runAsync()).rejects.toThrow(/requested time range is empty/);
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(getContextAsync).not.toHaveBeenCalled();
|
||||
expect(mockByAppIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects --end without --start', async () => {
|
||||
const { command } = createCommand(['--end', '2026-09-08T11:00:00.000Z']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/--end requires --start/);
|
||||
expect(mockByAppIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects --days combined with --start', async () => {
|
||||
const { command } = createCommand(['--days', '7', '--start', '2026-09-01T00:00:00.000Z']);
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('resolves workflow file names and passes the other filters to the server', async () => {
|
||||
const { command } = createCommand([
|
||||
'--workflow',
|
||||
'build.yml',
|
||||
'--status',
|
||||
'FAILURE',
|
||||
'--status',
|
||||
'CANCELED',
|
||||
'--trigger',
|
||||
'GITHUB_PUSH',
|
||||
'--git-ref',
|
||||
'main',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockByIdWorkflowFileNamesAsync).toHaveBeenCalledWith(graphqlClient, 'app-1');
|
||||
expect(mockByAppIdAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({
|
||||
filters: {
|
||||
workflowIds: ['wf-build'],
|
||||
statuses: ['FAILURE', 'CANCELED'],
|
||||
triggerEventTypes: ['GITHUB_PUSH'],
|
||||
gitRefRequested: ['refs/heads/main'],
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('only accepts finished-run statuses', async () => {
|
||||
const { command } = createCommand(['--status', 'IN_PROGRESS']);
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
expect(mockByAppIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips the project directory when --project-id is given', async () => {
|
||||
const { command, getContextAsync } = createCommand(['--project-id', 'other-app']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(getContextAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ projectIdOverride: 'other-app' })
|
||||
);
|
||||
expect(mockByAppIdAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ appId: 'other-app' })
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a non-integer --limit before querying', async () => {
|
||||
const { command } = createCommand(['--limit', '1.5']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/as an integer/);
|
||||
expect(mockByAppIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes --limit through as the page size', async () => {
|
||||
const { command } = createCommand(['--limit', '10']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockByAppIdAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ first: 10 })
|
||||
);
|
||||
});
|
||||
|
||||
it('emits JSON when --json is passed', async () => {
|
||||
const { command } = createCommand(['--json', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
const json = mockPrintJsonOnlyOutput.mock.calls[0][0] as any;
|
||||
expect(json.project).toBe('@acme/app');
|
||||
expect(json.timespan).toEqual({
|
||||
start: '2026-09-01T00:00:00.000Z',
|
||||
end: '2026-09-09T00:00:00.000Z',
|
||||
});
|
||||
expect(json.overview.totalRuns).toEqual({ current: 100, previous: 80 });
|
||||
expect(json.overview.successRatePercent).toEqual({ current: 75, previous: 80 });
|
||||
expect(json.runsOverTime.granularity).toBe('DAY');
|
||||
expect(json.workflows[0].name).toBe('Build');
|
||||
expect(json.workflows[0].fileName).toBe('build.yml');
|
||||
});
|
||||
|
||||
it('turns a plan-gate rejection into a readable error with the plan limit', async () => {
|
||||
const graphQLError = new GraphQLError(
|
||||
'The selected timeframe exceeds the supported range for workflow insights.',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{ errorCode: 'EAS_WORKFLOWS_INSIGHTS_TIMESPAN_LIMIT_EXCEEDED', metadata: { limitDays: 30 } }
|
||||
);
|
||||
mockByAppIdAsync.mockRejectedValue(new CombinedError({ graphQLErrors: [graphQLError] }));
|
||||
|
||||
const { command } = createCommand(['--days', '90']);
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'The selected timeframe exceeds the supported range for workflow insights. Your plan includes the last 30 days.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
EasProjectIdFlag,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../../commandUtils/pagination';
|
||||
import { WorkflowRunTriggerEventType } from '../../../graphql/generated';
|
||||
import { WorkflowsInsightsQuery } from '../../../graphql/queries/WorkflowsInsightsQuery';
|
||||
import { InsightsTimeRangeFlags, resolveInsightsTimeRange } from '../../../insights/timeRange';
|
||||
import Log from '../../../log';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import {
|
||||
INSIGHTS_RUN_STATUSES,
|
||||
WorkflowsInsightsSharedFilterFlags,
|
||||
getAppliedWorkflowsInsightsFilters,
|
||||
resolveWorkflowsInsightsFiltersInputAsync,
|
||||
} from '../../../workflow/insights/filters';
|
||||
import {
|
||||
buildWorkflowsInsightsJson,
|
||||
buildWorkflowsInsightsTable,
|
||||
toWorkflowsInsightsSummary,
|
||||
} from '../../../workflow/insights/formatInsights';
|
||||
import { alignInsightsTimespan } from '../../../workflow/insights/granularity';
|
||||
import { withInsightsPlanGateHandlingAsync } from '../../../workflow/insights/planGating';
|
||||
|
||||
const DEFAULT_WORKFLOWS_LIMIT = 50;
|
||||
const MAX_WORKFLOWS_LIMIT = 100;
|
||||
|
||||
export default class WorkflowInsights extends EasCommand {
|
||||
static override description =
|
||||
'display run counts, success rate, and per-workflow trends for a time range';
|
||||
|
||||
static override flags = {
|
||||
...WorkflowsInsightsSharedFilterFlags,
|
||||
status: Flags.option({
|
||||
description: 'Only include runs with this status (can be specified multiple times).',
|
||||
options: INSIGHTS_RUN_STATUSES,
|
||||
multiple: true,
|
||||
})(),
|
||||
trigger: Flags.option({
|
||||
description: 'Only include runs started by this trigger (can be specified multiple times).',
|
||||
options: Object.values(WorkflowRunTriggerEventType),
|
||||
multiple: true,
|
||||
})(),
|
||||
...InsightsTimeRangeFlags,
|
||||
limit: getLimitFlagWithCustomValues({
|
||||
defaultTo: DEFAULT_WORKFLOWS_LIMIT,
|
||||
limit: MAX_WORKFLOWS_LIMIT,
|
||||
description: `The number of workflows to list. Defaults to ${DEFAULT_WORKFLOWS_LIMIT} and is capped at ${MAX_WORKFLOWS_LIMIT}.`,
|
||||
}),
|
||||
...EasProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(WorkflowInsights);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const { timespan, granularity } = alignInsightsTimespan(resolveInsightsTimeRange(flags));
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(WorkflowInsights, {
|
||||
nonInteractive,
|
||||
projectIdOverride: flags['project-id'],
|
||||
});
|
||||
|
||||
const filters = await resolveWorkflowsInsightsFiltersInputAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
flags
|
||||
);
|
||||
const app = await withInsightsPlanGateHandlingAsync(() =>
|
||||
WorkflowsInsightsQuery.byAppIdAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
startTime: timespan.startTime,
|
||||
endTime: timespan.endTime,
|
||||
filters,
|
||||
granularity,
|
||||
first: flags.limit ?? DEFAULT_WORKFLOWS_LIMIT,
|
||||
})
|
||||
);
|
||||
|
||||
const summary = toWorkflowsInsightsSummary(app, {
|
||||
timespan,
|
||||
granularity,
|
||||
filters: getAppliedWorkflowsInsightsFilters(flags),
|
||||
});
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildWorkflowsInsightsJson(summary));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(buildWorkflowsInsightsTable(summary));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
EasProjectIdFlag,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../../commandUtils/pagination';
|
||||
import { WorkflowDeviceTestCaseInsightsQuery } from '../../../graphql/queries/WorkflowDeviceTestCaseInsightsQuery';
|
||||
import { InsightsTimeRangeFlags, resolveInsightsTimeRange } from '../../../insights/timeRange';
|
||||
import Log from '../../../log';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import { WorkflowsInsightsSharedFilterFlags } from '../../../workflow/insights/filters';
|
||||
import {
|
||||
buildMaestroFlowHistoryJson,
|
||||
buildMaestroFlowHistoryTable,
|
||||
buildMaestroInsightsJson,
|
||||
buildMaestroInsightsTable,
|
||||
toMaestroFlowHistorySummary,
|
||||
toMaestroInsightsSummary,
|
||||
} from '../../../workflow/insights/formatMaestroInsights';
|
||||
import { alignMaestroInsightsTimespan } from '../../../workflow/insights/granularity';
|
||||
import {
|
||||
MAESTRO_STATUS_OPTIONS,
|
||||
getAppliedMaestroInsightsFilters,
|
||||
resolveMaestroHistoryFiltersInputAsync,
|
||||
resolveMaestroInsightsFiltersInputAsync,
|
||||
} from '../../../workflow/insights/maestroFilters';
|
||||
import {
|
||||
MAESTRO_SORT_DIRECTIONS,
|
||||
MAESTRO_SORT_OPTIONS,
|
||||
SORT_DIRECTION_BY_OPTION,
|
||||
SORT_FIELD_BY_OPTION,
|
||||
} from '../../../workflow/insights/maestroSort';
|
||||
import { withInsightsPlanGateHandlingAsync } from '../../../workflow/insights/planGating';
|
||||
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const MAX_LIMIT = 100;
|
||||
const ERROR_PATTERNS_LIMIT = 5;
|
||||
|
||||
// These only shape the flows table, so they cannot be combined with a single flow's history.
|
||||
const OVERVIEW_ONLY_FLAGS = ['status', 'tag', 'search', 'sort', 'sort-direction'];
|
||||
|
||||
export default class WorkflowInsightsMaestro extends EasCommand {
|
||||
static override description =
|
||||
"display Maestro test insights for a time range: pass and flake rates, per-flow stats, and a single flow's history";
|
||||
|
||||
static override flags = {
|
||||
...WorkflowsInsightsSharedFilterFlags,
|
||||
status: Flags.option({
|
||||
description:
|
||||
'Only include flow runs with this status (can be specified multiple times). PASSED means passed on the first attempt.',
|
||||
options: MAESTRO_STATUS_OPTIONS,
|
||||
multiple: true,
|
||||
exclusive: ['flow'],
|
||||
})(),
|
||||
tag: Flags.string({
|
||||
description: 'Only include flow runs with this tag (can be specified multiple times).',
|
||||
multiple: true,
|
||||
exclusive: ['flow'],
|
||||
}),
|
||||
search: Flags.string({
|
||||
description: 'Only list flows whose path contains this text.',
|
||||
exclusive: ['flow'],
|
||||
}),
|
||||
sort: Flags.option({
|
||||
description: 'Sort the flows table by this column.',
|
||||
options: MAESTRO_SORT_OPTIONS,
|
||||
default: 'fails',
|
||||
exclusive: ['flow'],
|
||||
})(),
|
||||
'sort-direction': Flags.option({
|
||||
description: 'Sort direction for the flows table.',
|
||||
options: MAESTRO_SORT_DIRECTIONS,
|
||||
default: 'desc',
|
||||
exclusive: ['flow'],
|
||||
})(),
|
||||
flow: Flags.string({
|
||||
description: `Show one flow's history instead of the overview, by flow path. Cannot be combined with ${OVERVIEW_ONLY_FLAGS.map(
|
||||
flag => `--${flag}`
|
||||
).join(', ')}.`,
|
||||
exclusive: OVERVIEW_ONLY_FLAGS,
|
||||
}),
|
||||
...InsightsTimeRangeFlags,
|
||||
limit: getLimitFlagWithCustomValues({
|
||||
defaultTo: DEFAULT_LIMIT,
|
||||
limit: MAX_LIMIT,
|
||||
description: `The number of flows to list, or of recent runs with --flow. Defaults to ${DEFAULT_LIMIT} and is capped at ${MAX_LIMIT}.`,
|
||||
}),
|
||||
...EasProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(WorkflowInsightsMaestro);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const { timespan, granularity } = alignMaestroInsightsTimespan(resolveInsightsTimeRange(flags));
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(WorkflowInsightsMaestro, {
|
||||
nonInteractive,
|
||||
projectIdOverride: flags['project-id'],
|
||||
});
|
||||
|
||||
const appliedFilters = getAppliedMaestroInsightsFilters(flags);
|
||||
const limit = flags.limit ?? DEFAULT_LIMIT;
|
||||
|
||||
if (flags.flow !== undefined) {
|
||||
const flowPath = flags.flow;
|
||||
const filters = await resolveMaestroHistoryFiltersInputAsync(graphqlClient, projectId, flags);
|
||||
const app = await withInsightsPlanGateHandlingAsync(() =>
|
||||
WorkflowDeviceTestCaseInsightsQuery.historyByAppIdAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
path: flowPath,
|
||||
startTime: timespan.startTime,
|
||||
endTime: timespan.endTime,
|
||||
filters,
|
||||
granularity,
|
||||
errorPatternsFirst: ERROR_PATTERNS_LIMIT,
|
||||
recentRunsFirst: limit,
|
||||
})
|
||||
);
|
||||
const summary = toMaestroFlowHistorySummary(app, {
|
||||
flowPath,
|
||||
timespan,
|
||||
granularity,
|
||||
filters: appliedFilters,
|
||||
});
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildMaestroFlowHistoryJson(summary));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(buildMaestroFlowHistoryTable(summary));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const filters = await resolveMaestroInsightsFiltersInputAsync(graphqlClient, projectId, flags);
|
||||
const sort = { field: flags.sort, direction: flags['sort-direction'] };
|
||||
const app = await withInsightsPlanGateHandlingAsync(() =>
|
||||
WorkflowDeviceTestCaseInsightsQuery.insightsByAppIdAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
startTime: timespan.startTime,
|
||||
endTime: timespan.endTime,
|
||||
filters,
|
||||
granularity,
|
||||
sortField: SORT_FIELD_BY_OPTION[sort.field],
|
||||
sortDirection: SORT_DIRECTION_BY_OPTION[sort.direction],
|
||||
search: flags.search,
|
||||
first: limit,
|
||||
})
|
||||
);
|
||||
const summary = toMaestroInsightsSummary(app, {
|
||||
timespan,
|
||||
granularity,
|
||||
filters: appliedFilters,
|
||||
sort,
|
||||
});
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildMaestroInsightsJson(summary));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(buildMaestroInsightsTable(summary));
|
||||
}
|
||||
}
|
||||
}
|
||||
+174
-5
@@ -1081,6 +1081,26 @@ export type AgentDeviceRunSessionRemoteConfig = {
|
||||
__typename?: 'AgentDeviceRunSessionRemoteConfig';
|
||||
agentDeviceRemoteSessionToken: Scalars['String']['output'];
|
||||
agentDeviceRemoteSessionUrl: Scalars['String']['output'];
|
||||
/**
|
||||
* Fingerprint of the reverse-tunnel server key for the client to pin. Null when
|
||||
* the session does not use local egress.
|
||||
*/
|
||||
egressFingerprint?: Maybe<Scalars['String']['output']>;
|
||||
/**
|
||||
* Loopback port on the device host that the local egress client must serve
|
||||
* through the tunnel. Null when the session does not use local egress.
|
||||
*/
|
||||
egressPort?: Maybe<Scalars['Int']['output']>;
|
||||
/**
|
||||
* Secret the local egress client presents to the reverse-tunnel endpoint. Null
|
||||
* when the session does not use local egress.
|
||||
*/
|
||||
egressToken?: Maybe<Scalars['String']['output']>;
|
||||
/**
|
||||
* Reverse-tunnel endpoint the local egress client connects to. Null when the
|
||||
* session does not use local egress.
|
||||
*/
|
||||
egressUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** Session token gating the web preview. Null when the preview runs ungated. */
|
||||
webPreviewToken?: Maybe<Scalars['String']['output']>;
|
||||
/**
|
||||
@@ -4673,6 +4693,26 @@ export type AppiumRunSessionRemoteConfig = {
|
||||
appiumUrl: Scalars['String']['output'];
|
||||
/** W3C capabilities for the device that backs this session. */
|
||||
capabilities: Scalars['JSONObject']['output'];
|
||||
/**
|
||||
* Fingerprint of the reverse-tunnel server key for the client to pin. Null when
|
||||
* the session does not use local egress.
|
||||
*/
|
||||
egressFingerprint?: Maybe<Scalars['String']['output']>;
|
||||
/**
|
||||
* Loopback port on the device host that the local egress client must serve
|
||||
* through the tunnel. Null when the session does not use local egress.
|
||||
*/
|
||||
egressPort?: Maybe<Scalars['Int']['output']>;
|
||||
/**
|
||||
* Secret the local egress client presents to the reverse-tunnel endpoint. Null
|
||||
* when the session does not use local egress.
|
||||
*/
|
||||
egressToken?: Maybe<Scalars['String']['output']>;
|
||||
/**
|
||||
* Reverse-tunnel endpoint the local egress client connects to. Null when the
|
||||
* session does not use local egress.
|
||||
*/
|
||||
egressUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** Session token gating the web preview. Null when the preview runs ungated. */
|
||||
webPreviewToken?: Maybe<Scalars['String']['output']>;
|
||||
/**
|
||||
@@ -5081,6 +5121,26 @@ export type AppleTeamUpdateInput = {
|
||||
|
||||
export type ArgentRunSessionRemoteConfig = {
|
||||
__typename?: 'ArgentRunSessionRemoteConfig';
|
||||
/**
|
||||
* Fingerprint of the reverse-tunnel server key for the client to pin. Null when
|
||||
* the session does not use local egress.
|
||||
*/
|
||||
egressFingerprint?: Maybe<Scalars['String']['output']>;
|
||||
/**
|
||||
* Loopback port on the device host that the local egress client must serve
|
||||
* through the tunnel. Null when the session does not use local egress.
|
||||
*/
|
||||
egressPort?: Maybe<Scalars['Int']['output']>;
|
||||
/**
|
||||
* Secret the local egress client presents to the reverse-tunnel endpoint. Null
|
||||
* when the session does not use local egress.
|
||||
*/
|
||||
egressToken?: Maybe<Scalars['String']['output']>;
|
||||
/**
|
||||
* Reverse-tunnel endpoint the local egress client connects to. Null when the
|
||||
* session does not use local egress.
|
||||
*/
|
||||
egressUrl?: Maybe<Scalars['String']['output']>;
|
||||
toolsAuthToken?: Maybe<Scalars['String']['output']>;
|
||||
toolsUrl: Scalars['String']['output'];
|
||||
/** Session token gating the web preview. Null when the preview runs ungated. */
|
||||
@@ -5938,6 +5998,8 @@ export enum BuildRetryDisabledReason {
|
||||
AlreadyRetried = 'ALREADY_RETRIED',
|
||||
InvalidStatus = 'INVALID_STATUS',
|
||||
IsGithubBuild = 'IS_GITHUB_BUILD',
|
||||
IsWorkflowBuild = 'IS_WORKFLOW_BUILD',
|
||||
LocalBuildsNotRetryable = 'LOCAL_BUILDS_NOT_RETRYABLE',
|
||||
NotCompletedYet = 'NOT_COMPLETED_YET',
|
||||
TooMuchTimeElapsed = 'TOO_MUCH_TIME_ELAPSED'
|
||||
}
|
||||
@@ -6284,6 +6346,12 @@ export type CreateDeviceRunSessionInput = {
|
||||
* Mutually exclusive with buildFingerprint, applicationArchiveUrl, and expoGo.
|
||||
*/
|
||||
buildId?: InputMaybe<Scalars['ID']['input']>;
|
||||
/**
|
||||
* Where the virtual device's proxied network traffic exits to the internet. If
|
||||
* omitted, all traffic exits from EAS infrastructure. LOCAL is only supported for
|
||||
* sessions on IOS.
|
||||
*/
|
||||
egress?: InputMaybe<DeviceRunSessionEgress>;
|
||||
/**
|
||||
* Install and launch Expo Go before the simulator session becomes available. The server resolves
|
||||
* the platform-specific application archive. Mutually exclusive with buildId, buildFingerprint,
|
||||
@@ -7029,6 +7097,22 @@ export type DeviceRunSessionArtifactUploadSession = {
|
||||
url: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
/**
|
||||
* Where the virtual device's proxied network traffic exits to the internet. When
|
||||
* unset, all traffic exits from EAS infrastructure.
|
||||
*/
|
||||
export enum DeviceRunSessionEgress {
|
||||
/**
|
||||
* HTTP(S) requests that honor the device's system proxy exit through a reverse
|
||||
* tunnel to the machine running the EAS CLI egress client, so third parties see
|
||||
* that machine's public IP. Those requests fail while the client is disconnected.
|
||||
* Requests from libraries that bypass the system proxy are not covered and exit
|
||||
* from EAS infrastructure. The client must stay connected for the life of the
|
||||
* session.
|
||||
*/
|
||||
Local = 'LOCAL'
|
||||
}
|
||||
|
||||
export type DeviceRunSessionEventLogUploadSession = {
|
||||
__typename?: 'DeviceRunSessionEventLogUploadSession';
|
||||
headers: Scalars['JSONObject']['output'];
|
||||
@@ -11072,6 +11156,26 @@ export type SentryProjectMutation_DeleteSentryProjectArgs = {
|
||||
*/
|
||||
export type ServeSimRunSessionRemoteConfig = {
|
||||
__typename?: 'ServeSimRunSessionRemoteConfig';
|
||||
/**
|
||||
* Fingerprint of the reverse-tunnel server key for the client to pin. Null when
|
||||
* the session does not use local egress.
|
||||
*/
|
||||
egressFingerprint?: Maybe<Scalars['String']['output']>;
|
||||
/**
|
||||
* Loopback port on the device host that the local egress client must serve
|
||||
* through the tunnel. Null when the session does not use local egress.
|
||||
*/
|
||||
egressPort?: Maybe<Scalars['Int']['output']>;
|
||||
/**
|
||||
* Secret the local egress client presents to the reverse-tunnel endpoint. Null
|
||||
* when the session does not use local egress.
|
||||
*/
|
||||
egressToken?: Maybe<Scalars['String']['output']>;
|
||||
/**
|
||||
* Reverse-tunnel endpoint the local egress client connects to. Null when the
|
||||
* session does not use local egress.
|
||||
*/
|
||||
egressUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** Session token gating the preview. Null when the preview runs ungated. */
|
||||
previewToken?: Maybe<Scalars['String']['output']>;
|
||||
previewUrl: Scalars['String']['output'];
|
||||
@@ -13135,6 +13239,26 @@ export type WebNotificationUpdateReadStateInput = {
|
||||
|
||||
export type WebPreviewOnlyRunSessionRemoteConfig = {
|
||||
__typename?: 'WebPreviewOnlyRunSessionRemoteConfig';
|
||||
/**
|
||||
* Fingerprint of the reverse-tunnel server key for the client to pin. Null when
|
||||
* the session does not use local egress.
|
||||
*/
|
||||
egressFingerprint?: Maybe<Scalars['String']['output']>;
|
||||
/**
|
||||
* Loopback port on the device host that the local egress client must serve
|
||||
* through the tunnel. Null when the session does not use local egress.
|
||||
*/
|
||||
egressPort?: Maybe<Scalars['Int']['output']>;
|
||||
/**
|
||||
* Secret the local egress client presents to the reverse-tunnel endpoint. Null
|
||||
* when the session does not use local egress.
|
||||
*/
|
||||
egressToken?: Maybe<Scalars['String']['output']>;
|
||||
/**
|
||||
* Reverse-tunnel endpoint the local egress client connects to. Null when the
|
||||
* session does not use local egress.
|
||||
*/
|
||||
egressUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** Session token gating the web preview. Null when the preview runs ungated. */
|
||||
webPreviewToken?: Maybe<Scalars['String']['output']>;
|
||||
webPreviewUrl: Scalars['String']['output'];
|
||||
@@ -15896,6 +16020,13 @@ export type AppByIdWorkflowsQueryVariables = Exact<{
|
||||
|
||||
export type AppByIdWorkflowsQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, workflows: Array<{ __typename?: 'Workflow', id: string, name?: string | null, fileName: string, createdAt: any, updatedAt: any, revisionsPaginated: { __typename?: 'WorkflowRevisionsConnection', edges: Array<{ __typename?: 'WorkflowRevisionEdge', node: { __typename?: 'WorkflowRevision', id: string, blobSha: string, commitSha?: string | null, createdAt: any, yamlConfig: string } }> } }> } } };
|
||||
|
||||
export type AppByIdWorkflowFileNamesQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type AppByIdWorkflowFileNamesQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, workflows: Array<{ __typename?: 'Workflow', id: string, fileName: string }> } } };
|
||||
|
||||
export type AppByIdWorkflowRunsFilteredByStatusQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
status?: InputMaybe<WorkflowRunStatus>;
|
||||
@@ -16178,11 +16309,11 @@ export type DeviceRunSessionByIdQueryVariables = Exact<{
|
||||
|
||||
|
||||
export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, name?: string | null, tags: Array<string>, status: DeviceRunSessionStatus, type: DeviceRunSessionType, platform: AppPlatform, createdAt: any, startedAt?: any | null, finishedAt?: any | null, updatedAt: any, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, artifacts: Array<{ __typename?: 'DeviceRunSessionArtifact', id: string, name: string, filename: string, downloadUrl: string, fileSizeBytes?: number | null, metadata?: any | null, createdAt: any, updatedAt: any }>, remoteConfig?:
|
||||
| { __typename: 'AgentDeviceRunSessionRemoteConfig', agentDeviceRemoteSessionUrl: string, agentDeviceRemoteSessionToken: string, webPreviewUrl?: string | null, webPreviewToken?: string | null }
|
||||
| { __typename: 'AppiumRunSessionRemoteConfig', appiumUrl: string, capabilities: any, webPreviewUrl?: string | null, webPreviewToken?: string | null }
|
||||
| { __typename: 'ArgentRunSessionRemoteConfig', toolsUrl: string, toolsAuthToken?: string | null, webPreviewUrl?: string | null, webPreviewToken?: string | null }
|
||||
| { __typename: 'ServeSimRunSessionRemoteConfig', previewUrl: string, previewToken?: string | null }
|
||||
| { __typename: 'WebPreviewOnlyRunSessionRemoteConfig', previewUrl: string, previewToken?: string | null }
|
||||
| { __typename: 'AgentDeviceRunSessionRemoteConfig', agentDeviceRemoteSessionUrl: string, agentDeviceRemoteSessionToken: string, webPreviewUrl?: string | null, webPreviewToken?: string | null, egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null }
|
||||
| { __typename: 'AppiumRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, appiumUrl: string, capabilities: any, webPreviewUrl?: string | null, webPreviewToken?: string | null }
|
||||
| { __typename: 'ArgentRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, toolsUrl: string, toolsAuthToken?: string | null, webPreviewUrl?: string | null, webPreviewToken?: string | null }
|
||||
| { __typename: 'ServeSimRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, previewUrl: string, previewToken?: string | null }
|
||||
| { __typename: 'WebPreviewOnlyRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, previewUrl: string, previewToken?: string | null }
|
||||
| null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } } };
|
||||
|
||||
export type DeviceRunSessionsByAppIdQueryVariables = Exact<{
|
||||
@@ -16622,6 +16753,33 @@ export type WebhookByIdQueryVariables = Exact<{
|
||||
|
||||
export type WebhookByIdQuery = { __typename?: 'RootQuery', webhook: { __typename?: 'WebhookQuery', byId: { __typename?: 'Webhook', id: string, event: WebhookType, url: string, createdAt: any, updatedAt: any } } };
|
||||
|
||||
export type WorkflowDeviceTestCaseInsightsByAppIdQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
timespan: WorkflowDeviceTestCaseInsightsTimespanInput;
|
||||
filters?: InputMaybe<WorkflowDeviceTestCaseInsightsFiltersInput>;
|
||||
granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity;
|
||||
sortField?: InputMaybe<WorkflowDeviceTestCaseStatSortField>;
|
||||
sortDirection?: InputMaybe<WorkflowDeviceTestCaseSortDirection>;
|
||||
search?: InputMaybe<Scalars['String']['input']>;
|
||||
first: Scalars['Int']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type WorkflowDeviceTestCaseInsightsByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, fullName: string, workflowDeviceTestCaseInsights: { __typename?: 'WorkflowDeviceTestCaseInsights', totals: { __typename?: 'WorkflowDeviceTestCaseInsightsTotals', totalRuns: { __typename?: 'WorkflowDeviceTestCaseInsightsMetric', currentValue: number, previousValue: number }, passedCleanCount: { __typename?: 'WorkflowDeviceTestCaseInsightsMetric', currentValue: number, previousValue: number }, flakyCount: { __typename?: 'WorkflowDeviceTestCaseInsightsMetric', currentValue: number, previousValue: number }, distinctFlakyTestCount: { __typename?: 'WorkflowDeviceTestCaseInsightsMetric', currentValue: number, previousValue: number }, avgDurationMs: { __typename?: 'WorkflowDeviceTestCaseInsightsNullableMetric', currentValue?: number | null, previousValue?: number | null } }, timeSeries: Array<{ __typename?: 'WorkflowDeviceTestCaseInsightsBucket', bucketStartAt: any, passedClean: number, flaky: number, failed: number }>, tests: { __typename?: 'WorkflowDeviceTestCaseStatConnection', totalCount: number, edges: Array<{ __typename?: 'WorkflowDeviceTestCaseStatEdge', node: { __typename?: 'WorkflowDeviceTestCaseStat', path: string, name: string, totalRuns: number, passedCleanCount: number, flakyCount: number, failedCount: number, p90DurationMs?: number | null, lastRunAt: any, lastRunStatus: WorkflowDeviceTestCaseStatus, lastRunIsFlaky: boolean } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean } } } } } };
|
||||
|
||||
export type WorkflowDeviceTestCaseHistoryByAppIdQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
path: Scalars['String']['input'];
|
||||
timespan: WorkflowDeviceTestCaseInsightsTimespanInput;
|
||||
filters?: InputMaybe<WorkflowDeviceTestCaseHistoryFiltersInput>;
|
||||
granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity;
|
||||
errorPatternsFirst: Scalars['Int']['input'];
|
||||
recentRunsFirst: Scalars['Int']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type WorkflowDeviceTestCaseHistoryByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, fullName: string, workflowDeviceTestCaseHistory: { __typename?: 'WorkflowDeviceTestCaseHistory', totals: { __typename?: 'WorkflowDeviceTestCaseInsightsTotals', totalRuns: { __typename?: 'WorkflowDeviceTestCaseInsightsMetric', currentValue: number }, passedCleanCount: { __typename?: 'WorkflowDeviceTestCaseInsightsMetric', currentValue: number }, flakyCount: { __typename?: 'WorkflowDeviceTestCaseInsightsMetric', currentValue: number }, p90DurationMs: { __typename?: 'WorkflowDeviceTestCaseInsightsNullableMetric', currentValue?: number | null } }, timeSeries: Array<{ __typename?: 'WorkflowDeviceTestCaseInsightsBucket', bucketStartAt: any, passedClean: number, flaky: number, failed: number }>, errorPatterns: Array<{ __typename?: 'WorkflowDeviceTestCaseErrorPattern', sampleMessage: string, count: number }>, recentRuns: { __typename?: 'WorkflowDeviceTestCaseRecentRunConnection', totalCount: number, edges: Array<{ __typename?: 'WorkflowDeviceTestCaseRecentRunEdge', node: { __typename?: 'WorkflowDeviceTestCaseRecentRun', id: string, status: WorkflowDeviceTestCaseStatus, durationMs?: number | null, isFlaky: boolean, createdAt: any, workflowRunId: string, workflowRunName: string, gitRef?: string | null, commitSha?: string | null } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean } } } } } };
|
||||
|
||||
export type WorkflowJobByIdQueryVariables = Exact<{
|
||||
workflowJobId: Scalars['ID']['input'];
|
||||
}>;
|
||||
@@ -16699,6 +16857,17 @@ export type WorkflowRunsForAppIdFileNameAndStatusQuery = { __typename?: 'RootQue
|
||||
| { __typename: 'User', username: string, id: string }
|
||||
| null, errors: Array<{ __typename?: 'WorkflowRunError', title?: string | null, message: string }>, workflow: { __typename?: 'Workflow', id: string, name?: string | null, fileName: string } } }> } } } };
|
||||
|
||||
export type WorkflowsInsightsByAppIdQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
timespan: WorkflowsInsightsTimespanInput;
|
||||
filters?: InputMaybe<WorkflowsInsightsFiltersInput>;
|
||||
granularity: WorkflowsInsightsRunsOverTimeGranularity;
|
||||
first: Scalars['Int']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type WorkflowsInsightsByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, fullName: string, workflows: Array<{ __typename?: 'Workflow', id: string, fileName: string }>, workflowsInsights: { __typename?: 'AppWorkflowsInsights', overviewMetrics: { __typename?: 'WorkflowsInsightsOverviewMetrics', totalRuns: { __typename?: 'WorkflowsInsightsMetric', currentValue: number, previousValue: number }, successfulRuns: { __typename?: 'WorkflowsInsightsMetric', currentValue: number, previousValue: number }, failedRuns: { __typename?: 'WorkflowsInsightsMetric', currentValue: number, previousValue: number }, activeWorkflows: { __typename?: 'WorkflowsInsightsMetric', currentValue: number, previousValue: number } }, runsOverTime: { __typename?: 'WorkflowsInsightsRunsOverTimeData', lineChart: { __typename?: 'LineChartData', labels: Array<string>, datasets: Array<{ __typename?: 'LineDataset', id: string, data: Array<number | null> }> } }, workflows: { __typename?: 'WorkflowsInsightsWorkflowConnection', edges: Array<{ __typename?: 'WorkflowsInsightsWorkflowEdge', node: { __typename?: 'WorkflowsInsightsWorkflowNode', workflowId: string, name: string, totalRuns: number, successfulRuns: number, failedRuns: number, canceledRuns: number, lastRunAt: any } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean } } } } } };
|
||||
|
||||
export type AccountFragment = { __typename?: 'Account', id: string, name: string, ownerUserActor?:
|
||||
| { __typename?: 'SSOUser', id: string, username: string }
|
||||
| { __typename?: 'User', id: string, username: string }
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
AppByFullNameQuery,
|
||||
AppByIdProfileImageUrlQuery,
|
||||
AppByIdQuery,
|
||||
AppByIdWorkflowFileNamesQuery,
|
||||
AppByIdWorkflowRunsFilteredByStatusQuery,
|
||||
AppByIdWorkflowsQuery,
|
||||
AppFragment,
|
||||
@@ -131,6 +132,34 @@ export const AppQuery = {
|
||||
assert(data.app, 'GraphQL: `app` not defined in server response');
|
||||
return data.app.byId.workflows;
|
||||
},
|
||||
async byIdWorkflowFileNamesAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string
|
||||
): Promise<{ id: string; fileName: string }[]> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppByIdWorkflowFileNamesQuery>(
|
||||
gql`
|
||||
query AppByIdWorkflowFileNamesQuery($appId: String!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
workflows {
|
||||
id
|
||||
fileName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ appId },
|
||||
{ additionalTypenames: ['App', 'Workflow'] }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
assert(data.app, 'GraphQL: `app` not defined in server response');
|
||||
return data.app.byId.workflows;
|
||||
},
|
||||
async byIdWorkflowRunsFilteredByStatusAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
|
||||
@@ -88,24 +88,44 @@ export const DeviceRunSessionQuery = {
|
||||
agentDeviceRemoteSessionToken
|
||||
webPreviewUrl
|
||||
webPreviewToken
|
||||
egressUrl
|
||||
egressToken
|
||||
egressFingerprint
|
||||
egressPort
|
||||
}
|
||||
... on ArgentRunSessionRemoteConfig {
|
||||
egressUrl
|
||||
egressToken
|
||||
egressFingerprint
|
||||
egressPort
|
||||
toolsUrl
|
||||
toolsAuthToken
|
||||
webPreviewUrl
|
||||
webPreviewToken
|
||||
}
|
||||
... on AppiumRunSessionRemoteConfig {
|
||||
egressUrl
|
||||
egressToken
|
||||
egressFingerprint
|
||||
egressPort
|
||||
appiumUrl
|
||||
capabilities
|
||||
webPreviewUrl
|
||||
webPreviewToken
|
||||
}
|
||||
... on ServeSimRunSessionRemoteConfig {
|
||||
egressUrl
|
||||
egressToken
|
||||
egressFingerprint
|
||||
egressPort
|
||||
previewUrl
|
||||
previewToken
|
||||
}
|
||||
... on WebPreviewOnlyRunSessionRemoteConfig {
|
||||
egressUrl
|
||||
egressToken
|
||||
egressFingerprint
|
||||
egressPort
|
||||
previewUrl: webPreviewUrl
|
||||
previewToken: webPreviewToken
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withUpgradeRequiredErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
WorkflowDeviceTestCaseHistoryByAppIdQuery,
|
||||
WorkflowDeviceTestCaseHistoryByAppIdQueryVariables,
|
||||
WorkflowDeviceTestCaseHistoryFiltersInput,
|
||||
WorkflowDeviceTestCaseInsightsByAppIdQuery,
|
||||
WorkflowDeviceTestCaseInsightsByAppIdQueryVariables,
|
||||
WorkflowDeviceTestCaseInsightsFiltersInput,
|
||||
WorkflowDeviceTestCaseInsightsTimeSeriesGranularity,
|
||||
WorkflowDeviceTestCaseSortDirection,
|
||||
WorkflowDeviceTestCaseStatSortField,
|
||||
} from '../generated';
|
||||
|
||||
export type AppWithMaestroInsightsObject =
|
||||
WorkflowDeviceTestCaseInsightsByAppIdQuery['app']['byId'];
|
||||
export type AppWithMaestroFlowHistoryObject =
|
||||
WorkflowDeviceTestCaseHistoryByAppIdQuery['app']['byId'];
|
||||
|
||||
const FEATURE_NAME = 'EAS Maestro insights';
|
||||
|
||||
export const WorkflowDeviceTestCaseInsightsQuery = {
|
||||
async insightsByAppIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
startTime,
|
||||
endTime,
|
||||
filters,
|
||||
granularity,
|
||||
sortField,
|
||||
sortDirection,
|
||||
search,
|
||||
first,
|
||||
}: {
|
||||
appId: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
filters?: WorkflowDeviceTestCaseInsightsFiltersInput;
|
||||
granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity;
|
||||
sortField: WorkflowDeviceTestCaseStatSortField;
|
||||
sortDirection: WorkflowDeviceTestCaseSortDirection;
|
||||
search?: string;
|
||||
first: number;
|
||||
}
|
||||
): Promise<AppWithMaestroInsightsObject> {
|
||||
const data = await withUpgradeRequiredErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<
|
||||
WorkflowDeviceTestCaseInsightsByAppIdQuery,
|
||||
WorkflowDeviceTestCaseInsightsByAppIdQueryVariables
|
||||
>(
|
||||
gql`
|
||||
query WorkflowDeviceTestCaseInsightsByAppId(
|
||||
$appId: String!
|
||||
$timespan: WorkflowDeviceTestCaseInsightsTimespanInput!
|
||||
$filters: WorkflowDeviceTestCaseInsightsFiltersInput
|
||||
$granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity!
|
||||
$sortField: WorkflowDeviceTestCaseStatSortField
|
||||
$sortDirection: WorkflowDeviceTestCaseSortDirection
|
||||
$search: String
|
||||
$first: Int!
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
fullName
|
||||
workflowDeviceTestCaseInsights(timespan: $timespan, filters: $filters) {
|
||||
totals {
|
||||
totalRuns {
|
||||
currentValue
|
||||
previousValue
|
||||
}
|
||||
passedCleanCount {
|
||||
currentValue
|
||||
previousValue
|
||||
}
|
||||
flakyCount {
|
||||
currentValue
|
||||
previousValue
|
||||
}
|
||||
distinctFlakyTestCount {
|
||||
currentValue
|
||||
previousValue
|
||||
}
|
||||
avgDurationMs {
|
||||
currentValue
|
||||
previousValue
|
||||
}
|
||||
}
|
||||
timeSeries(granularity: $granularity) {
|
||||
bucketStartAt
|
||||
passedClean
|
||||
flaky
|
||||
failed
|
||||
}
|
||||
tests(
|
||||
sortField: $sortField
|
||||
sortDirection: $sortDirection
|
||||
search: $search
|
||||
first: $first
|
||||
) {
|
||||
edges {
|
||||
node {
|
||||
path
|
||||
name
|
||||
totalRuns
|
||||
passedCleanCount
|
||||
flakyCount
|
||||
failedCount
|
||||
p90DurationMs
|
||||
lastRunAt
|
||||
lastRunStatus
|
||||
lastRunIsFlaky
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
timespan: { start: startTime, end: endTime },
|
||||
filters,
|
||||
granularity,
|
||||
sortField,
|
||||
sortDirection,
|
||||
search,
|
||||
first,
|
||||
},
|
||||
{ additionalTypenames: ['App', 'WorkflowDeviceTestCaseInsights'] }
|
||||
)
|
||||
.toPromise(),
|
||||
{ featureName: FEATURE_NAME }
|
||||
);
|
||||
|
||||
return data.app.byId;
|
||||
},
|
||||
|
||||
async historyByAppIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
path,
|
||||
startTime,
|
||||
endTime,
|
||||
filters,
|
||||
granularity,
|
||||
errorPatternsFirst,
|
||||
recentRunsFirst,
|
||||
}: {
|
||||
appId: string;
|
||||
path: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
filters?: WorkflowDeviceTestCaseHistoryFiltersInput;
|
||||
granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity;
|
||||
errorPatternsFirst: number;
|
||||
recentRunsFirst: number;
|
||||
}
|
||||
): Promise<AppWithMaestroFlowHistoryObject> {
|
||||
const data = await withUpgradeRequiredErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<
|
||||
WorkflowDeviceTestCaseHistoryByAppIdQuery,
|
||||
WorkflowDeviceTestCaseHistoryByAppIdQueryVariables
|
||||
>(
|
||||
gql`
|
||||
query WorkflowDeviceTestCaseHistoryByAppId(
|
||||
$appId: String!
|
||||
$path: String!
|
||||
$timespan: WorkflowDeviceTestCaseInsightsTimespanInput!
|
||||
$filters: WorkflowDeviceTestCaseHistoryFiltersInput
|
||||
$granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity!
|
||||
$errorPatternsFirst: Int!
|
||||
$recentRunsFirst: Int!
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
fullName
|
||||
workflowDeviceTestCaseHistory(path: $path, timespan: $timespan, filters: $filters) {
|
||||
totals {
|
||||
totalRuns {
|
||||
currentValue
|
||||
}
|
||||
passedCleanCount {
|
||||
currentValue
|
||||
}
|
||||
flakyCount {
|
||||
currentValue
|
||||
}
|
||||
p90DurationMs {
|
||||
currentValue
|
||||
}
|
||||
}
|
||||
timeSeries(granularity: $granularity) {
|
||||
bucketStartAt
|
||||
passedClean
|
||||
flaky
|
||||
failed
|
||||
}
|
||||
errorPatterns(first: $errorPatternsFirst) {
|
||||
sampleMessage
|
||||
count
|
||||
}
|
||||
recentRuns(first: $recentRunsFirst) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
status
|
||||
durationMs
|
||||
isFlaky
|
||||
createdAt
|
||||
workflowRunId
|
||||
workflowRunName
|
||||
gitRef
|
||||
commitSha
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
path,
|
||||
timespan: { start: startTime, end: endTime },
|
||||
filters,
|
||||
granularity,
|
||||
errorPatternsFirst,
|
||||
recentRunsFirst,
|
||||
},
|
||||
{ additionalTypenames: ['App', 'WorkflowDeviceTestCaseHistory'] }
|
||||
)
|
||||
.toPromise(),
|
||||
{ featureName: FEATURE_NAME }
|
||||
);
|
||||
|
||||
return data.app.byId;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withUpgradeRequiredErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
WorkflowsInsightsByAppIdQuery,
|
||||
WorkflowsInsightsByAppIdQueryVariables,
|
||||
WorkflowsInsightsFiltersInput,
|
||||
WorkflowsInsightsRunsOverTimeGranularity,
|
||||
} from '../generated';
|
||||
|
||||
export type AppWithWorkflowsInsightsObject = WorkflowsInsightsByAppIdQuery['app']['byId'];
|
||||
|
||||
export const WorkflowsInsightsQuery = {
|
||||
async byAppIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
startTime,
|
||||
endTime,
|
||||
filters,
|
||||
granularity,
|
||||
first,
|
||||
}: {
|
||||
appId: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
filters?: WorkflowsInsightsFiltersInput;
|
||||
granularity: WorkflowsInsightsRunsOverTimeGranularity;
|
||||
first: number;
|
||||
}
|
||||
): Promise<AppWithWorkflowsInsightsObject> {
|
||||
const data = await withUpgradeRequiredErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<WorkflowsInsightsByAppIdQuery, WorkflowsInsightsByAppIdQueryVariables>(
|
||||
gql`
|
||||
query WorkflowsInsightsByAppId(
|
||||
$appId: String!
|
||||
$timespan: WorkflowsInsightsTimespanInput!
|
||||
$filters: WorkflowsInsightsFiltersInput
|
||||
$granularity: WorkflowsInsightsRunsOverTimeGranularity!
|
||||
$first: Int!
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
fullName
|
||||
workflows {
|
||||
id
|
||||
fileName
|
||||
}
|
||||
workflowsInsights {
|
||||
overviewMetrics(timespan: $timespan, filters: $filters) {
|
||||
totalRuns {
|
||||
currentValue
|
||||
previousValue
|
||||
}
|
||||
successfulRuns {
|
||||
currentValue
|
||||
previousValue
|
||||
}
|
||||
failedRuns {
|
||||
currentValue
|
||||
previousValue
|
||||
}
|
||||
activeWorkflows {
|
||||
currentValue
|
||||
previousValue
|
||||
}
|
||||
}
|
||||
runsOverTime(timespan: $timespan, filters: $filters, granularity: $granularity) {
|
||||
lineChart {
|
||||
labels
|
||||
datasets {
|
||||
id
|
||||
data
|
||||
}
|
||||
}
|
||||
}
|
||||
workflows(timespan: $timespan, filters: $filters, first: $first) {
|
||||
edges {
|
||||
node {
|
||||
workflowId
|
||||
name
|
||||
totalRuns
|
||||
successfulRuns
|
||||
failedRuns
|
||||
canceledRuns
|
||||
lastRunAt
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
timespan: { start: startTime, end: endTime },
|
||||
filters,
|
||||
granularity,
|
||||
first,
|
||||
},
|
||||
{ additionalTypenames: ['App', 'AppWorkflowsInsights', 'Workflow'] }
|
||||
)
|
||||
.toPromise(),
|
||||
{ featureName: 'EAS Workflows insights' }
|
||||
);
|
||||
|
||||
return data.app.byId;
|
||||
},
|
||||
};
|
||||
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user