mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
af8476c0a9
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.
113 lines
4.5 KiB
TypeScript
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 });
|
|
});
|
|
});
|