Files
Mateusz Aliyev 6e9e45c9f0 feat: attach to devices offered by external providers (#735)
This pull request lets Argent drive a simulator or emulator another
process is already running, attaching to its simulator-server rather
than spawning a second one.

A provider writes a JSON descriptor to `~/.argent/providers/` listing
the devices it offers and the mechanisms Argent may use on each. They
appear in `list-devices` with an `ext:` id and work with the existing
tools. The file is re-read on every call, so a withdrawal or a narrowed
grant applies immediately.

`ios.additionalDeviceSets` (#600) already makes such a simulator
reachable by UDID. What changes is ownership. `boot-device` and
`stop-simulator-server` refuse these devices, anything the provider did
not grant is refused with a message naming it and Argent uses only the
endpoints its own simulator-server build serves. A grant binds to the
device rather than to one of its names, so the real udid or serial is
gated exactly like the `ext:` id. Android emulators are covered too and
a device visible to both a provider and `adb`/`simctl` is listed once.

The contract ships as `schemas/device-provider-v1.json`, validated by
`argent providers check`, and is documented in
`docs/reference/device-providers.mdx`. Includes unit tests and an e2e
phase where the harness acts as its own provider.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added support for discovering and connecting to externally managed
devices.
* Added `argent providers` commands for listing, validating, publishing,
withdrawing, and pruning providers.
* Added provider-aware simulator, debugger, profiler, native tools, and
device listings.
* Added capability controls, endpoint validation, revocation handling,
and provider-specific diagnostics.
  * Added automatic CLI discovery for provider integrations.
* **Bug Fixes**
  * Improved service recovery and paused-runtime error reporting.
  * Improved simulator keyboard and paste command reliability.
* **Documentation**
* Documented provider descriptors, capabilities, lifecycle rules, and
CLI usage.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-04 22:02:57 +02:00

269 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
# Full Argent E2E harness — top-level orchestrator.
#
# Starting from ONLY a `swmansion-argent-*.tgz`, this:
# 0. installs from the tarball (global + local), runs init/uninstall
# 1. introspects the CLI (help, a `tools describe` per published tool, flags,
# telemetry, server, link)
# 2. validates every tool's argument schema (missing/enum/type rejection)
# 3. drives a happy-path of every applicable tool against real targets:
# - Android emulator (Linux + Mac)
# - Chromium/Electron app (Linux + Mac)
# - React-Native debugger/profiler chain against Bluesky (Android)
#
# Unless --system is given, everything runs under a sandbox HOME + npm prefix
# and leaves the real ~/.argent, MCP configs and global packages alone. Results
# land in scripts/e2e-full/results/ as a JSONL log + a markdown report; the
# process exits non-zero if any hard assertion failed.
#
# Usage:
# run-e2e.sh [--tgz PATH] [--phase a,b,c] [--skip-install] [--system]
# [--android-serial S | --android-avd NAME] [--keep] [-h]
#
# Phases: install introspection validation android chromium rn
# (default: all that apply to this OS; iOS/tvOS/Vega are intentionally omitted)
set -uo pipefail
E2E_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$E2E_ROOT/../.." && pwd)"
TGZ=""
PHASES=""
SKIP_INSTALL=0
SYSTEM_INSTALL=0
KEEP=0
export E2E_ANDROID_SERIAL="${E2E_ANDROID_SERIAL:-}"
export E2E_ANDROID_AVD="${E2E_ANDROID_AVD:-}"
# Help text is the header block above. Stopping at the first non-comment line
# means it can never drift into printing code, the way a pinned range would.
usage() { sed -n '2,${/^#/!q; s/^# \{0,1\}//; p;}' "${BASH_SOURCE[0]}"; exit "${1:-0}"; }
# Without this, a value-taking flag given without its value reads an unset $2
# and dies on "unbound variable" instead of printing usage.
need_val() { [ "$1" -ge 2 ] || { echo "$2 needs a value" >&2; usage 1; }; }
while [ $# -gt 0 ]; do
case "$1" in
--tgz) need_val $# "$1"; TGZ="$2"; shift 2;;
--phase|--phases) need_val $# "$1"; PHASES="$2"; shift 2;;
--skip-install) SKIP_INSTALL=1; shift;;
--system) SYSTEM_INSTALL=1; shift;;
--android-serial) need_val $# "$1"; E2E_ANDROID_SERIAL="$2"; shift 2;;
--android-avd) need_val $# "$1"; E2E_ANDROID_AVD="$2"; shift 2;;
--keep) KEEP=1; shift;;
-h|--help) usage 0;;
*) echo "unknown arg: $1" >&2; usage 1;;
esac
done
# A misspelled phase would otherwise select nothing, and a run that executes no
# phase records no failure: "pass:0 fail:0" and exit 0 for an untested release.
ALL_PHASES="install introspection validation android chromium rn device-provider"
for _p in ${PHASES//,/ }; do
case " $ALL_PHASES " in
*" $_p "*) ;;
*) echo "unknown phase: $_p (known: $ALL_PHASES)" >&2; exit 2;;
esac
done
unset _p
if [ -z "$TGZ" ]; then
TGZ="$(ls -t "$REPO_ROOT"/swmansion-argent-*.tgz 2>/dev/null | head -1 || true)"
fi
if [ -z "$TGZ" ] || [ ! -f "$TGZ" ]; then
echo "No tgz found. Pass --tgz PATH or place swmansion-argent-*.tgz at $REPO_ROOT" >&2
exit 2
fi
TGZ="$(cd "$(dirname "$TGZ")" && pwd)/$(basename "$TGZ")"
export E2E_WORK="$(mktemp -d "${TMPDIR:-/tmp}/argent-e2e.XXXXXX")"
export E2E_HOME="$E2E_WORK/home"
export E2E_PREFIX="$E2E_WORK/prefix"
export E2E_UNPACKED="$E2E_WORK/unpacked/package"
mkdir -p "$E2E_HOME" "$E2E_PREFIX" "$E2E_WORK/unpacked" "$E2E_WORK/ws"
export E2E_TGZ="$TGZ"
export E2E_OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
RESULTS_DIR="$E2E_ROOT/results"
mkdir -p "$RESULTS_DIR"
# The pid disambiguates two runs started in the same second: they would share
# one results file and each compute its exit code over the other's cases too.
TS="$(date +%Y%m%d-%H%M%S)-$$"
export E2E_JSONL="$RESULTS_DIR/e2e-$TS.jsonl"
REPORT_MD="$RESULTS_DIR/report-$TS.md"
: > "$E2E_JSONL"
# Sourced before the EXIT trap below, so finish() has group/warn/err if it fires.
source "$E2E_ROOT/lib/common.sh"
source "$E2E_ROOT/lib/discover-tools.sh"
# Teardown. Installed as soon as the sandbox and the results log exist, because
# every exit path other than the happy one has to reach it: an unbound variable
# inside a phase, a Ctrl-C, or the early `exit 3` below would otherwise leave the
# sandbox behind with the run's results never rendered.
# ARGENT_BIN and TGZ_VERSION are read defensively — an abort can happen before
# either is assigned.
E2E_COMPLETED=0
CLEANUP_RAN=0
finish() {
local rc=$?
trap - EXIT INT TERM
# Processes first, sandbox second. The tool-server's state file lives in
# $E2E_HOME and ensure_server starts it with no idle timeout, so removing the
# sandbox first strands a server `argent server stop` can no longer find,
# along with any Electron, Metro and fixture server the tiers spawned.
# ARGENT_BIN has to be set before cleanup can run: argent_cli resolves it with
# ${ARGENT_BIN:?}, which exits the shell outright — from in here that would
# abandon the rest of this handler and leak the sandbox it exists to remove.
if [ "$CLEANUP_RAN" -eq 0 ] && [ -n "${ARGENT_BIN:-}" ] && declare -F run_one >/dev/null 2>&1; then
CLEANUP_RAN=1
run_one cleanup "$E2E_ROOT/phases/90-cleanup.sh" || true
fi
group "Generating report"
E2E_JSONL="$E2E_JSONL" TGZ_VERSION="${TGZ_VERSION:-unknown}" E2E_OS="$E2E_OS" \
ARGENT_BIN="${ARGENT_BIN:-}" \
python3 "$E2E_ROOT/lib/report.py" "$E2E_JSONL" > "$REPORT_MD" || warn "report generation failed"
cat "$REPORT_MD" >&2 || true
local tp tf ts
tp=$(jq -s '[.[]|select(.status=="pass")]|length' "$E2E_JSONL" 2>/dev/null || echo 0)
tf=$(jq -s '[.[]|select(.status=="fail")]|length' "$E2E_JSONL" 2>/dev/null || echo 0)
ts=$(jq -s '[.[]|select(.status=="skip")]|length' "$E2E_JSONL" 2>/dev/null || echo 0)
if [ "$KEEP" -eq 1 ]; then
warn "--keep: leaving sandbox at $E2E_WORK"
else
rm -rf "$E2E_WORK"
fi
group "DONE — pass:$tp fail:$tf skip:$ts"
echo "report: $REPORT_MD" >&2
# A run that stopped early is not a pass: the phases it never reached cannot
# have failed.
if [ "$E2E_COMPLETED" -eq 0 ]; then
err "harness stopped before finishing its phase list — these results are PARTIAL"
[ "$rc" -ne 0 ] && exit "$rc"
exit 1
fi
[ "$tf" -eq 0 ] || exit 1
exit 0
}
trap finish EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
export E2E_TOOLS_PORT="$(python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1]);s.close()')"
# The RN tier looks for the Bluesky checkout under the real HOME.
export HOME_REAL="$HOME"
export HOME="$E2E_HOME"
# Device state must stay visible through the sandbox HOME or the android tier
# false-fails on every gesture/screenshot: adb auth keys live in ~/.android, and
# on macOS the emulator writes its gRPC discovery files under
# ~/Library/Caches/TemporaryItems, which simulator-server resolves via $HOME.
# Linux uses XDG_RUNTIME_DIR for that discovery and is unaffected. Deliberately
# not ~/Library wholesale, which would expose the real editor configs.
[ -d "$HOME_REAL/.android" ] && ln -s "$HOME_REAL/.android" "$E2E_HOME/.android"
if [ "$E2E_OS" = darwin ] && [ -d "$HOME_REAL/Library/Caches/TemporaryItems" ]; then
mkdir -p "$E2E_HOME/Library/Caches"
ln -s "$HOME_REAL/Library/Caches/TemporaryItems" "$E2E_HOME/Library/Caches/TemporaryItems"
fi
export PATH="$E2E_PREFIX/bin:$PATH"
# Confine every `npm install -g` — ours and the ones `argent init/update` runs
# internally — to the sandbox prefix.
if [ "$SYSTEM_INSTALL" -eq 0 ]; then export npm_config_prefix="$E2E_PREFIX"; fi
export DO_NOT_TRACK=1
export CI=1
# The file-level install assertions and --skip-install run off the unpacked copy.
tar xzf "$E2E_TGZ" -C "$E2E_WORK/unpacked"
TGZ_VERSION="$(jq -r .version "$E2E_UNPACKED/package.json")"
export TGZ_VERSION
if [ -z "$PHASES" ]; then
case "$E2E_OS" in
linux) PHASES="install,introspection,validation,android,chromium,rn,device-provider";;
darwin) PHASES="install,introspection,validation,android,chromium,rn,device-provider";;
*) PHASES="install,introspection,validation";;
esac
fi
selected() { case ",$PHASES," in *",$1,"*) return 0;; *) return 1;; esac; }
# The check above walks the words a phase list splits into, so a list that splits
# into none — `--phase ,` or `--phase ' '` — passes it while selecting nothing.
# Count what `selected` actually matches instead.
_selected=0
for _p in $ALL_PHASES; do selected "$_p" && _selected=$((_selected + 1)); done
if [ "$_selected" -eq 0 ]; then
echo "no phases selected from '$PHASES' (known: $ALL_PHASES)" >&2
exit 2
fi
unset _p _selected
if [ "$SKIP_INSTALL" -eq 1 ]; then
export ARGENT_BIN="node $E2E_UNPACKED/dist/cli.js"
read -ra ARGENT_CMD <<< "$ARGENT_BIN"
warn "skip-install: driving unpacked bundle; phase 'install' will be skipped"
else
group "Sandbox install: npm i -g $(basename "$E2E_TGZ") --prefix \$E2E_PREFIX"
# The optional deps (electron, node-pty) are a slow network download and only
# the device tiers need them.
OMIT="--omit=optional"
if selected chromium || selected rn; then OMIT=""; fi
# The install phase restores the driver with these after its uninstall test;
# different flags would silently change what the rest of the run tests.
export E2E_NPM_OMIT="$OMIT"
if [ "$SYSTEM_INSTALL" -eq 1 ]; then
warn "--system: installing to the REAL global prefix (release-machine mode)"
export E2E_NPM_PREFIX_ARGS=""
npm install -g "$E2E_TGZ" $OMIT 2>&1 | tail -20 >&2 || true
export ARGENT_BIN="$(command -v argent || echo "$E2E_PREFIX/bin/argent")"
else
export E2E_NPM_PREFIX_ARGS="--prefix $E2E_PREFIX"
npm install -g "$E2E_TGZ" --prefix "$E2E_PREFIX" $OMIT 2>&1 | tail -20 >&2 || true
export ARGENT_BIN="$E2E_PREFIX/bin/argent"
fi
read -ra ARGENT_CMD <<< "$ARGENT_BIN"
if [ ! -x "${ARGENT_CMD[0]}" ] && [ "${ARGENT_CMD[0]}" != "node" ]; then
err "argent binary not found at $ARGENT_BIN after install"
exit 3
fi
fi
log "Driving with: $ARGENT_BIN (v${TGZ_VERSION})"
log "Sandbox: $E2E_WORK"
log "Results: $E2E_JSONL"
# Each phase file defines run_phase() and is sourced in-process, so counters and
# env accumulate across phases.
run_one() { # phase-name file
local name="$1" file="$2"
group "PHASE: $name"
if [ ! -f "$file" ]; then fail "$name" harness phase-file "missing $file"; return; fi
# `source` on a file with a syntax error leaves the previous run_phase bound,
# and that phase would run again with its results recorded under this name.
unset -f run_phase
# shellcheck disable=SC1090
source "$file"
if ! declare -F run_phase >/dev/null; then
fail "$name" harness phase-load "$file defined no run_phase (syntax error?)"
return
fi
run_phase || warn "phase $name returned non-zero (continuing)"
}
if [ "$SKIP_INSTALL" -eq 0 ] && selected install; then run_one install "$E2E_ROOT/phases/00-install.sh"; fi
if selected introspection; then run_one introspection "$E2E_ROOT/phases/10-introspection.sh"; fi
if selected validation; then run_one validation "$E2E_ROOT/phases/20-validation.sh"; fi
if selected android; then run_one android "$E2E_ROOT/phases/30-android.sh"; fi
if selected chromium; then run_one chromium "$E2E_ROOT/phases/40-chromium.sh"; fi
if selected rn; then run_one rn "$E2E_ROOT/phases/50-rn-bluesky.sh"; fi
if selected device-provider; then run_one device-provider "$E2E_ROOT/phases/60-device-provider.sh"; fi
# Every selected phase ran. The EXIT trap does cleanup, report and exit code —
# the same path an aborted run takes, so teardown cannot depend on getting here.
E2E_COMPLETED=1