Files
software-mansion__argent/packages/tool-server/test/metro/vega-debugger-capability.test.ts
Ignacy Łątka af8476c0a9 feat(vega): support the JS-runtime debugger and network inspector on Fire TV (#502)
Vega's React Native is a fork of **RN 0.72**, so it serves the *legacy*
Hermes inspector rather than the post-0.73 (Fusebox) debugger. The
`debugger-*` / `view-network-*` / `react-profiler-*` tools were never
gated for it, so every call against a VVD was rejected before any RN
code ran:

```
Tool 'view-network-logs' is not supported on vega vvd (no vega support declared).
```

That gate turned out to be the only thing in the way for most of them.

The concern that prompted this was that the network inspector needs the
new debugger. It does not: argent's network inspector never touches the
CDP `Network` domain, it monkey-patches `fetch` over `Runtime.evaluate`
and reads back a global array. `Runtime.evaluate` has worked in Hermes
since long before 0.73, so the network inspector is precisely the tool
that is *unaffected* by the missing new-debugger stack.

## What now works on Vega

Gated in via `DEBUGGER_TOOL_CAPABILITY` (these need only
`Runtime.evaluate`):

`debugger-connect` · `debugger-status` · `debugger-evaluate` ·
`debugger-log-registry` · `view-network-logs` ·
`view-network-request-details`

## What stays gated off, and why

`debugger-component-tree` · `debugger-inspect-element` ·
`debugger-reload-metro` · `react-profiler-*` · `profiler-*`

Two different reasons, and it is worth keeping them apart:

- **`debugger-component-tree` and `debugger-inspect-element` are
hard-blocked.** They are the only two callers of
`cdp.evaluateWithBinding`, i.e. they deliver their payload over
`Runtime.addBinding` / `Runtime.bindingCalled`. RN 0.72's Hermes **ACKs
`Runtime.addBinding` and never installs the binding** — confirmed on the
VVD: after a successful `debugger-connect`, `typeof __argent_callback`
is still `"undefined"`, and no `bindingCalled` ever fires. They would
hang until timeout.
- **`debugger-reload-metro` and the profiler tools are simply unverified
here.** They do *not* use the binding (`reload-metro` is `Page.reload`
plus an HTTP `/reload` fallback; the profilers ride `Runtime.evaluate`
and the CDP `Profiler` domain). They share `RN_ONLY_TOOL_CAPABILITY` and
stay out of scope rather than ship untested.

Either way the capability gate turns a hang (or an unknown) into an
immediate, explicit "not supported". `describe` remains the way to read
on-screen structure on Vega.

Worth noting for a follow-up: the component-tree data itself *is*
reachable on RN 0.72 — walking the DevTools hook via a plain
`Runtime.evaluate` returns the real fiber tree (640 fibers on the sample
app). Component-tree is blocked by argent's transport choice, not by a
missing runtime capability, so a non-binding fallback would unlock it.

## Two bugs this surfaced

**A Vega `device_id` could bind to another device's runtime.** Vega's
legacy inspector-proxy emits no `reactNative` block at all, so its
targets carry no `logicalDeviceId`. `selectTarget` both matched
`device_id` on `logicalDeviceId` *and* counted connected devices the
same way — so a Vega device was invisible to the multi-device guard from
#397: its id matched no target, the guard counted zero devices,
concluded there was nothing to disambiguate, and fell through to the
priority target. With a Fusebox iOS/Android app on the same Metro, that
target wins the priority list, so `debugger-evaluate` would have run JS
**in the wrong app** while `debugger-status` reported the other device's
name and `isNewDebugger: true`. Devices are now keyed by
`logicalDeviceId || deviceName` (the legacy proxy always reports a
name), so the guard sees them and refuses to guess. A lone VVD still
falls back to its own targets. This is not Vega-specific: any RN ≤ 0.72
device was invisible the same way.

**Metro's missing project-root header.** RN 0.72's Metro never sends
`X-React-Native-Project-Root`, and `discoverMetro` treated a missing
header as fatal — which took down evaluate, console logs and the network
inspector too. The header only feeds source-map / `file:line`
resolution, which degrades cleanly to no-location, so it is now
optional. Platform-agnostic, not a Vega special-case: a missing
source-map hint should never kill an otherwise working debugger session.

That made `projectRoot: ""` a reachable state, which two
source-resolution paths mishandled. `symbolicate` stripped the root with
`file.replace(projectRoot + "/", "")`, which on an empty root degrades
to `.replace("/", "")` and deletes the first slash *anywhere* in the
path (`src/screens/Home.tsx` → `srcscreens/Home.tsx`). And
`readSourceFragment` did fail closed, but only because
`fs/promises.realpath("")` happens to reject where the sync API returns
the tool-server's cwd — it now bails explicitly instead of leaning on
that quirk. Both are reachable from `debugger-inspect-element` on any
legacy Metro (not on Vega, where it is gated off).

## Verification

Driven end-to-end against a real VVD (`amazon-6b8a…`) running the
`react-native-multi-tv-app-sample` Vega app (Debug `.vpkg`, Metro
reachable via reverse port-forward), through a tool-server built from
this branch:

```
debugger-status   -> connected: true, deviceName: kepler-device, isNewDebugger: false, projectRoot: ""
debugger-evaluate -> "hermes=object fetch=function global=object"
debugger-log-registry -> captured console.log "PR502_E2E_MARKER"

=== NETWORK LOGS (page 1/1, 2 total) ===
{id: rn-net-1} "GET /todos/7" 200  Fetch 98 B  55 ms
{id: rn-net-2} "GET /users/3" 200  Fetch 520 B 17 ms

view-network-request-details -> full request/response headers, status, timing
```

And the gated tools fail fast (0.02s) rather than hanging:

```
debugger-component-tree  -> not supported on vega vvd
debugger-inspect-element -> not supported on vega vvd
debugger-reload-metro    -> not supported on vega vvd
react-profiler-start     -> not supported on vega vvd
```

Re-ran the whole sequence against the fixed target-selection to confirm
the single-VVD path still connects.

Also run locally: `tsc --build`, `tsc --noEmit -p tsconfig.test.json`,
`eslint . --max-warnings 0`, `prettier --check`, `grade-skills`, and the
tool-server unit tests (199 files / 1740 tests). Tests added:
`vega-target-routing` (the mis-route, using a target payload captured
verbatim from the live VVD), `source-resolver-empty-root` (both
empty-root paths), and `vega-debugger-capability` — which now drives the
real `ToolDefinition.capability` fields and `resolveDevice` instead of a
hand-written name↔constant pairing, pinning all 6 enabled and all 14
gated tools. The old version passed even with `debugger-status` wired to
the wrong capability constant.

Scope note: VVD only — no tool declares `vega: { device: true }`, so
physical Fire TV hardware is untouched.
2026-07-15 15:36:12 +02:00

160 lines
7.5 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { Registry } from "@argent/registry";
import { createRegistry } from "../../src/utils/setup-registry";
import { assertSupported, UnsupportedOperationError } from "../../src/utils/capability";
import { resolveDevice } from "../../src/utils/device-info";
import {
DEBUGGER_TOOL_CAPABILITY,
RN_ONLY_TOOL_CAPABILITY,
} from "../../src/tools/debugger/debugger-service-ref";
// Import the real ToolDefinitions rather than hand-pairing a tool name with a
// capability constant: the HTTP gate does `assertSupported(def.id, def.capability,
// device)` (src/http.ts), so the definition's own `capability` field is the only
// thing that decides. Asserting against it means a tool that imports the WRONG
// constant breaks this test instead of shipping.
import { debuggerConnectTool } from "../../src/tools/debugger/debugger-connect";
import { debuggerStatusTool } from "../../src/tools/debugger/debugger-status";
import { debuggerEvaluateTool } from "../../src/tools/debugger/debugger-evaluate";
import { debuggerLogRegistryTool } from "../../src/tools/debugger/debugger-log-registry";
import { debuggerComponentTreeTool } from "../../src/tools/debugger/debugger-component-tree";
import { debuggerInspectElementTool } from "../../src/tools/debugger/debugger-inspect-element";
import { debuggerReloadMetroTool } from "../../src/tools/debugger/debugger-reload-metro";
import { networkLogsTool } from "../../src/tools/network/network-logs";
import { networkRequestTool } from "../../src/tools/network/network-request";
import { createReactProfilerStartTool } from "../../src/tools/profiler/react/react-profiler-start";
import { createReactProfilerStopTool } from "../../src/tools/profiler/react/react-profiler-stop";
import { createReactProfilerStatusTool } from "../../src/tools/profiler/react/react-profiler-status";
import { reactProfilerRendersTool } from "../../src/tools/profiler/react/react-profiler-renders";
import { reactProfilerFiberTreeTool } from "../../src/tools/profiler/react/react-profiler-fiber-tree";
import { reactProfilerAnalyzeTool } from "../../src/tools/profiler/react/react-profiler-analyze";
import { reactProfilerCpuSummaryTool } from "../../src/tools/profiler/react/react-profiler-cpu-summary";
import { reactProfilerComponentSourceTool } from "../../src/tools/profiler/react/react-profiler-component-source";
import { profilerLoadTool } from "../../src/tools/profiler/query/profiler-load";
import { profilerCpuQueryTool } from "../../src/tools/profiler/query/profiler-cpu-query";
import { profilerCommitQueryTool } from "../../src/tools/profiler/query/profiler-commit-query";
// `vega device list` reports VVD serials as `amazon-<id>`. Go through the real
// resolveDevice() instead of hand-building a DeviceInfo: if the `amazon-`
// classification regresses, the serial falls through to "android" and every
// assertion below silently changes meaning (the gated tools support Android, so
// they would stop throwing) — the explicit classification test pins that.
const VEGA_VVD_ID = "amazon-6b8a76bae9485138";
const IOS_SIM_ID = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA";
const ANDROID_EMU_ID = "emulator-5554";
const vegaVvd = resolveDevice(VEGA_VVD_ID);
// react-profiler-{start,stop,status} are factory-built (they close over the
// registry to reach the profiler session), so they can only be inspected by
// constructing them the way setup-registry.ts does. Their capability does not
// depend on the registry instance.
const registry = new Registry();
/**
* Enabled on a Vega VVD. Vega's React Native is a fork of RN 0.72, so it serves
* the legacy Hermes inspector, which speaks Runtime + Debugger. Everything here
* needs nothing beyond `Runtime.evaluate` — including the network inspector,
* which monkey-patches `fetch` over `Runtime.evaluate` rather than using the CDP
* `Network` domain. All six verified working on a live VVD.
*/
const VEGA_ENABLED_TOOLS = [
debuggerConnectTool,
debuggerStatusTool,
debuggerEvaluateTool,
debuggerLogRegistryTool,
networkLogsTool,
networkRequestTool,
];
/**
* Gated off on a Vega VVD, for two distinct reasons:
*
* - `debugger-component-tree` and `debugger-inspect-element` are the only two
* callers of `cdp.evaluateWithBinding`, i.e. the only ones that need
* `Runtime.addBinding`. Legacy Hermes ACKs `Runtime.addBinding` but never
* installs it — verified on a live VVD: after connect, `typeof
* __argent_callback` is still "undefined" — so no `bindingCalled` ever fires
* and they would hang until timeout.
* - `debugger-reload-metro` (Page.reload + an HTTP /reload fallback) and the
* `react-profiler-*` / `profiler-*` tools (`Runtime.evaluate` + the CDP
* `Profiler` domain) do NOT use the binding. They are gated because they are
* unverified against the legacy inspector, not because of a missing binding.
*
* Either way the gate turns a hang or an unknown into an immediate 400.
*/
const VEGA_GATED_TOOLS = [
debuggerComponentTreeTool,
debuggerInspectElementTool,
debuggerReloadMetroTool,
createReactProfilerStartTool(registry),
createReactProfilerStopTool(registry),
createReactProfilerStatusTool(registry),
reactProfilerRendersTool,
reactProfilerFiberTreeTool,
reactProfilerAnalyzeTool,
reactProfilerCpuSummaryTool,
reactProfilerComponentSourceTool,
profilerLoadTool,
profilerCpuQueryTool,
profilerCommitQueryTool,
];
describe("Vega (RN 0.72) debugger capability", () => {
it("classifies an amazon- serial as a Vega VVD", () => {
// A regression here would 400 every debugger tool on Vega (or, worse, route
// the serial to Android and quietly admit the gated ones).
expect(vegaVvd).toEqual({ id: VEGA_VVD_ID, platform: "vega", kind: "vvd" });
});
it.each(VEGA_ENABLED_TOOLS.map((t) => [t.id, t] as const))(
"%s is allowed on a Vega VVD",
(_id, tool) => {
expect(tool.capability).toBeDefined();
expect(() => assertSupported(tool.id, tool.capability, vegaVvd)).not.toThrow();
}
);
it.each(VEGA_GATED_TOOLS.map((t) => [t.id, t] as const))(
"%s is rejected on a Vega VVD",
(_id, tool) => {
expect(tool.capability).toBeDefined();
expect(() => assertSupported(tool.id, tool.capability, vegaVvd)).toThrow(
UnsupportedOperationError
);
}
);
it("keeps both lists exhaustive — every tool on either matrix is pinned above", () => {
// Derived from the real registry, not a hardcoded count: a NEW debugger tool
// that picks up DEBUGGER_TOOL_CAPABILITY lands on Vega the moment it ships,
// and if it needs Runtime.addBinding it hangs there. It must be listed (and
// therefore decided about) here.
const pinned = new Set([...VEGA_ENABLED_TOOLS, ...VEGA_GATED_TOOLS].map((t) => t.id));
const live = createRegistry();
const drifted = live
.getSnapshot()
.tools.map((id) => live.getTool(id))
.filter(
(def) =>
def &&
(def.capability === DEBUGGER_TOOL_CAPABILITY ||
def.capability === RN_ONLY_TOOL_CAPABILITY) &&
!pinned.has(def.id)
)
.map((def) => def!.id);
expect(drifted).toEqual([]);
});
it("does not regress the existing platforms — iOS and Android still get every tool", () => {
const iosSim = resolveDevice(IOS_SIM_ID);
const androidEmu = resolveDevice(ANDROID_EMU_ID);
for (const device of [iosSim, androidEmu]) {
for (const tool of [...VEGA_ENABLED_TOOLS, ...VEGA_GATED_TOOLS]) {
expect(() => assertSupported(tool.id, tool.capability, device)).not.toThrow();
}
}
});
});