Files
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

101 lines
4.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { FAILURE_CODES, getFailureSignal } from "@argent/registry";
// Stub the adb round-trip (capture the shell command strings) but keep the real
// `shellQuote` so the text-injection quoting is exercised, not mocked away.
const adbShell = vi.fn();
vi.mock("../src/utils/adb", async (importActual) => {
const actual = await importActual<typeof import("../src/utils/adb")>();
return { ...actual, adbShell: (...args: unknown[]) => adbShell(...args) };
});
// Single VVD: the input path derives its serial from the emulator console port.
vi.mock("../src/utils/vega-automation", () => ({
emulatorSerial: vi.fn(async () => ({ serial: "emulator-5554", consolePort: 5554 })),
}));
import { injectVegaButtons, injectVegaNamedKey, injectVegaText } from "../src/utils/vega-input";
// Real device output (verified on a VVD): get_screen_size prints this when
// developer mode is ON; when OFF the dev-shell service is down and every
// inputd-cli command (get_screen_size included) returns the error below.
const SIZE_OK = "1920 x 1080";
const DEV_SHELL_DOWN = "Error: No running instances of com.amazon.dev.shell.service found";
/** The single adb shell script for the most recent injection. */
function lastScript(): string {
return adbShell.mock.calls.at(-1)?.[1] as string;
}
/** Await a promise expected to reject and return the thrown Error. */
async function captureError(p: Promise<unknown>): Promise<Error> {
try {
await p;
} catch (e) {
return e as Error;
}
throw new Error("expected the promise to reject, but it resolved");
}
beforeEach(() => {
adbShell.mockReset();
// Default: developer mode on, channel live.
adbShell.mockResolvedValue(SIZE_OK);
});
describe("injectViaInputd — developer-mode / liveness gate", () => {
it("runs the presses when get_screen_size reports a live channel", async () => {
await expect(injectVegaButtons(["down", "select"])).resolves.toBeUndefined();
const script = lastScript();
expect(script).toContain("inputd-cli get_screen_size");
// Presses are gated behind the size-shape `case` so a dead channel fails fast.
expect(script).toContain('case "$sz" in *[0-9]*x*[0-9]*)');
expect(script).toContain("button_press KEY_DOWN >/dev/null 2>&1 || true");
expect(script).toContain("sleep 0.3");
// Path order preserved: down before select(=ENTER).
expect(script.indexOf("KEY_DOWN")).toBeLessThan(script.indexOf("KEY_ENTER"));
});
it("fails with an actionable, classified error when developer mode is off", async () => {
adbShell.mockResolvedValue(DEV_SHELL_DOWN);
const err = await captureError(injectVegaButtons(["down"]));
expect(err.message).toMatch(/developer mode is off/i);
expect(err.message).toContain("vsm developer-mode enable");
// Classified for telemetry, not a bare 500.
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.VEGA_INPUT_UNAVAILABLE);
});
it("uses the generic channel error (not the dev-mode hint) for an unrelated dead channel", async () => {
adbShell.mockResolvedValue(""); // no <W>x<H>, no dev-shell signature
const err = await captureError(injectVegaButtons(["down"]));
expect(err.message).toMatch(/input channel is not usable/i);
expect(err.message).not.toMatch(/developer mode/i);
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.VEGA_INPUT_UNAVAILABLE);
});
});
describe("injectVegaText", () => {
it("rejects embedded newlines (send_text would truncate the tail)", async () => {
await expect(injectVegaText("line1\nline2")).rejects.toThrow(/newline/i);
// Guard runs before any device round-trip.
expect(adbShell).not.toHaveBeenCalled();
});
it("shell-quotes the text so a quote/space can't break out of the command", async () => {
await injectVegaText("it's a test");
expect(lastScript()).toContain("send_text 'it'\\''s a test'");
});
});
describe("injectVegaNamedKey", () => {
it("maps a known key to its KEY_ code", async () => {
await injectVegaNamedKey("enter");
expect(lastScript()).toContain("button_press KEY_ENTER");
});
it("throws on an unknown key instead of silently dropping it", async () => {
// f1–f12 are mapped; f13 is the first out-of-range function key.
await expect(injectVegaNamedKey("f13")).rejects.toThrow(/Unknown Vega key/i);
expect(adbShell).not.toHaveBeenCalled();
});
});