Files
software-mansion__argent/packages/tool-server/test/vega-inspect-http-status.test.ts
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

98 lines
4.2 KiB
TypeScript

/**
* Regression for PR #366 review thread r3442921231.
*
* The Vega automation toolkit is reached over a forwarded HTTP port, so `postJson`
* must honor the response status: a non-2xx toolkit answer has to surface as a
* thrown error (which `describeVega` degrades to its empty-tree + relaunch-hint
* fallback), never be handed downstream as page source. Otherwise a success-shaped
* 500 body is parsed into a real-looking tree, and a structured/empty error body is
* misreported as an empty screen.
*
* vega-inspect's only host deps (adb forward + serial discovery) are stubbed so the
* real fetchVegaPageSource/postJson talk to a fake local toolkit instead of a VVD.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { once } from "node:events";
const runAdb = vi.fn(async (..._a: unknown[]) => ({ stdout: "", stderr: "", code: 0 }));
const emulatorSerial = vi.fn();
vi.mock("../src/utils/adb", () => ({ runAdb: (...a: unknown[]) => runAdb(...a) }));
vi.mock("../src/utils/vega-automation", () => ({
emulatorSerial: (...a: unknown[]) => emulatorSerial(...a),
}));
import { fetchVegaPageSource } from "../src/utils/vega-inspect";
import { describeVega } from "../src/tools/describe/platforms/vega";
// Must match HOST_PORT_OFFSET in vega-inspect.ts: forwarded host port = consolePort + 10000.
const HOST_PORT_OFFSET = 10_000;
let server: Server | undefined;
afterEach(async () => {
if (server) {
server.close();
await once(server, "close").catch(() => {});
server = undefined;
}
});
/** Stand up a fake toolkit on a free port and point emulatorSerial at it. */
async function fakeToolkit(status: number, body: string): Promise<void> {
server = createServer((_req, res) => {
res.statusCode = status;
res.setHeader("content-type", "application/json");
res.end(body);
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const hostPort = (server.address() as AddressInfo).port;
emulatorSerial.mockResolvedValue({
serial: "emulator-5554",
consolePort: hostPort - HOST_PORT_OFFSET,
});
}
// A success-shaped page source with one focusable "Search" button — non-empty when
// parsed, so a 500 body leaking through would be observable as a populated tree.
const PAGE =
'<root><window width="1920" height="1080">' +
'<view role="button" focusable="true" focused="true" x="0" y="0" width="200" height="80">' +
"<text>Search</text></view></window></root>";
describe("fetchVegaPageSource honors the toolkit HTTP status", () => {
it("rejects a non-2xx response even when its body is success-shaped", async () => {
await fakeToolkit(500, JSON.stringify({ jsonrpc: "2.0", id: 1, result: PAGE }));
await expect(fetchVegaPageSource(2000)).rejects.toThrow(/HTTP 500/);
});
it("resolves the page source on a 2xx response", async () => {
await fakeToolkit(200, JSON.stringify({ jsonrpc: "2.0", id: 1, result: PAGE }));
await expect(fetchVegaPageSource(2000)).resolves.toContain("<root>");
});
});
describe("describeVega degrades a failing toolkit, never acts on bad data", () => {
it("does not surface a populated tree from an HTTP 500 body", async () => {
await fakeToolkit(500, JSON.stringify({ jsonrpc: "2.0", id: 1, result: PAGE }));
const out = await describeVega("amazon-test");
expect(out.source).toBe("vega-automation");
// Would be a "Search" button if the 500 body leaked past the status check.
expect(out.tree.children).toEqual([]);
expect(out.hint).toMatch(/relaunch the foreground app/i);
});
it("degrades a non-XML 2xx body to empty tree + hint without crashing", async () => {
// The parse guard half of the same thread: a 200 whose result is non-XML and
// over the 50-char empty gate must be caught, not escape as a raw parse error.
const nonXml = "automation toolkit returned an opaque non-XML diagnostic payload string";
expect(nonXml.length).toBeGreaterThan(50);
await fakeToolkit(200, JSON.stringify({ jsonrpc: "2.0", id: 1, result: nonXml }));
const out = await describeVega("amazon-test");
expect(out.tree.children).toEqual([]);
expect(out.hint).toMatch(/relaunch the foreground app/i);
});
});