Files
software-mansion__argent/scripts/ci/vega-vvd-test.sh
Piotr Krawiec 0bed3624a0 feat(vega): Amazon Vega (Fire TV) platform support (#366)
## Summary

Adds **Vega (Amazon Fire TV)** as a third Argent platform alongside iOS
and Android. Vega apps are React Native 0.72 + Hermes running on a QEMU
**Virtual Device (VVD)**, driven by a **D-pad tv-remote rather than
touch**. This PR extends Argent's platform abstraction so the existing
tool surface gates correctly on `vega`, and adds the Vega-specific tools
needed to actually drive a Fire TV app end to end.

### Platform abstraction

- `Platform` gains `vega`; `DeviceKind` gains `vvd` (the QEMU VVD);
`ToolCapability` gains a `vega { vvd, device }` block; `ToolDependency`
gains `vega`.
- `assertSupported()` selects the `vega` capability matrix for Vega
devices; `dispatchByPlatform()` gains an optional `vega` branch, and a
Vega dispatch with no branch throws `NotImplementedOnPlatformError`
(501).
- New `vega-cli` util (modeled on `adb.ts`) resolves `vega`/`kepler`
from `PATH` or `~/vega/bin` with the same hardened exec/quoting;
`check-deps` resolves and hints the `vega` dependency.
- The `simulator-server` blueprint is never wired for `vega` (Vega uses
`adb` + the `vega`/`kepler` CLI, not the proprietary binary).

### How Vega is wired

- **Input / screenshot / describe** go over `adb` (input via
`inputd-cli`, screenshots host-side via the Android emulator console,
element tree via the on-device automation toolkit).
- **App lifecycle** goes over the `vega`/`kepler` CLI.
- v1 is **Virtual-Device-only**: every Vega capability is declared
`vvd`. Physical Fire TV is out of scope and untested — the tool-server
does not connect to or detect hardware, so `resolveDevice` classifies
every `amazon-` serial as a VVD by shape. A physical device is therefore
not classified as `device` and not rejected at the capability gate;
supporting and gating real hardware is deferred to a version where it
can be tested.
- Vega runs **React Native 0.72**, which predates the new React Native
DevTools. The CDP/Hermes debugger surface (`debugger-*`, React/native
profilers) depends on integration points the new DevTools provides, so
**debugging and profiling are out of scope for this PR** — Vega
profiling/crash analysis is handled by the
`amazon-devices-buildertools-mcp` server instead.

### Also includes

- `argent-vega` skill documenting the full Vega workflow (target
discovery, the describe → compute-path → `tv-remote` navigation loop,
text injection).
- Vega VVD end-to-end CI workflow.

## Feature support by platform

Legend: ✅ supported · ❌ not supported · ⚠️ degraded / conditional · —
not applicable

| Feature (tool) | iOS | Android | Chromium | **Vega (VVD)** | Vega
notes |
|---|:---:|:---:|:---:|:---:|---|
| **Device discovery** (`list-devices`) | ✅ | ✅ | ✅ | **✅** | Tagged
`platform:"vega"`, `kind:"vvd"`; pass `serial` as `udid` |
| **Boot / start device** | ✅ | ✅ | — | ✅ |  |
| **Launch app** (`launch-app`) | ✅ | ✅ | ✅ | **✅** | `bundleId` =
interactive-component app id from `manifest.toml` |
| **Restart app** (`restart-app`) | ✅ | ✅ | ✅ | **✅** | Terminate +
relaunch |
| **Reinstall app** (`reinstall-app`) | ✅ | ✅ | ❌ | **✅** | Uninstall +
install a `.vpkg` |
| **Screenshot** (`screenshot`) | ✅ | ✅ | ✅ | **✅** | Host-side via
emulator console (needs `adb`); rotation ignored (fixed landscape) |
| **Describe / element tree** (`describe`) | ✅ | ✅ | ✅ | **✅** |
On-device automation toolkit; surfaces `[focused]`/`[selected]` +
normalized frames |
| **Touch gestures** (`gesture-tap`/`swipe`/`scroll`/`drag`/`custom`) |
✅ | ✅ | ✅ | **❌** | Vega is tv-remote-driven; gestures error — use
`tv-remote` |
| **Pinch / rotate gesture** (`gesture-pinch`/`gesture-rotate`) | ✅ | ❌
| ⚠️ | **❌** | N/A on a TV |
| **Remote / D-pad** (`tv-remote`) | — | — | — | **✅** | Vega-only tool;
single key, repeat, or a whole path in one call |
| **Hardware buttons** (`button`) | ✅ | ✅ | — | **❌** | Remote keys live
in `remote` instead |
| **Text input** (`keyboard`) | ✅ | ✅ | ✅ | **✅** | Whole-string
injection via `inputd-cli send_text` |
| **Rotate orientation** (`rotate`) | ✅ | ✅ | — | **❌** | TV framebuffer
is fixed landscape |
| **Open URL / deep link** (`open-url`) | ✅ | ✅ | ✅ | **❌** | Not wired
on Vega |
| **JS debugger** (`debugger-*`) | ✅ | ✅ | ✅ | **❌** | RN 0.72 predates
the new RN DevTools — out of scope for this PR |
| **Native devtools / AX** (`native-describe-screen`, etc.) | ✅ | ❌ | —
| **❌** | iOS-only |
| **Native profiler** (`native-profiler-*`) | ✅ | ✅ | — | **❌** | Use
`amazon-devices-buildertools-mcp` (Perfetto) instead |
| **React profiler** (`react-profiler-*`) | ✅ | ✅ | — | **❌** | Out of
scope; RN 0.72 / new RN DevTools not supported |
| **Screenshot diff** (`screenshot-diff`) | ✅ | ✅ | ✅ | **✅** |
Host-side image comparison; platform-agnostic |
| **Flows record/replay** (`flow-*`) | ✅ | ✅ | ✅ | **✅** | Records any
tool-call sequence |

## Test plan

- [x] `list-devices` surfaces connected Vega devices (`platform:"vega"`,
`kind:"vvd"`)
- [x] `launch-app`, `restart-app`, `reinstall-app`,
`list-installed-apps` work on Vega
- [x] `tv-remote` drives focus (single key, `repeat`, and multi-key
path) and `keyboard` injects whole strings
- [x] `screenshot` returns a frame via the host-side VVD path
- [x] `describe` returns the on-screen element tree with `[focused]`
state and normalized coordinates
- [x] Vega VVD e2e CI workflow passes
- [ ] Test multiple platforms being driven at the same time
    - [x] Android + Vega
    - [ ] iOS + Vega
    - [ ] chrome + Vega

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:31:45 +02:00

354 lines
16 KiB
Bash

#!/usr/bin/env bash
# End-to-end CI smoke test for argent's Vega (Fire TV) tools, built from THIS
# branch's source. Runs INSIDE the vega-virtual-device-host container with the
# VVD already booted and ready — i.e. as the `script:` of
# finloop/vega-virtual-device-action (see .github/workflows/vega-vvd-e2e.yml).
#
# It drives the tools through the tool-server's HTTP API (the same way
# wayland-e2e.yml drives the Android path), so it exercises the real code:
# - list-devices → discovers the VVD and its serial
# - screenshot → `adb emu screenrecord` (host-side, no native binary)
# - tv-remote → `adb shell inputd-cli` button injection
# - describe → `adb forward` + on-device automation toolkit
# - keyboard → `adb shell inputd-cli send_text`
# - restart-app / reinstall-app
# → the `vega`/`kepler` CLI
#
# There is intentionally no vega-fast-cli probe anymore: the host binary is gone,
# so the arch/glibc question it raised is moot. `tv-remote` working here proves the
# adb/inputd-cli replacement drives the device.
#
# Prereq: the workspace is already built (`npm ci` + `tsc --build` on the runner,
# bind-mounted in at /workspace), so `packages/tool-server/dist/index.js` exists.
#
# NOT `set -e`: gated checks are captured for a per-tool summary, then we exit
# non-zero if any failed.
set -uo pipefail
PORT="${ARGENT_PORT:-3033}"
OUT_DIR="${OUT_DIR:-artifacts}"
mkdir -p "$OUT_DIR"
KEPLER_VPKG="${KEPLER_VPKG:-fixtures/keplervideoapp_aarch64.vpkg}"
APP_PKG="${APP_PKG:-com.amazondeveloper.keplervideoapp}"
APP_ID="${APP_ID:-${APP_PKG}.main}"
TOOLS_URL="http://127.0.0.1:${PORT}"
# /tmp is lost when the container exits; copy logs into the uploaded artifact dir.
# shellcheck disable=SC2329 # invoked indirectly via the EXIT trap below
copy_logs() { cp /tmp/tool-server.log /tmp/*-app.log "$OUT_DIR/" 2>/dev/null || true; }
trap copy_logs EXIT
FAILURES=()
fail() { echo "FAIL: $*"; FAILURES+=("$*"); }
group() { echo "::group::$*"; }
endg() { echo "::endgroup::"; }
# Call a tool over HTTP: post_tool <id> <json-args>. Prints the raw response.
post_tool() {
curl -fsS -m 60 -X POST "${TOOLS_URL}/tools/$1" \
-H 'Content-Type: application/json' -d "$2" 2>/dev/null
}
# Dig a dotted field out of a tool response, tolerating the `{data:{…}}` wrapper.
# The JSON is piped on stdin (not passed as argv) so large responses don't trip
# "Argument list too long". The program goes via `-c` (stdin is the data), with
# the dotted path as argv[1].
# jget '<json>' path.to.field
jget() {
printf '%s' "$1" | python3 -c '
import sys, json
try:
obj = json.load(sys.stdin)
except Exception:
sys.exit(0)
node = obj.get("data", obj) if isinstance(obj, dict) else obj
for key in sys.argv[1].split("."):
if isinstance(node, dict) and key in node:
node = node[key]
else:
sys.exit(0)
print(node if not isinstance(node, (dict, list)) else json.dumps(node))
' "$2"
}
# PNG non-black check: a black capture decompresses to ~all-zero bytes (~0.000);
# the kepler app is a DARK media UI that renders ~0.01+, so the floor is 0.004
# (matches the action repo's navigation script). Prints WxH + the fraction.
NONBLACK_MIN_FRAC="${NONBLACK_MIN_FRAC:-0.004}"
nonblack() {
python3 - "$1" "$NONBLACK_MIN_FRAC" <<'PY'
import sys, zlib, struct
try:
d = open(sys.argv[1], "rb").read()
except OSError:
sys.exit(1)
if d[:8] != b"\x89PNG\r\n\x1a\n":
sys.exit(1)
i, idat, w, h = 8, bytearray(), 0, 0
while i + 8 <= len(d):
ln = struct.unpack(">I", d[i:i + 4])[0]; t = d[i + 4:i + 8]
if t == b"IHDR": w, h = struct.unpack(">II", d[i + 8:i + 16])
if t == b"IDAT": idat += d[i + 8:i + 8 + ln]
i += 12 + ln
if t == b"IEND": break
raw = zlib.decompress(bytes(idat))
frac = (len(raw) - raw.count(0)) / len(raw) if raw else 0.0
sys.stderr.write(f"{w}x{h} nonblack_frac={frac:.4f}\n")
sys.exit(0 if frac > float(sys.argv[2]) else 1)
PY
}
# ── Environment ─────────────────────────────────────────────────────────────
group "Environment"
echo "node $(node -v 2>/dev/null || echo '<none>')"
adb version 2>/dev/null | head -1 || echo "adb <none>"
echo "vega $(vega -v 2>/dev/null | tr '\n' ' ' || echo '<none>')"
if test -f packages/tool-server/dist/index.js; then
echo "tool-server dist: present"
else
echo "ERROR: packages/tool-server/dist/index.js missing — build the workspace first"; exit 1
fi
endg
# ── Start the tool-server (built from this branch) ──────────────────────────
# The container's Node (20.x) is older than 22.12, where `require(esm)` is on by
# default. tool-server compiles to CommonJS but depends on the ESM-only
# `@argent/configuration-core`, so the raw `tsc` dist needs
# `--experimental-require-module` (backported to Node 20.17+) to load it. The
# production esbuild bundle inlines the dep and doesn't need this.
group "Start tool-server"
: > /tmp/tool-server.log
setsid env ARGENT_PORT="$PORT" node --experimental-require-module packages/tool-server/dist/index.js start \
</dev/null >/tmp/tool-server.log 2>&1 &
ready=""
for _ in $(seq 1 30); do
if curl -fsS -o /dev/null "${TOOLS_URL}/tools" 2>/dev/null; then ready=1; break; fi
sleep 1
done
if [ -z "$ready" ]; then echo "ERROR: tool-server not ready"; cat /tmp/tool-server.log; exit 1; fi
echo "tool-server up at ${TOOLS_URL}"
endg
# ── adb + install/launch the kepler app ─────────────────────────────────────
group "adb sees the VVD"
adb start-server >/dev/null 2>&1 || true
timeout 60 adb wait-for-device || { echo "ERROR: adb never saw the device"; exit 1; }
adb devices -l
endg
group "Install + launch kepler video app"
VPKG_ABS="$(readlink -f "$KEPLER_VPKG" 2>/dev/null || echo "$KEPLER_VPKG")"
[ -f "$VPKG_ABS" ] || { echo "ERROR: fixture vpkg not found at ${VPKG_ABS}"; exit 1; }
echo "vpkg: ${VPKG_ABS} ($(stat -c%s "$VPKG_ABS" 2>/dev/null || echo '?') bytes)"
# `vega device <sub>` targets the single connected VVD (no -d; see vega-cli.ts).
# adb seeing the device isn't enough: the Vega device agent attaches a beat after
# adb, and the boot action's non-black-frame gate can report ready before it — so
# a single `vega device install-app` can hit "No connected Vega devices". Wait for
# `vega virtual-device status` running:true (the same signal argent's isVvdRunning
# uses), then retry the install to ride out a late attach.
for attempt in $(seq 1 30); do
if vega virtual-device status 2>/dev/null | grep -qE '"running"[[:space:]]*:[[:space:]]*true'; then
echo "VVD reports running"; break
fi
echo "attempt ${attempt}: vega virtual-device status not running yet; waiting..."
sleep 2
done
vega device uninstall-app -a "$APP_ID" >/dev/null 2>&1 || true
install_ok=""
for attempt in $(seq 1 10); do
vega device install-app -p "$VPKG_ABS" >/tmp/install-app.log 2>&1 || true
if grep -qi success /tmp/install-app.log; then install_ok=1; echo "install-app succeeded"; break; fi
echo "attempt ${attempt}: install-app not ready; retrying..."
sleep 3
done
if [ -z "$install_ok" ]; then
echo "ERROR: vega install-app did not report success"; cat /tmp/install-app.log; exit 1
fi
vega device launch-app -a "$APP_ID" >/tmp/launch-app.log 2>&1 || true
tail -3 /tmp/launch-app.log 2>/dev/null || true
running=""
for attempt in $(seq 1 30); do
apps="$(vega device running-apps 2>/dev/null || true)"
if [[ "$apps" == *"$APP_PKG"* ]]; then running=1; echo "app ${APP_ID} is running"; break; fi
echo "attempt ${attempt}: ${APP_ID} not running yet; retrying..."
sleep 2
done
[ -z "$running" ] && echo "WARNING: ${APP_ID} not in running-apps; continuing anyway"
sleep 10
endg
# ── Discover the Vega serial via list-devices ───────────────────────────────
group "Discover Vega device"
SERIAL=""
for attempt in $(seq 1 12); do
resp="$(post_tool list-devices '{}')"
SERIAL="$(python3 - "$resp" <<'PY'
import sys, json
try:
o = json.loads(sys.argv[1]); o = o.get("data", o)
print(next((d.get("serial") or d.get("udid") or d.get("id")
for d in o.get("devices", []) if d.get("platform") == "vega"), ""))
except Exception:
pass
PY
)"
[ -n "$SERIAL" ] && break
echo "attempt ${attempt}: no Vega device yet; retrying..."
sleep 5
done
[ -z "$SERIAL" ] && { echo "ERROR: no Vega device from list-devices"; post_tool list-devices '{}'; exit 1; }
echo "Vega serial: ${SERIAL}"
endg
# ── Relaunch through argent so the automation toolkit attaches ───────────────
# The bootstrap launch above used the raw `vega device launch-app`, which does
# NOT set the toolkit-enable flag. That flag is only read at app launch, so the
# on-device introspection server never attaches and TEST 3 (describe) returns an
# empty tree. argent's restart-app (re)asserts the flag, then terminate+launches
# — the canonical way to make an app introspectable — so the toolkit attaches.
group "Relaunch via argent (attach automation toolkit)"
resp="$(post_tool restart-app "$(printf '{"udid":"%s","bundleId":"%s"}' "$SERIAL" "$APP_ID")")" || resp=""
echo " restart-app response: ${resp:0:200}"
sleep 10
endg
# ── TEST 1: screenshot (adb emu — arch-agnostic baseline) ───────────────────
group "TEST screenshot"
SHOT_BEFORE="${OUT_DIR}/kepler-before.png"
captured=""
for attempt in 1 2 3 4 5; do
rm -f "$SHOT_BEFORE"
resp="$(post_tool screenshot "$(printf '{"udid":"%s","scale":1}' "$SERIAL")")"
# The HTTP screenshot result is an image artifact: {data:{image:{hostPath,…}}}.
src="$(jget "$resp" image.hostPath)"; [ -n "$src" ] || src="$(jget "$resp" path)"
[ -n "$src" ] && [ -f "$src" ] && cp "$src" "$SHOT_BEFORE" 2>/dev/null
if nonblack "$SHOT_BEFORE"; then captured=1; break; fi
echo "attempt ${attempt}: screenshot missing/black; retrying..."
echo " response: ${resp:0:200}"
sleep 5
done
if [ -n "$captured" ]; then
echo "OK: screenshot is non-black -> ${SHOT_BEFORE}"
else
fail "screenshot did not return a non-black image"
fi
endg
# ── TEST 2: tv-remote (adb inputd-cli — the headline) ───────────────────────
# A path of D-pad presses navigates the kepler UI in one round-trip. Success =
# the tool returns the full press count; failure surfaces the device error
# (e.g. inputd-cli missing / no focused surface).
group "TEST tv-remote (adb inputd-cli)"
REMOTE_ARGS="$(printf '{"udid":"%s","button":["down","right","right","select"]}' "$SERIAL")"
echo "tv-remote args: ${REMOTE_ARGS}"
resp="$(post_tool tv-remote "$REMOTE_ARGS")" || resp=""
count="$(jget "$resp" count)"
echo "response: ${resp:0:300}"
if [ -n "$count" ] && [ "$count" -ge 4 ] 2>/dev/null; then
echo "OK: tv-remote injected ${count} presses via adb inputd-cli"
else
fail "tv-remote did not inject the expected presses (count='${count}')"
fi
# Capture the post-navigation screen (best-effort).
resp="$(post_tool screenshot "$(printf '{"udid":"%s","scale":1}' "$SERIAL")")"
src="$(jget "$resp" image.hostPath)"; [ -n "$src" ] || src="$(jget "$resp" path)"
[ -n "$src" ] && [ -f "$src" ] && cp "$src" "${OUT_DIR}/kepler-after.png" 2>/dev/null && echo "saved ${OUT_DIR}/kepler-after.png"
endg
# ── TEST 3: describe (adb forward + on-device automation toolkit) ────────────
# The element tree is the core of the Vega nav loop. The toolkit attaches at app
# launch; retry to ride out a late attach (an empty tree is the relaunch hint).
group "TEST describe"
desc_ok=""
for attempt in 1 2 3 4 5; do
resp="$(post_tool describe "$(printf '{"udid":"%s"}' "$SERIAL")")" || resp=""
src="$(jget "$resp" source)"
desc="$(jget "$resp" description)"
# An empty / unreachable describe still returns source:"vega-automation" and a
# non-empty `description` (format-tree always emits the Source/Mode/ROOT header
# + the relaunch hint), so `src` and `-n desc` alone can't tell a real tree from
# a broken one. Require actual element lines *beyond* the ROOT header — i.e. the
# toolkit attached and produced on-screen content.
elems="$(printf '%s\n' "$desc" | awk '/^ROOT /{seen=1; next} seen && NF {c++} END{print c+0}')"
if [ "$src" = "vega-automation" ] && [ "${elems:-0}" -ge 1 ] 2>/dev/null; then desc_ok=1; break; fi
echo "attempt ${attempt}: describe empty/not ready (element lines beyond header: ${elems:-0}); relaunch + retry..."
echo " response: ${resp:0:200}"
# describe's own recovery hint: the toolkit attaches only at app launch, so
# relaunch via restart-app (re-asserts the enable flag) to (re)attach it.
post_tool restart-app "$(printf '{"udid":"%s","bundleId":"%s"}' "$SERIAL" "$APP_ID")" >/dev/null 2>&1 || true
sleep 4
done
if [ -n "$desc_ok" ]; then
echo "OK: describe returned a vega-automation tree with ${elems} element line(s) beyond the header"
else
fail "describe did not return a tree with element content beyond the header"
fi
endg
# ── TEST 4: keyboard (adb inputd-cli send_text) ─────────────────────────────
# Smoke: the text path injects via inputd-cli without erroring and reports the
# per-character count. No focused field is needed to exercise the channel.
group "TEST keyboard"
resp="$(post_tool keyboard "$(printf '{"udid":"%s","text":"argent"}' "$SERIAL")")" || resp=""
keys="$(jget "$resp" keys)"
echo "response: ${resp:0:200}"
if [ -n "$keys" ] && [ "$keys" -ge 1 ] 2>/dev/null; then
echo "OK: keyboard injected ${keys} chars via inputd-cli"
else
fail "keyboard did not report injected keys (keys='${keys}')"
fi
endg
# ── TEST 5: restart-app (terminate + relaunch) ──────────────────────────────
group "TEST restart-app"
restarted=""
for attempt in 1 2 3; do
resp="$(post_tool restart-app "$(printf '{"udid":"%s","bundleId":"%s"}' "$SERIAL" "$APP_ID")")" || resp=""
if printf '%s' "$resp" | grep -qiE '"restarted"[[:space:]]*:[[:space:]]*true'; then restarted=1; break; fi
echo "attempt ${attempt}: restart not confirmed; retrying..."
echo " response: ${resp:0:200}"
sleep 4
done
if [ -n "$restarted" ]; then
echo "OK: restart-app relaunched ${APP_ID}"
else
fail "restart-app did not report success for ${APP_ID}"
fi
sleep 5
endg
# ── TEST 6: reinstall-app (uninstall + install the .vpkg) ────────────────────
# Runs last: it leaves the app freshly installed (and not running). Uses a longer
# timeout for the install and retries the vega CLI's occasionally-racy handshake.
group "TEST reinstall-app"
reinstalled=""
for attempt in 1 2 3; do
resp="$(curl -fsS -m 180 -X POST "${TOOLS_URL}/tools/reinstall-app" \
-H 'Content-Type: application/json' \
-d "$(printf '{"udid":"%s","bundleId":"%s","appPath":"%s"}' "$SERIAL" "$APP_ID" "$VPKG_ABS")" 2>/dev/null)" || resp=""
if printf '%s' "$resp" | grep -qiE '"reinstalled"[[:space:]]*:[[:space:]]*true'; then reinstalled=1; break; fi
echo "attempt ${attempt}: reinstall not confirmed; retrying..."
echo " response: ${resp:0:300}"
sleep 5
done
if [ -n "$reinstalled" ]; then
echo "OK: reinstall-app reinstalled ${APP_ID} from the vpkg"
else
fail "reinstall-app did not report success for ${APP_ID}"
fi
endg
# ── Summary ─────────────────────────────────────────────────────────────────
echo "::group::Summary"
if [ "${#FAILURES[@]}" -eq 0 ]; then
echo "PASS: all Vega tool checks passed against the kepler app on the VVD —"
echo " list-devices, screenshot, tv-remote, describe, keyboard,"
echo " restart-app, reinstall-app."
endg
exit 0
fi
echo "FAILED checks:"
for f in "${FAILURES[@]}"; do echo " - $f"; done
endg
exit 1