Files
software-mansion__argent/packages/tool-server/test/metro/source-resolver-empty-root.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

113 lines
4.5 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
import * as fs from "node:fs";
import * as fsp from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { createSourceResolver } from "../../src/utils/debugger/source-resolver";
// discoverMetro no longer hard-fails when Metro omits X-React-Native-Project-Root
// (RN 0.72 / Vega never sends it), so `projectRoot` can now legitimately be "".
// Every source-resolution path must cope with that:
//
// - readSourceFragment must fail closed, and must do so BY DESIGN. Asserting it
// returns null is not enough: fs/promises.realpath("") rejects with ENOENT and
// the broad try/catch swallows that into the same null, so the tests below
// would pass even with the guard deleted. (The SYNC realpath, by contrast,
// happily returns the tool-server's cwd — so a refactor to realpathSync, or a
// path.resolve() slipped in before the realpath, would silently start reading
// source out of the tool-server's own working directory.) Pin the guard by
// proving the filesystem is never touched at all.
// - symbolicate must not corrupt the path. `file.replace(projectRoot + "/", "")`
// degrades to `.replace("/", "")` on an empty root, which strips the first
// slash *anywhere* in the string.
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return { ...actual, realpath: vi.fn(actual.realpath), readFile: vi.fn(actual.readFile) };
});
describe("source-resolver with no project root (RN 0.72 / Vega Metro)", () => {
let fakeCwd: string;
let originalCwd: string;
beforeAll(() => {
originalCwd = process.cwd();
fakeCwd = fs.mkdtempSync(path.join(os.tmpdir(), "toolserver-cwd-"));
fs.writeFileSync(path.join(fakeCwd, "not-the-app.js"), "const SECRET = 'TOOL_SERVER_CWD';\n");
// Stand in for the tool-server's working directory.
process.chdir(fakeCwd);
});
afterAll(() => {
process.chdir(originalCwd);
fs.rmSync(fakeCwd, { recursive: true, force: true });
});
it("readSourceFragment must not read files out of the tool-server cwd", async () => {
vi.mocked(fsp.realpath).mockClear();
vi.mocked(fsp.readFile).mockClear();
const r = createSourceResolver(8081, "");
const out = await r.readSourceFragment({ file: "not-the-app.js", line: 1, column: 0 });
expect(out).toBeNull();
// Bailed on the empty root itself, rather than on realpath("") happening to
// reject. Delete the guard and these two fire.
expect(fsp.realpath).not.toHaveBeenCalled();
expect(fsp.readFile).not.toHaveBeenCalled();
});
it("readSourceFragment must not read an absolute path under the tool-server cwd", async () => {
vi.mocked(fsp.realpath).mockClear();
vi.mocked(fsp.readFile).mockClear();
const r = createSourceResolver(8081, "");
const out = await r.readSourceFragment({
file: path.join(fakeCwd, "not-the-app.js"),
line: 1,
column: 0,
});
expect(out).toBeNull();
expect(fsp.realpath).not.toHaveBeenCalled();
expect(fsp.readFile).not.toHaveBeenCalled();
});
it("symbolicate must not strip an interior slash from a relative path", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
Response.json({ stack: [{ file: "src/screens/Home.tsx", lineNumber: 12, column: 3 }] })
)
);
const r = createSourceResolver(8081, "");
const loc = await r.symbolicate("http://localhost:8081/index.bundle", 1, 1);
expect(loc?.file).toBe("src/screens/Home.tsx");
vi.unstubAllGlobals();
});
it("symbolicate must not mangle an absolute path", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
Response.json({
stack: [{ file: "/Users/me/app/src/App.tsx", lineNumber: 12, column: 3 }],
})
)
);
const r = createSourceResolver(8081, "");
const loc = await r.symbolicate("http://localhost:8081/index.bundle", 1, 1);
expect(loc?.file).toBe("/Users/me/app/src/App.tsx");
vi.unstubAllGlobals();
});
it("still resolves normally when Metro does report a project root", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "real-app-"));
fs.writeFileSync(path.join(root, "App.js"), "const OK = 'IN_PROJECT';\n");
const r = createSourceResolver(8081, root);
const out = await r.readSourceFragment({ file: "App.js", line: 1, column: 0 });
expect(out).toContain("IN_PROJECT");
fs.rmSync(root, { recursive: true, force: true });
});
});