mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
0bed3624a0
## 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>
156 lines
6.1 KiB
TypeScript
156 lines
6.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import type { Registry } from "@argent/registry";
|
|
|
|
const listVvdImages = vi.fn();
|
|
const isVvdRunning = vi.fn();
|
|
const startVvd = vi.fn();
|
|
const stopVvd = vi.fn();
|
|
const waitForVvdRunning = vi.fn();
|
|
const resolveRunningVvdSerial = vi.fn();
|
|
const listVegaDevices = vi.fn();
|
|
const ensureDep = vi.fn();
|
|
|
|
vi.mock("../src/utils/vega-sdk", () => ({
|
|
listVvdImages: (...a: unknown[]) => listVvdImages(...a),
|
|
}));
|
|
vi.mock("../src/utils/vega-vvd", () => ({
|
|
isVvdRunning: (...a: unknown[]) => isVvdRunning(...a),
|
|
startVvd: (...a: unknown[]) => startVvd(...a),
|
|
stopVvd: (...a: unknown[]) => stopVvd(...a),
|
|
waitForVvdRunning: (...a: unknown[]) => waitForVvdRunning(...a),
|
|
}));
|
|
vi.mock("../src/utils/vega-devices", () => ({
|
|
resolveRunningVvdSerial: (...a: unknown[]) => resolveRunningVvdSerial(...a),
|
|
listVegaDevices: (...a: unknown[]) => listVegaDevices(...a),
|
|
}));
|
|
vi.mock("../src/utils/check-deps", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("../src/utils/check-deps")>("../src/utils/check-deps");
|
|
return { ...actual, ensureDep: (...a: unknown[]) => ensureDep(...a) };
|
|
});
|
|
|
|
import { createBootDeviceTool } from "../src/tools/devices/boot-device";
|
|
|
|
const registry = {} as Registry;
|
|
const SERIAL = "amazon-4a27df03c9777152";
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
listVvdImages.mockResolvedValue([{ name: "tv", path: "/sdk/vvd/images/tv" }]);
|
|
ensureDep.mockResolvedValue(undefined);
|
|
startVvd.mockResolvedValue(undefined);
|
|
stopVvd.mockResolvedValue(undefined);
|
|
waitForVvdRunning.mockResolvedValue(undefined);
|
|
resolveRunningVvdSerial.mockResolvedValue(SERIAL);
|
|
listVegaDevices.mockResolvedValue([
|
|
{ platform: "vega", kind: "vvd", state: "running", serial: SERIAL, vvdImage: "tv" },
|
|
]);
|
|
});
|
|
|
|
describe("boot-device — Vega VVD path", () => {
|
|
it("boots a stopped VVD: resolves the image, starts that image via -p, waits, returns the serial", async () => {
|
|
isVvdRunning.mockResolvedValue(false);
|
|
|
|
const result = await createBootDeviceTool(registry).execute!({}, { vvdImage: "tv" });
|
|
|
|
expect(ensureDep).toHaveBeenCalledWith("vega");
|
|
expect(startVvd).toHaveBeenCalledWith({ timeoutSeconds: 120, imagePath: "/sdk/vvd/images/tv" });
|
|
expect(waitForVvdRunning).toHaveBeenCalled();
|
|
expect(result).toEqual({ platform: "vega", serial: SERIAL, vvdImage: "tv", booted: true });
|
|
});
|
|
|
|
it("returns the serial without restarting when the requested image is already running", async () => {
|
|
isVvdRunning.mockResolvedValue(true);
|
|
|
|
const result = await createBootDeviceTool(registry).execute!({}, { vvdImage: "tv" });
|
|
|
|
expect(startVvd).not.toHaveBeenCalled();
|
|
expect(stopVvd).not.toHaveBeenCalled();
|
|
expect((result as { serial: string }).serial).toBe(SERIAL);
|
|
});
|
|
|
|
it("rejects a non-force boot of a different image while another VVD is running", async () => {
|
|
isVvdRunning.mockResolvedValue(true);
|
|
listVvdImages.mockResolvedValue([
|
|
{ name: "tv", path: "/sdk/vvd/images/tv" },
|
|
{ name: "tablet", path: "/sdk/vvd/images/tablet" },
|
|
]);
|
|
listVegaDevices.mockResolvedValue([
|
|
{ platform: "vega", kind: "vvd", state: "running", serial: SERIAL, vvdImage: "tv" },
|
|
]);
|
|
|
|
await expect(
|
|
createBootDeviceTool(registry).execute!({}, { vvdImage: "tablet" })
|
|
).rejects.toThrow(/already running.*force:true/s);
|
|
expect(startVvd).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects a non-force boot when the running VVD's image cannot be confirmed (vvdImage: null)", async () => {
|
|
isVvdRunning.mockResolvedValue(true);
|
|
listVvdImages.mockResolvedValue([
|
|
{ name: "tv", path: "/sdk/vvd/images/tv" },
|
|
{ name: "tablet", path: "/sdk/vvd/images/tablet" },
|
|
]);
|
|
// A VVD is running but its image could not be resolved (profile mismatch with
|
|
// 2+ images, or 2+ running VVDs). An unconfirmable running image must be treated
|
|
// as a mismatch — NOT silently reported as a successful boot of the requested
|
|
// image while a different VVD is actually running.
|
|
listVegaDevices.mockResolvedValue([
|
|
{ platform: "vega", kind: "vvd", state: "running", serial: SERIAL, vvdImage: null },
|
|
]);
|
|
|
|
await expect(
|
|
createBootDeviceTool(registry).execute!({}, { vvdImage: "tablet" })
|
|
).rejects.toThrow(/already running/i);
|
|
expect(startVvd).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("force-restarts a running VVD (stop then start)", async () => {
|
|
isVvdRunning.mockResolvedValue(true);
|
|
|
|
await createBootDeviceTool(registry).execute!({}, { vvdImage: "tv", force: true });
|
|
|
|
expect(stopVvd).toHaveBeenCalled();
|
|
expect(startVvd).toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not double-spend bootTimeoutMs: waitForVvdRunning gets only the remaining budget", async () => {
|
|
isVvdRunning.mockResolvedValue(false);
|
|
let now = 1_000_000;
|
|
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
|
|
// startVvd consumes 30s of the shared boot budget.
|
|
startVvd.mockImplementation(async () => {
|
|
now += 30_000;
|
|
});
|
|
try {
|
|
await createBootDeviceTool(registry).execute!({}, { vvdImage: "tv", bootTimeoutMs: 120_000 });
|
|
const waited = waitForVvdRunning.mock.calls[0]![0] as number;
|
|
// Buggy code passes the full 120_000 again; the fix passes ~90_000 (remaining).
|
|
expect(waited).toBeLessThanOrEqual(90_000);
|
|
expect(waited).toBeGreaterThan(0);
|
|
} finally {
|
|
nowSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("throws a helpful error listing available images for an unknown image", async () => {
|
|
isVvdRunning.mockResolvedValue(false);
|
|
listVvdImages.mockResolvedValue([{ name: "tv", path: "/sdk/vvd/images/tv" }]);
|
|
|
|
await expect(
|
|
createBootDeviceTool(registry).execute!({}, { vvdImage: "phone" })
|
|
).rejects.toThrow(/not found.*Available: tv/s);
|
|
expect(startVvd).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects more than one platform selector", async () => {
|
|
await expect(
|
|
createBootDeviceTool(registry).execute!({}, { vvdImage: "tv", udid: "x" })
|
|
).rejects.toThrow(/exactly one/);
|
|
});
|
|
|
|
it("rejects when no platform selector is given", async () => {
|
|
await expect(createBootDeviceTool(registry).execute!({}, {})).rejects.toThrow(/exactly one/);
|
|
});
|
|
});
|