Files
Ignacy Łątka e2e21c9ce4 fix(debugger): return object/array results from debugger-evaluate (#365)
## Problem
`debugger-evaluate` silently returns nothing for any expression that
evaluates to an object or array. `CDPClient.evaluate` calls
`Runtime.evaluate` without `returnByValue`, so a non-primitive result
comes back as a RemoteObject reference with no `value` field, and the
tool drops it (only primitives/strings came through).

## Fix
Pass `returnByValue` + `awaitPromise` (default on) so objects/arrays
serialize by value and promise-returning expressions resolve to their
value. `evaluateWithBinding` opts both off to keep its fire-and-forget
binding path unchanged (it delivers its payload via a side binding, not
the evaluate return).

## Verification
- New cdp-client unit tests assert `evaluate` sends `returnByValue:true`
+ `awaitPromise:true` and returns an object result, and that
`evaluateWithBinding` sends both `false`.
- Full cdp-client + reconnect suites pass; 1254 tool-server tests pass;
eslint + prettier clean.
- The real-Hermes `returnByValue` path is already exercised in
production by `react-profiler-fiber-tree` / `react-profiler-renders`.
2026-06-25 16:25:24 +02:00

250 lines
7.5 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { WebSocketServer, WebSocket } from "ws";
import { CDPClient } from "../../src/utils/debugger/cdp-client";
let wss: WebSocketServer;
let port: number;
let serverWs: WebSocket | null = null;
beforeEach(async () => {
serverWs = null;
await new Promise<void>((resolve) => {
wss = new WebSocketServer({ port: 0 }, () => {
port = (wss.address() as { port: number }).port;
resolve();
});
});
wss.on("connection", (ws) => {
serverWs = ws;
});
});
afterEach(async () => {
if (serverWs) serverWs.close();
await new Promise<void>((resolve) => wss.close(() => resolve()));
});
function waitForServer(): Promise<WebSocket> {
return new Promise((resolve) => {
if (serverWs) return resolve(serverWs);
wss.once("connection", (ws) => resolve(ws));
});
}
describe("CDPClient", () => {
it("connects and disconnects", async () => {
const client = new CDPClient(`ws://localhost:${port}`);
await client.connect();
expect(client.isConnected()).toBe(true);
await client.disconnect();
expect(client.isConnected()).toBe(false);
});
it("sends a command and receives response", async () => {
const client = new CDPClient(`ws://localhost:${port}`);
await client.connect();
const ws = await waitForServer();
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
ws.send(
JSON.stringify({
id: msg.id,
result: { debuggerId: "test-id" },
})
);
});
const result = await client.send("Runtime.enable");
expect(result).toEqual({ debuggerId: "test-id" });
expect(client.getEnabledDomains().has("Runtime")).toBe(true);
await client.disconnect();
});
it("tracks enabled domains", async () => {
const client = new CDPClient(`ws://localhost:${port}`);
await client.connect();
const ws = await waitForServer();
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
ws.send(JSON.stringify({ id: msg.id, result: {} }));
});
await client.send("Debugger.enable");
expect(client.getEnabledDomains().has("Debugger")).toBe(true);
await client.send("Debugger.disable");
expect(client.getEnabledDomains().has("Debugger")).toBe(false);
await client.disconnect();
});
it("accumulates scriptParsed events", async () => {
const client = new CDPClient(`ws://localhost:${port}`);
await client.connect();
const ws = await waitForServer();
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
ws.send(JSON.stringify({ id: msg.id, result: {} }));
ws.send(
JSON.stringify({
method: "Debugger.scriptParsed",
params: {
scriptId: "42",
url: "http://localhost:8081/index.bundle",
startLine: 0,
endLine: 9999,
},
})
);
});
await client.send("Debugger.enable");
await new Promise((r) => setTimeout(r, 50));
const scripts = client.getLoadedScripts();
expect(scripts.has("42")).toBe(true);
expect(scripts.get("42")!.url).toContain("index.bundle");
await client.disconnect();
});
it("handles CDP errors", async () => {
const client = new CDPClient(`ws://localhost:${port}`);
await client.connect();
const ws = await waitForServer();
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
ws.send(
JSON.stringify({
id: msg.id,
error: { code: -32601, message: "Method not found" },
})
);
});
await expect(client.send("Nonexistent.method")).rejects.toThrow("Method not found");
await client.disconnect();
});
it("emits disconnected on server close", async () => {
const client = new CDPClient(`ws://localhost:${port}`);
await client.connect();
const disconnected = new Promise<void>((resolve) => {
client.events.on("disconnected", () => resolve());
});
const ws = await waitForServer();
ws.close();
await disconnected;
expect(client.isConnected()).toBe(false);
});
it("evaluateWithBinding matches by requestId", async () => {
const client = new CDPClient(`ws://localhost:${port}`);
await client.connect();
const ws = await waitForServer();
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.method === "Runtime.evaluate") {
ws.send(JSON.stringify({ id: msg.id, result: { result: { value: "ok" } } }));
setTimeout(() => {
ws.send(
JSON.stringify({
method: "Runtime.bindingCalled",
params: {
name: "__argent_callback",
payload: JSON.stringify({
requestId: "req-123",
type: "inspect_result",
data: "test",
}),
},
})
);
}, 10);
}
});
const result = await client.evaluateWithBinding("someScript()", "req-123", { timeout: 5000 });
expect(result.requestId).toBe("req-123");
expect(result.type).toBe("inspect_result");
expect(result.data).toBe("test");
await client.disconnect();
});
it("evaluate requests returnByValue + awaitPromise and returns object results", async () => {
const client = new CDPClient(`ws://localhost:${port}`);
await client.connect();
const ws = await waitForServer();
let evalParams: Record<string, unknown> | undefined;
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.method === "Runtime.evaluate") {
evalParams = msg.params;
// A returnByValue response carries the deep-serialized value. Without
// returnByValue, Hermes/V8 return a RemoteObject ref with no `value`,
// which is exactly the dropped-object bug this guards against.
ws.send(
JSON.stringify({
id: msg.id,
result: { result: { type: "object", value: { a: 1, b: [2, 3] } } },
})
);
}
});
const value = await client.evaluate("({ a: 1, b: [2, 3] })");
expect(evalParams?.returnByValue).toBe(true);
expect(evalParams?.awaitPromise).toBe(true);
expect(value).toEqual({ a: 1, b: [2, 3] });
await client.disconnect();
});
it("evaluateWithBinding drives the script with returnByValue + awaitPromise off", async () => {
const client = new CDPClient(`ws://localhost:${port}`);
await client.connect();
const ws = await waitForServer();
let evalParams: Record<string, unknown> | undefined;
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.method === "Runtime.evaluate") {
evalParams = msg.params;
ws.send(JSON.stringify({ id: msg.id, result: { result: { value: "ok" } } }));
setTimeout(() => {
ws.send(
JSON.stringify({
method: "Runtime.bindingCalled",
params: {
name: "__argent_callback",
payload: JSON.stringify({ requestId: "req-1", data: "x" }),
},
})
);
}, 10);
}
});
await client.evaluateWithBinding("someScript()", "req-1", { timeout: 5000 });
// The binding delivers the payload; the script's own return must not be
// serialized or awaited, or fire-and-forget binding scripts would hang.
expect(evalParams?.returnByValue).toBe(false);
expect(evalParams?.awaitPromise).toBe(false);
await client.disconnect();
});
});