aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoramankumarsingh77 <amankumarsingh7702@gmail.com>2026-07-28 01:19:58 +0530
committerAman Kumar <145339995+amankumarsingh77@users.noreply.github.com>2026-08-02 19:12:46 +0530
commitf9dcdf7f6dcdf2979693a9f179eda2b9995d57ae (patch)
tree763ca6f879a693f56572ab256d33769d6d6bb0ad
parentb9c77bb6f9b8b83574489344ef6efd207a611e98 (diff)
refactor: close the remaining unguarded type assertions
Types the event-queue iterator's TReturn as undefined so the terminal result needs no cast; declares the isError field the pi runtime reads by duck-typing; binds socket to a local const in the daemon transport; replaces the @ts-ignore on the optional sharp import with a non-literal specifier so src/ still typechecks where sharp was not installed. Adds src/util/guards.ts as the single home for narrowing over unknown — isRecord plus scalar/array readers — and converts the two private isObject copies in cdp/types.ts and daemon/protocol.ts along with every inline `as Record<string, unknown>` in domains/js.ts and domains/render.ts. Gives Accessibility.getFullAXTree a real result schema, closing the last unvalidated CDP boundary: only nodeId is required, every object carries additionalProperties, and each AX value stays Unknown, so the tree decodes as a whole or fails as a whole rather than being filtered. Guards the five tool-result `details` readers, the daemon attach response, the persisted session state, the browser-namespace flag, and the fs errno narrowing, each feeding an already-present failure path. The daemon-transport socket binding is not a bug fix: send() runs synchronously inside sendWithTimeout's promise executor, so socket cannot be reassigned between the check and the write. It removes the assertion and makes the write immune to a later refactor that adds an await. Two assertions survive by design and are documented in the task report: the never->unknown re-widening in util/tool.ts, which the type-erased tool registry makes unavoidable, and the AsyncFunction constructor lookup in domains/js.ts, which JavaScript exposes no typed binding for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
-rw-r--r--src/cdp/commands.ts29
-rw-r--r--src/cdp/daemon-transport.ts21
-rw-r--r--src/cdp/discovery.ts3
-rw-r--r--src/cdp/event-queue.ts13
-rw-r--r--src/cdp/session.ts4
-rw-r--r--src/cdp/types.ts9
-rw-r--r--src/client.ts3
-rw-r--r--src/daemon/bridge.ts16
-rw-r--r--src/daemon/protocol.ts9
-rw-r--r--src/domains/console.ts47
-rw-r--r--src/domains/js.ts38
-rw-r--r--src/domains/network.ts54
-rw-r--r--src/domains/ref-resolve.ts4
-rw-r--r--src/domains/render.ts14
-rw-r--r--src/domains/screenshot.ts8
-rw-r--r--src/domains/snapshot.ts35
-rw-r--r--src/index.ts3
-rw-r--r--src/state.ts19
-rw-r--r--src/util/guards.ts30
-rw-r--r--src/util/sharp-shim.ts20
-rw-r--r--src/util/tool.ts27
-rw-r--r--test/cdp/commands-test.ts59
-rw-r--r--test/cdp/event-queue-test.ts136
-rw-r--r--test/util/guards-test.ts95
24 files changed, 575 insertions, 121 deletions
diff --git a/src/cdp/commands.ts b/src/cdp/commands.ts
index 95c8be2..e6d744d 100644
--- a/src/cdp/commands.ts
+++ b/src/cdp/commands.ts
@@ -50,6 +50,33 @@ const ExceptionDetails = Type.Object(
{ additionalProperties: true },
);
+const AxValue = Type.Object({ value: Type.Optional(Type.Unknown()) }, { additionalProperties: true });
+
+const AxNode = Type.Object(
+ {
+ nodeId: Type.String(),
+ parentId: Type.Optional(Type.String()),
+ childIds: Type.Optional(Type.Array(Type.String())),
+ ignored: Type.Optional(Type.Boolean()),
+ role: Type.Optional(AxValue),
+ name: Type.Optional(AxValue),
+ value: Type.Optional(AxValue),
+ description: Type.Optional(AxValue),
+ properties: Type.Optional(
+ Type.Array(
+ Type.Object(
+ { name: Type.Optional(Type.String()), value: Type.Optional(AxValue) },
+ { additionalProperties: true },
+ ),
+ ),
+ ),
+ backendDOMNodeId: Type.Optional(Type.Number()),
+ },
+ { additionalProperties: true },
+);
+
+export type AxNodeResult = Static<typeof AxNode>;
+
export const COMMANDS = {
// --- Target ---
"Target.attachToTarget": cmd(
@@ -211,7 +238,7 @@ export const COMMANDS = {
"Emulation.setDeviceMetricsOverride": cmd(Type.Object({}, { additionalProperties: true }), Empty),
"Accessibility.getFullAXTree": cmd(
Type.Object({ depth: Type.Optional(Type.Number()) }, { additionalProperties: true }),
- Type.Object({ nodes: Type.Array(Type.Unknown()) }),
+ Type.Object({ nodes: Type.Array(AxNode) }),
),
};
diff --git a/src/cdp/daemon-transport.ts b/src/cdp/daemon-transport.ts
index 4b2fb78..819e581 100644
--- a/src/cdp/daemon-transport.ts
+++ b/src/cdp/daemon-transport.ts
@@ -67,21 +67,23 @@ export const createDaemonTransport = (clientId: string): CdpTransport => {
resolve(r);
};
+ let sock: Socket;
try {
- socket = createConnection(DAEMON_SOCKET_PATH);
+ sock = createConnection(DAEMON_SOCKET_PATH);
} catch (e) {
settle(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e))));
return;
}
+ socket = sock;
const connectTimer = setTimeout(() => {
cleanup("Connection timeout");
settle(err(cdpError("timeout", `Daemon connection timed out after ${timeoutMs}ms`)));
}, timeoutMs);
- socket.on("connect", () => {
+ sock.on("connect", () => {
clearTimeout(connectTimer);
- rl = createInterface({ input: socket!, crlfDelay: Infinity });
+ rl = createInterface({ input: sock, crlfDelay: Infinity });
rl.on("line", (line: string) => {
const msg = deserialize(line);
@@ -130,11 +132,11 @@ export const createDaemonTransport = (clientId: string): CdpTransport => {
}
});
- socket!.on("error", () => {
+ sock.on("error", () => {
// Errors are surfaced via 'close'
});
- socket!.on("close", () => {
+ sock.on("close", () => {
clearTimeout(connectTimer);
cleanup("Daemon socket closed");
// If connect hasn't settled yet, settle with error
@@ -143,10 +145,10 @@ export const createDaemonTransport = (clientId: string): CdpTransport => {
// Send registration
const regMsg: WireControl = { type: "control", action: "register", clientId };
- socket!.write(serialize(regMsg) + "\n");
+ sock.write(serialize(regMsg) + "\n");
});
- socket.on("error", (e: NodeJS.ErrnoException) => {
+ sock.on("error", (e: NodeJS.ErrnoException) => {
clearTimeout(connectTimer);
const msg = e.code === "ENOENT"
? "Daemon not running — socket not found"
@@ -176,7 +178,8 @@ export const createDaemonTransport = (clientId: string): CdpTransport => {
const timeoutMs = opts?.timeoutMs ?? 15_000;
const sessionId = opts?.sessionId ?? null;
- if (!socket || socket.destroyed || !registered) {
+ const sock = socket;
+ if (sock === null || sock.destroyed || !registered) {
return Promise.resolve(err(cdpError("transport_closed", "Daemon not connected", method)));
}
@@ -189,7 +192,7 @@ export const createDaemonTransport = (clientId: string): CdpTransport => {
...(sessionId !== null && sessionId !== undefined ? { sessionId } : {}),
};
- return sendWithTimeout(pending, id, method, timeoutMs, "Daemon", () => socket!.write(serialize(req) + "\n"));
+ return sendWithTimeout(pending, id, method, timeoutMs, "Daemon", () => sock.write(serialize(req) + "\n"));
};
// ── Events ─────────────────────────────────────────────────────────────
diff --git a/src/cdp/discovery.ts b/src/cdp/discovery.ts
index b4be31c..dc998b5 100644
--- a/src/cdp/discovery.ts
+++ b/src/cdp/discovery.ts
@@ -8,6 +8,7 @@ import { parseJson } from "../schemas/parse";
import { type Result, err, ok } from "../util/result";
import { userDataDirCandidates } from "../profile/paths";
import { type CdpError, cdpError } from "./errors";
+import { errnoCode } from "../util/guards";
const PORT_PROBE_DEADLINE_MS = 30_000;
const PORT_PROBE_INTERVAL_MS = 1_000;
@@ -149,7 +150,7 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
// dir is on our list but the user hasn't installed that browser. EPERM/
// EACCES is common under sandboxes; remember it and fall back to network
// probing rather than failing the whole discovery.
- const code = (e as NodeJS.ErrnoException).code;
+ const code = errnoCode(e);
if (code === "ENOENT" || code === undefined) continue;
if (code === "EPERM" || code === "EACCES") {
readErrors.push(`${portFile}: ${code}`);
diff --git a/src/cdp/event-queue.ts b/src/cdp/event-queue.ts
index 281fb3f..87f5551 100644
--- a/src/cdp/event-queue.ts
+++ b/src/cdp/event-queue.ts
@@ -30,7 +30,7 @@ export type EventQueue = {
export const makeEventQueue = (): EventQueue => {
const buf: CdpEvent[] = [];
- const waiters: Array<(v: IteratorResult<CdpEvent>) => void> = [];
+ const waiters: Array<(v: IteratorResult<CdpEvent, undefined>) => void> = [];
let ended = false;
return {
push(e) {
@@ -41,19 +41,16 @@ export const makeEventQueue = (): EventQueue => {
},
end() {
ended = true;
- // value: undefined as unknown as CdpEvent is the correct iterator-protocol
- // pattern: when done=true the value field is conventionally undefined but
- // the TS type still requires CdpEvent.
- for (const w of waiters.splice(0)) w({ value: undefined as unknown as CdpEvent, done: true });
+ for (const w of waiters.splice(0)) w({ value: undefined, done: true });
},
iter: {
- [Symbol.asyncIterator]() {
+ [Symbol.asyncIterator](): AsyncIterator<CdpEvent, undefined, undefined> {
return {
- next: (): Promise<IteratorResult<CdpEvent>> =>
+ next: (): Promise<IteratorResult<CdpEvent, undefined>> =>
new Promise((resolve) => {
const next = buf.shift();
if (next) resolve({ value: next, done: false });
- else if (ended) resolve({ value: undefined as unknown as CdpEvent, done: true });
+ else if (ended) resolve({ value: undefined, done: true });
else waiters.push(resolve);
}),
};
diff --git a/src/cdp/session.ts b/src/cdp/session.ts
index de1ff4b..bf423e1 100644
--- a/src/cdp/session.ts
+++ b/src/cdp/session.ts
@@ -250,7 +250,8 @@ export const createCdpSession = (
const hw = ownership.harnessWindow();
if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
- if (survivors.length === 0) {
+ const anchorId = survivors[0];
+ if (anchorId === undefined) {
// No owned tab survived — the harness window is gone (user closed it,
// or Chrome restarted and reassigned all ids). The persisted windowId
// is now meaningless and could even collide with one of the user's
@@ -263,7 +264,6 @@ export const createCdpSession = (
// Ownership is thus DERIVED from the real window, never from the bare
// persisted integer (which Chrome may reassign to a user window on
// restart — anchoring on a survivor makes that misfire impossible).
- const anchorId = survivors[0]!;
const anchorWin = await getWindowId(session, anchorId);
if (anchorWin.success) {
const anchorWindowId = anchorWin.data;
diff --git a/src/cdp/types.ts b/src/cdp/types.ts
index d57255f..33ab202 100644
--- a/src/cdp/types.ts
+++ b/src/cdp/types.ts
@@ -1,3 +1,5 @@
+import { isRecord } from "../util/guards";
+
export type CdpEvent = {
readonly method: string;
readonly params: unknown;
@@ -51,11 +53,8 @@ const INTERNAL_PREFIXES: ReadonlyArray<string> = [
export const isInternalUrl = (url: string): boolean =>
INTERNAL_PREFIXES.some((p) => url.startsWith(p));
-const isObject = (v: unknown): v is Readonly<Record<string, unknown>> =>
- typeof v === "object" && v !== null && !Array.isArray(v);
-
export const isCdpRawMessage = (v: unknown): v is CdpRawMessage => {
- if (!isObject(v)) return false;
+ if (!isRecord(v)) return false;
const id = v["id"];
if (id !== undefined && typeof id !== "number") return false;
const method = v["method"];
@@ -64,7 +63,7 @@ export const isCdpRawMessage = (v: unknown): v is CdpRawMessage => {
if (sessionId !== undefined && typeof sessionId !== "string") return false;
const errVal = v["error"];
if (errVal !== undefined) {
- if (!isObject(errVal)) return false;
+ if (!isRecord(errVal)) return false;
if (typeof errVal["message"] !== "string") return false;
const code = errVal["code"];
if (code !== undefined && typeof code !== "number") return false;
diff --git a/src/client.ts b/src/client.ts
index ad35826..4937447 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -280,8 +280,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
const listTabs = async (includeInternal = true): Promise<Result<ReadonlyArray<TabInfo>, CdpError>> => {
const r = await session.callBrowser("Target.getTargets");
if (!r.success) return r;
- const data = r.data as { targetInfos: ReadonlyArray<{ targetId: string; type: string; title: string; url: string }> };
- const tabs = data.targetInfos
+ const tabs = r.data.targetInfos
.filter((t) => t.type === "page")
.filter((t) => includeInternal || !t.url.startsWith("chrome://"))
.map((t): TabInfo => ({
diff --git a/src/daemon/bridge.ts b/src/daemon/bridge.ts
index de6dec7..3f8b604 100644
--- a/src/daemon/bridge.ts
+++ b/src/daemon/bridge.ts
@@ -16,6 +16,7 @@ import { isCdpRawMessage } from "../cdp/types";
import type { CdpRawMessage } from "../cdp/types";
import type { WireRequest, WireResponse, WireEvent } from "./protocol";
import { CDP_CONNECT_TIMEOUT_MS, CDP_COMMAND_TIMEOUT_MS } from "./protocol";
+import { asString, isRecord } from "../util/guards";
// ── Types ──────────────────────────────────────────────────────────────────────
@@ -179,9 +180,9 @@ export const createCdpBridge = (): CdpBridge => {
} else {
// Track session ownership for attach responses
if (cb.isAttach && msg.result) {
- const result = msg.result as { sessionId?: string };
- if (result.sessionId) {
- router.record(cb.clientId, result.sessionId);
+ const sessionId = isRecord(msg.result) ? asString(msg.result["sessionId"]) : undefined;
+ if (sessionId) {
+ router.record(cb.clientId, sessionId);
}
}
cb.send(cb.clientId, { type: "response", id: localId, result: msg.result });
@@ -238,12 +239,15 @@ export const createCdpBridge = (): CdpBridge => {
if (ws && ws.readyState === WebSocket.OPEN) return;
// Resolve URL (discover if not cached, or clear cache on retry)
- let url = wsUrl;
- if (!url || reconnectAttempt > 0) {
+ const cachedUrl = wsUrl;
+ let url: string;
+ if (cachedUrl === null || cachedUrl === "" || reconnectAttempt > 0) {
const d = await discoverWsUrl();
if (!d.success) { scheduleRetry(); return; }
url = d.data;
wsUrl = url;
+ } else {
+ url = cachedUrl;
}
const settledPromise = new Promise<void>((settle) => {
@@ -256,7 +260,7 @@ export const createCdpBridge = (): CdpBridge => {
let sock: WebSocket;
try {
- sock = new WebSocket(url!, { perMessageDeflate: false });
+ sock = new WebSocket(url, { perMessageDeflate: false });
} catch {
scheduleRetry();
return;
diff --git a/src/daemon/protocol.ts b/src/daemon/protocol.ts
index 4fa4a2a..997a6c8 100644
--- a/src/daemon/protocol.ts
+++ b/src/daemon/protocol.ts
@@ -10,6 +10,8 @@
* - readline line-delimited: https://nodejs.org/docs/latest-v22.x/api/readline.html#event-line
*/
+import { isRecord } from "../util/guards";
+
// ── Message types ──────────────────────────────────────────────────────────────
/** A CDP command sent from a pi client to the daemon. */
@@ -60,15 +62,12 @@ export type WireMessage = WireRequest | WireResponse | WireEvent | WireControl;
// ── Type guard ─────────────────────────────────────────────────────────────────
-const isObject = (v: unknown): v is Readonly<Record<string, unknown>> =>
- typeof v === "object" && v !== null && !Array.isArray(v);
-
/**
* Validates that an unknown parsed-JSON value is a well-formed WireMessage.
* Does NOT validate the full CDP semantics — only structural shape.
*/
export const isWireMessage = (v: unknown): v is WireMessage => {
- if (!isObject(v)) return false;
+ if (!isRecord(v)) return false;
const t = v["type"];
if (typeof t !== "string") return false;
@@ -84,7 +83,7 @@ export const isWireMessage = (v: unknown): v is WireMessage => {
if (typeof v["id"] !== "number") return false;
const errVal = v["error"];
if (errVal !== undefined) {
- if (!isObject(errVal)) return false;
+ if (!isRecord(errVal)) return false;
if (typeof errVal["message"] !== "string") return false;
if (typeof errVal["code"] !== "number") return false;
}
diff --git a/src/domains/console.ts b/src/domains/console.ts
index 9696cd9..c38da2a 100644
--- a/src/domains/console.ts
+++ b/src/domains/console.ts
@@ -5,6 +5,7 @@ import { type Result, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
import { applyTruncation } from "../util/truncate";
import type { ConsoleRecord, ConsoleLevel } from "../cdp/console-buffer";
+import { asArrayOf, asBoolean, asNumber, asRecord, asString } from "../util/guards";
const ConsoleArgs = Type.Object({
levels: Type.Optional(
@@ -106,6 +107,50 @@ type ConsoleDetails = {
nextCursor?: number;
};
+const CONSOLE_LEVELS: ReadonlyArray<ConsoleLevel> = ["log", "info", "warn", "error", "debug"];
+
+const asConsoleLevel = (v: unknown): ConsoleLevel | undefined =>
+ CONSOLE_LEVELS.find((l) => l === v);
+
+const asConsoleRecord = (v: unknown): ConsoleRecord | undefined => {
+ const o = asRecord(v);
+ if (o === undefined) return undefined;
+ const level = asConsoleLevel(o["level"]);
+ const text = asString(o["text"]);
+ if (level === undefined || text === undefined) return undefined;
+ const url = asString(o["url"]);
+ const lineNumber = asNumber(o["lineNumber"]);
+ const stackTrace = asString(o["stackTrace"]);
+ return {
+ seq: asNumber(o["seq"]) ?? 0,
+ timestampMs: asNumber(o["timestampMs"]) ?? 0,
+ level,
+ text,
+ source: o["source"] === "log-entry" ? "log-entry" : "console-api",
+ ...(url !== undefined ? { url } : {}),
+ ...(lineNumber !== undefined ? { lineNumber } : {}),
+ ...(stackTrace !== undefined ? { stackTrace } : {}),
+ };
+};
+
+const asConsoleDetails = (v: unknown): ConsoleDetails | undefined => {
+ const o = asRecord(v);
+ if (o === undefined) return undefined;
+ const records = asArrayOf(o["records"], asConsoleRecord);
+ if (records === undefined) return undefined;
+ const fullOutputPath = asString(o["fullOutputPath"]);
+ const nextCursor = asNumber(o["nextCursor"]);
+ return {
+ total: asNumber(o["total"]) ?? records.length,
+ returned: asNumber(o["returned"]) ?? records.length,
+ bufferOverflowed: asBoolean(o["bufferOverflowed"]) ?? false,
+ truncated: asBoolean(o["truncated"]) ?? false,
+ records,
+ ...(fullOutputPath !== undefined ? { fullOutputPath } : {}),
+ ...(nextCursor !== undefined ? { nextCursor } : {}),
+ };
+};
+
export const consoleTool = defineBrowserTool({
name: "browser_console",
label: "Browser Console",
@@ -156,7 +201,7 @@ export const consoleTool = defineBrowserTool({
},
renderResult(result, expanded, theme) {
- const details = result.details as ConsoleDetails | undefined;
+ const details = asConsoleDetails(result.details);
if (!details) return new Text(theme.fg("error", "console: no details"), 0, 0);
const md = renderConsoleMarkdown(details.records, {
diff --git a/src/domains/js.ts b/src/domains/js.ts
index 61dbe53..6d9ace0 100644
--- a/src/domains/js.ts
+++ b/src/domains/js.ts
@@ -8,6 +8,7 @@ import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
import { type Result, err, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
import { applyTruncation } from "../util/truncate";
+import { asNumber, asRecord, asString, isRecord } from "../util/guards";
const COMPACT_PREVIEW_BYTES = 120;
@@ -80,10 +81,14 @@ export const executeJsTool = defineBrowserTool({
},
renderResult(result, expanded, theme) {
- const details = result.details as
- | { valueLength?: number; full?: string; pretty?: string; fullOutputPath?: string }
- | undefined;
- if (!details) return new Text(theme.fg("error", "execute_js: no details"), 0, 0);
+ const raw = asRecord(result.details);
+ if (raw === undefined) return new Text(theme.fg("error", "execute_js: no details"), 0, 0);
+ const details = {
+ valueLength: asNumber(raw["valueLength"]),
+ full: asString(raw["full"]),
+ pretty: asString(raw["pretty"]),
+ fullOutputPath: asString(raw["fullOutputPath"]),
+ };
const len = details.valueLength ?? 0;
const full = details.full ?? "";
@@ -141,9 +146,7 @@ const MAX_SOURCE_BYTES = 1_000_000;
type ContentItem = { readonly type: "text"; readonly text: string };
const isContentItem = (v: unknown): v is ContentItem =>
- typeof v === "object" && v !== null
- && (v as Readonly<Record<string, unknown>>)["type"] === "text"
- && typeof (v as Readonly<Record<string, unknown>>)["text"] === "string";
+ isRecord(v) && v["type"] === "text" && typeof v["text"] === "string";
export const runScriptTool = defineBrowserTool({
name: "browser_run_script",
@@ -211,12 +214,12 @@ export const runScriptTool = defineBrowserTool({
requireFromHere,
ac.signal,
(u: unknown) => {
- if (typeof u !== "object" || u === null) return;
- const content = (u as Readonly<Record<string, unknown>>)["content"];
+ if (!isRecord(u)) return;
+ const content = u["content"];
if (!Array.isArray(content) || content.length === 0) return;
- const first = content[0];
- if (typeof first !== "object" || first === null) return;
- const txt = (first as Readonly<Record<string, unknown>>)["text"];
+ const first: unknown = content[0];
+ if (!isRecord(first)) return;
+ const txt = first["text"];
if (typeof txt !== "string") return;
try { onUpdate({ text: txt }); } catch { /* swallow */ }
},
@@ -230,10 +233,10 @@ export const runScriptTool = defineBrowserTool({
clearTimeout(timeoutTimer);
if (signal !== undefined) signal.removeEventListener("abort", onAbort);
- if (typeof result !== "object" || result === null) {
+ if (!isRecord(result)) {
return err({ kind: "invalid_state", message: `Script must return an object; got ${JSON.stringify(result)}` });
}
- const content = (result as Readonly<Record<string, unknown>>)["content"];
+ const content = result["content"];
if (!Array.isArray(content)) {
return err({ kind: "invalid_state", message: "Script return value must have a content array" });
}
@@ -241,11 +244,8 @@ export const runScriptTool = defineBrowserTool({
return err({ kind: "invalid_state", message: "Script content array must contain { type: 'text', text: string } items" });
}
const textOut = content.map((c) => c.text).join("\n");
- const details = (result as Readonly<Record<string, unknown>>)["details"];
- const isPlainObject = typeof details === "object" && details !== null && !Array.isArray(details);
- return isPlainObject
- ? ok({ text: textOut, details: details as Readonly<Record<string, unknown>> })
- : ok({ text: textOut });
+ const details = result["details"];
+ return isRecord(details) ? ok({ text: textOut, details }) : ok({ text: textOut });
} catch (e) {
clearTimeout(timeoutTimer);
if (signal !== undefined) signal.removeEventListener("abort", onAbort);
diff --git a/src/domains/network.ts b/src/domains/network.ts
index dc23c6d..fa755b3 100644
--- a/src/domains/network.ts
+++ b/src/domains/network.ts
@@ -4,6 +4,7 @@ import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
import { type Result, err, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
import { applyTruncation } from "../util/truncate";
+import { asArrayOf, asBoolean, asNumber, asRecord, asString } from "../util/guards";
import type { NetworkRecord } from "../cdp/network-buffer";
const HttpGetArgs = Type.Object({
@@ -168,6 +169,57 @@ type NetworkDetails = {
includeBodies: boolean;
};
+const asNetworkRecord = (v: unknown): NetworkRecord | undefined => {
+ const o = asRecord(v);
+ if (o === undefined) return undefined;
+ const method = asString(o["method"]);
+ const url = asString(o["url"]);
+ const type = asString(o["type"]);
+ if (method === undefined || url === undefined || type === undefined) return undefined;
+ const status = asNumber(o["status"]);
+ const statusText = asString(o["statusText"]);
+ const mimeType = asString(o["mimeType"]);
+ const durationMs = asNumber(o["durationMs"]);
+ const responseBodySize = asNumber(o["responseBodySize"]);
+ const failed = asBoolean(o["failed"]);
+ const errorText = asString(o["errorText"]);
+ const bodyRaw = o["body"];
+ const body = typeof bodyRaw === "string" || bodyRaw === null ? bodyRaw : undefined;
+ return {
+ requestId: asString(o["requestId"]) ?? "",
+ method,
+ url,
+ type,
+ requestStartedMs: asNumber(o["requestStartedMs"]) ?? 0,
+ requestBodySize: asNumber(o["requestBodySize"]) ?? 0,
+ ...(status !== undefined ? { status } : {}),
+ ...(statusText !== undefined ? { statusText } : {}),
+ ...(mimeType !== undefined ? { mimeType } : {}),
+ ...(durationMs !== undefined ? { durationMs } : {}),
+ ...(responseBodySize !== undefined ? { responseBodySize } : {}),
+ ...(failed !== undefined ? { failed } : {}),
+ ...(errorText !== undefined ? { errorText } : {}),
+ ...(body !== undefined ? { body } : {}),
+ };
+};
+
+const asNetworkDetails = (v: unknown): NetworkDetails | undefined => {
+ const o = asRecord(v);
+ if (o === undefined) return undefined;
+ const requests = asArrayOf(o["requests"], asNetworkRecord);
+ if (requests === undefined) return undefined;
+ const fullOutputPath = asString(o["fullOutputPath"]);
+ return {
+ total: asNumber(o["total"]) ?? requests.length,
+ returned: asNumber(o["returned"]) ?? requests.length,
+ bufferOverflowed: asBoolean(o["bufferOverflowed"]) ?? false,
+ truncated: asBoolean(o["truncated"]) ?? false,
+ requests,
+ includeBodies: asBoolean(o["includeBodies"]) ?? false,
+ ...(fullOutputPath !== undefined ? { fullOutputPath } : {}),
+ };
+};
+
export const networkRequestsTool = defineBrowserTool({
name: "browser_network_requests",
label: "Browser Network Requests",
@@ -235,7 +287,7 @@ export const networkRequestsTool = defineBrowserTool({
},
renderResult(result, expanded, theme) {
- const details = result.details as NetworkDetails | undefined;
+ const details = asNetworkDetails(result.details);
if (!details) return new Text(theme.fg("error", "network: no details"), 0, 0);
const md = renderNetworkMarkdown(details.requests, {
diff --git a/src/domains/ref-resolve.ts b/src/domains/ref-resolve.ts
index de83521..a80c620 100644
--- a/src/domains/ref-resolve.ts
+++ b/src/domains/ref-resolve.ts
@@ -2,7 +2,7 @@ import type { BrowserClient } from "../client";
import { type Result, err, ok } from "../util/result";
import type { ToolErr } from "../util/tool";
import { type Box, boxOf } from "./box";
-import { buildTree, collectInteractiveTargets, type RawAxNode, type SlimNode } from "./snapshot";
+import { buildTree, collectInteractiveTargets, type SlimNode } from "./snapshot";
const staleErr = (ref: string): ToolErr => ({
kind: "invalid_state",
@@ -78,7 +78,7 @@ export const interactiveDiff = async (client: BrowserClient): Promise<string> =>
const axRes = await session.call("Accessibility.getFullAXTree", {});
if (!axRes.success) return "";
- const rawNodes = (axRes.data as { nodes: ReadonlyArray<RawAxNode> }).nodes;
+ const rawNodes = axRes.data.nodes;
const slim = buildTree(rawNodes, { interestingOnly: true, maxNodes: 1000 });
const targets = collectInteractiveTargets(slim);
diff --git a/src/domains/render.ts b/src/domains/render.ts
index f6a6535..1e4e30d 100644
--- a/src/domains/render.ts
+++ b/src/domains/render.ts
@@ -11,6 +11,7 @@ import { Markdown, Text } from "@mariozechner/pi-tui";
import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
import type { Component } from "@mariozechner/pi-tui";
import type { Theme } from "@mariozechner/pi-coding-agent";
+import { isRecord } from "../util/guards";
const COMPACT_PREVIEW_LINES = 6;
@@ -24,16 +25,11 @@ export type ExpandableText = {
readonly fullOutputPath?: string;
};
-const isExpandableText = (value: unknown): value is ExpandableText => {
- if (typeof value !== "object" || value === null) return false;
- const v = value as Record<string, unknown>;
- return typeof v["summary"] === "string" && typeof v["body"] === "string";
-};
+const isExpandableText = (value: unknown): value is ExpandableText =>
+ isRecord(value) && typeof value["summary"] === "string" && typeof value["body"] === "string";
-const extractRender = (details: unknown): unknown => {
- if (typeof details !== "object" || details === null) return undefined;
- return (details as Record<string, unknown>)["render"];
-};
+const extractRender = (details: unknown): unknown =>
+ isRecord(details) ? details["render"] : undefined;
/**
* Build a renderResult over an ExpandableText stored in `details.render`.
diff --git a/src/domains/screenshot.ts b/src/domains/screenshot.ts
index c6cc87b..2e9cfdc 100644
--- a/src/domains/screenshot.ts
+++ b/src/domains/screenshot.ts
@@ -7,6 +7,7 @@ import { type Result, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
import { screenshotPath } from "../util/paths";
import { loadSharp } from "../util/sharp-shim";
+import { asRecord, asString } from "../util/guards";
import { safeJs } from "../util/js-template";
import { cdpCall } from "./cdp-call";
@@ -91,15 +92,16 @@ export const screenshotTool = defineBrowserTool({
},
renderResult(result, _expanded, theme) {
- const details = result.details as { path?: string; format?: string } | undefined;
- const filePath = details?.path;
+ const raw = asRecord(result.details);
+ const filePath = raw === undefined ? undefined : asString(raw["path"]);
if (!filePath) {
return new Text(theme.fg("error", "Screenshot path missing"), 0, 0);
}
+ const format = raw === undefined ? undefined : asString(raw["format"]);
try {
const buf = readFileSync(filePath);
const b64 = buf.toString("base64");
- const mimeType = details?.format === "jpeg" ? "image/jpeg" : "image/png";
+ const mimeType = format === "jpeg" ? "image/jpeg" : "image/png";
const imageTheme: ImageTheme = {
fallbackColor: (str: string) => theme.fg("dim", str),
};
diff --git a/src/domains/snapshot.ts b/src/domains/snapshot.ts
index ec5c894..c015203 100644
--- a/src/domains/snapshot.ts
+++ b/src/domains/snapshot.ts
@@ -3,12 +3,14 @@ import { Type } from "typebox";
import { Container, Image, type ImageTheme, Markdown, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
import type { CdpSession } from "../cdp/session";
+import type { AxNodeResult } from "../cdp/commands";
import { type Box, boxOf } from "./box";
import { cdpCall } from "./cdp-call";
import { type Result, err, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
import { applyTruncation } from "../util/truncate";
import { screenshotPath } from "../util/paths";
+import { asBoolean, asNumber, asRecord, asString } from "../util/guards";
const SnapshotArgs = Type.Object({
includeScreenshot: Type.Optional(
@@ -28,20 +30,8 @@ const SnapshotArgs = Type.Object({
),
});
-// Raw shapes from CDP Accessibility.getFullAXTree. Cast at this boundary only.
-type AxValue = { value?: unknown };
-export type RawAxNode = {
- nodeId: string;
- parentId?: string;
- childIds?: ReadonlyArray<string>;
- ignored?: boolean;
- role?: AxValue;
- name?: AxValue;
- value?: AxValue;
- description?: AxValue;
- properties?: ReadonlyArray<{ name?: string; value?: AxValue }>;
- backendDOMNodeId?: number;
-};
+export type RawAxNode = AxNodeResult;
+type AxValue = NonNullable<RawAxNode["role"]>;
export type SlimNode = {
role: string;
@@ -322,7 +312,7 @@ export const snapshotTool = defineBrowserTool({
// 1. AX tree
const axRes = await cdpCall(client, "Accessibility.getFullAXTree", {});
if (!axRes.success) return axRes;
- const rawNodes = (axRes.data as { nodes: ReadonlyArray<RawAxNode> }).nodes;
+ const rawNodes = axRes.data.nodes;
// 2. Page url/title
const piRes = await client.pageInfo();
@@ -412,10 +402,17 @@ export const snapshotTool = defineBrowserTool({
},
renderResult(result, expanded, theme) {
- const details = result.details as
- | (SnapshotDetails & { summary?: string; ok?: boolean })
- | undefined;
- if (!details) return new Text(theme.fg("error", "snapshot: no details"), 0, 0);
+ const raw = asRecord(result.details);
+ if (raw === undefined) return new Text(theme.fg("error", "snapshot: no details"), 0, 0);
+ const details = {
+ nodeCount: asNumber(raw["nodeCount"]),
+ truncated: asBoolean(raw["truncated"]),
+ fullOutputPath: asString(raw["fullOutputPath"]),
+ screenshotPath: asString(raw["screenshotPath"]),
+ url: asString(raw["url"]),
+ title: asString(raw["title"]),
+ summary: asString(raw["summary"]),
+ };
const titleLine = details.title ? ` "${details.title}"` : "";
const summary = details.summary ?? "";
diff --git a/src/index.ts b/src/index.ts
index e421511..9029940 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -33,9 +33,10 @@ import { type BrowserState, defaultState, persistState, restoreState } from "./s
import { registerAllTools } from "./registry";
import { cleanupTempDirs } from "./util/truncate";
import { createDaemonTransport } from "./cdp/daemon-transport";
+import { asString } from "./util/guards";
export default function browserHarnessExtension(pi: ExtensionAPI): void {
- const flagNs = pi.getFlag("browser-namespace") as string | undefined;
+ const flagNs = asString(pi.getFlag("browser-namespace"));
const namespace = flagNs ?? `pi-${Math.random().toString(36).slice(2, 10)}`;
let state: BrowserState = defaultState(namespace);
diff --git a/src/state.ts b/src/state.ts
index d4b1534..601f7e0 100644
--- a/src/state.ts
+++ b/src/state.ts
@@ -6,6 +6,7 @@
*/
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
+import { asArrayOf, asNumber, asString, isRecord } from "./util/guards";
export type BrowserState = {
readonly namespace: string;
@@ -31,13 +32,29 @@ export const persistState = (pi: ExtensionAPI, state: BrowserState): void => {
* If `currentNamespace` is supplied (e.g. from the --browser-namespace flag),
* it overrides whatever is in the persisted entry.
*/
+const asPersistedState = (v: unknown): Partial<BrowserState> | undefined => {
+ if (!isRecord(v)) return undefined;
+ const namespace = asString(v["namespace"]);
+ const remoteBrowserId = asString(v["remoteBrowserId"]);
+ const ownedTargetIds = asArrayOf(v["ownedTargetIds"], asString);
+ const harnessWindowTargetId = asString(v["harnessWindowTargetId"]);
+ const harnessWindowId = asNumber(v["harnessWindowId"]);
+ return {
+ ...(namespace !== undefined ? { namespace } : {}),
+ ...(remoteBrowserId !== undefined ? { remoteBrowserId } : {}),
+ ...(ownedTargetIds !== undefined ? { ownedTargetIds } : {}),
+ ...(harnessWindowTargetId !== undefined ? { harnessWindowTargetId } : {}),
+ ...(harnessWindowId !== undefined ? { harnessWindowId } : {}),
+ };
+};
+
export const restoreState = (ctx: ExtensionContext, currentNamespace?: string): BrowserState => {
const branchEntries = ctx.sessionManager.getBranch();
const fallback = defaultState(currentNamespace);
for (let i = branchEntries.length - 1; i >= 0; i--) {
const entry = branchEntries[i];
if (entry?.type === "custom" && entry.customType === "browser-harness-state") {
- const data = entry.data as Partial<BrowserState> | undefined;
+ const data = asPersistedState(entry.data);
if (data) {
return {
...fallback,
diff --git a/src/util/guards.ts b/src/util/guards.ts
new file mode 100644
index 0000000..2744c68
--- /dev/null
+++ b/src/util/guards.ts
@@ -0,0 +1,30 @@
+export const isRecord = (v: unknown): v is Readonly<Record<string, unknown>> =>
+ typeof v === "object" && v !== null && !Array.isArray(v);
+
+export const asRecord = (v: unknown): Readonly<Record<string, unknown>> | undefined =>
+ isRecord(v) ? v : undefined;
+
+export const asString = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
+
+export const asNumber = (v: unknown): number | undefined => (typeof v === "number" ? v : undefined);
+
+export const asBoolean = (v: unknown): boolean | undefined => (typeof v === "boolean" ? v : undefined);
+
+export const asArrayOf = <T>(
+ v: unknown,
+ item: (x: unknown) => T | undefined,
+): ReadonlyArray<T> | undefined => {
+ if (!Array.isArray(v)) return undefined;
+ const out: T[] = [];
+ for (const x of v) {
+ const parsed = item(x);
+ if (parsed === undefined) return undefined;
+ out.push(parsed);
+ }
+ return out;
+};
+
+export const errnoCode = (e: unknown): string | undefined => {
+ if (!(e instanceof Error) || !("code" in e)) return undefined;
+ return asString(e.code);
+};
diff --git a/src/util/sharp-shim.ts b/src/util/sharp-shim.ts
index a9f145e..36dd536 100644
--- a/src/util/sharp-shim.ts
+++ b/src/util/sharp-shim.ts
@@ -20,21 +20,21 @@ export type SharpLoad =
| { readonly kind: "missing" }
| { readonly kind: "error"; readonly message: string };
+const SHARP_SPECIFIER: string = "sharp";
+
+const MISSING_MODULE_MARKERS: ReadonlyArray<string> = [
+ "Cannot find module",
+ "MODULE_NOT_FOUND",
+ "ERR_MODULE_NOT_FOUND",
+];
+
export const loadSharp = async (): Promise<SharpLoad> => {
let mod: unknown;
try {
- // sharp is an optional dependency; it may not be installed.
- // @ts-ignore — optional peer dependency not required at compile time
- mod = await import("sharp");
+ mod = await import(SHARP_SPECIFIER);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
- if (
- msg.includes("Cannot find module") ||
- msg.includes("MODULE_NOT_FOUND") ||
- msg.includes("ERR_MODULE_NOT_FOUND")
- ) {
- return { kind: "missing" };
- }
+ if (MISSING_MODULE_MARKERS.some((marker) => msg.includes(marker))) return { kind: "missing" };
return { kind: "error", message: msg };
}
if (mod === null || mod === undefined) return { kind: "missing" };
diff --git a/src/util/tool.ts b/src/util/tool.ts
index 90a2e15..e4bb5ba 100644
--- a/src/util/tool.ts
+++ b/src/util/tool.ts
@@ -1,7 +1,6 @@
import type { TSchema, Static } from "typebox";
import type {
AgentToolResult,
- AgentToolUpdateCallback,
ExtensionAPI,
ExtensionContext,
Theme,
@@ -75,10 +74,12 @@ export const defineBrowserTool = <S extends TSchema>(
type OkDetails = { readonly ok: true } & Readonly<Record<string, unknown>>;
type ErrDetails = { readonly ok: false; readonly kind: ToolErrKind; readonly message: string } & Readonly<Record<string, unknown>>;
+type ErrorFlagged<D> = AgentToolResult<D> & { readonly isError?: boolean };
+
const toToolResult = (
r: Result<ToolOk, ToolErr>,
toolName: string,
-): AgentToolResult<OkDetails | ErrDetails> => {
+): ErrorFlagged<OkDetails | ErrDetails> => {
if (r.success) {
const details: OkDetails = { ok: true, ...(r.data.details ?? {}) };
return {
@@ -93,10 +94,7 @@ const toToolResult = (
...(r.error.details ?? {}),
};
return {
- // isError is an extension the pi-coding-agent runtime picks up from the result object;
- // AgentToolResult<T> itself doesn't declare it but the runtime reads it via duck-typing.
- // We cast through unknown to avoid widening the return type.
- ...(({ isError: true }) as unknown as object),
+ isError: true,
content: [{ type: "text", text: `${toolName} failed (${r.error.kind}): ${r.error.message}` }],
details,
};
@@ -111,6 +109,8 @@ export const registerBrowserTool = (
// by casting def to the generic form. The cast is safe because defineBrowserTool
// ensures the handler, parameters, and renderCall were all created with the same S.
type S = TSchema;
+ const defRenderCall = def.renderCall;
+ const defRenderResult = def.renderResult;
const td: ToolDefinition<S> = {
name: def.name,
label: def.label,
@@ -118,25 +118,22 @@ export const registerBrowserTool = (
promptSnippet: def.promptSnippet,
promptGuidelines: [...def.promptGuidelines],
parameters: def.parameters,
- ...(def.renderCall
+ ...(defRenderCall
? {
renderCall: (args: Static<S>, theme: Theme, _ctx: Parameters<NonNullable<ToolDefinition<S>["renderCall"]>>[2]) =>
- // Non-null assertion safe: this branch only runs when renderCall was provided.
// Cast from never->Component to unknown->Component: safe because at runtime
// args is always the concrete Static<S> value the tool was defined with.
- (def.renderCall as (args: unknown, theme: Theme) => Component)(args, theme),
+ (defRenderCall as (args: unknown, theme: Theme) => Component)(args, theme),
}
: {}),
- ...(def.renderResult
+ ...(defRenderResult
? {
renderResult: (
result: AgentToolResult<unknown>,
options: ToolRenderResultOptions,
theme: Theme,
_ctx: Parameters<NonNullable<ToolDefinition<S>["renderResult"]>>[3],
- ) =>
- // Non-null assertion safe: this branch only runs when renderResult was provided.
- def.renderResult!(result, options.expanded ?? false, theme),
+ ) => defRenderResult(result, options.expanded ?? false, theme),
}
: {}),
async execute(_toolCallId, args, signal, onUpdate, extensionCtx) {
@@ -184,13 +181,11 @@ export const registerBrowserTool = (
signal,
onUpdate: (u) => {
if (onUpdate) {
- // AgentToolUpdateCallback expects AgentToolResult<TDetails>; we use unknown for TDetails.
const update: AgentToolResult<OkDetails> = {
content: [{ type: "text", text: u.text }],
details: { ok: true, ...(u.details ?? {}) },
};
- // Cast needed: onUpdate is AgentToolUpdateCallback<unknown> (generic TDetails).
- (onUpdate as AgentToolUpdateCallback<OkDetails>)(update);
+ onUpdate(update);
}
},
extensionCtx,
diff --git a/test/cdp/commands-test.ts b/test/cdp/commands-test.ts
index b316f68..ad1b057 100644
--- a/test/cdp/commands-test.ts
+++ b/test/cdp/commands-test.ts
@@ -43,3 +43,62 @@ describe("cdp command table", () => {
assert.equal(r.success, false);
});
});
+
+describe("Accessibility.getFullAXTree schema", () => {
+ test("accepts a realistic Chrome AX node with unmodelled extra fields", () => {
+ const r = decodeResult("Accessibility.getFullAXTree", {
+ nodes: [
+ {
+ nodeId: "1",
+ ignored: false,
+ role: { type: "role", value: "RootWebArea" },
+ chromeRole: { type: "internalRole", value: 144 },
+ name: { type: "computedString", value: "Example", sources: [{ type: "relatedElement" }] },
+ properties: [
+ { name: "focusable", value: { type: "booleanOrUndefined", value: true } },
+ { name: "level", value: { type: "integer", value: 2 } },
+ ],
+ childIds: ["2", "3"],
+ backendDOMNodeId: 7,
+ frameId: "F1",
+ },
+ { nodeId: "2", parentId: "1", ignored: true, ignoredReasons: [{ name: "notRendered" }] },
+ ],
+ });
+ assert.equal(r.success, true);
+ if (r.success) {
+ assert.equal(r.data.nodes.length, 2);
+ assert.equal(r.data.nodes[0]?.role?.value, "RootWebArea");
+ assert.equal(r.data.nodes[0]?.backendDOMNodeId, 7);
+ assert.deepEqual(r.data.nodes[0]?.childIds, ["2", "3"]);
+ }
+ });
+
+ test("accepts an empty node list", () => {
+ const r = decodeResult("Accessibility.getFullAXTree", { nodes: [] });
+ assert.equal(r.success, true);
+ });
+
+ test("rejects a node whose nodeId is not a string", () => {
+ const r = decodeResult("Accessibility.getFullAXTree", { nodes: [{ nodeId: 5 }] });
+ assert.equal(r.success, false);
+ if (!r.success) assert.equal(r.error.kind, "invalid_response");
+ });
+
+ test("rejects childIds that are not strings", () => {
+ const r = decodeResult("Accessibility.getFullAXTree", { nodes: [{ nodeId: "1", childIds: [2] }] });
+ assert.equal(r.success, false);
+ });
+
+ test("rejects a missing nodes array", () => {
+ const r = decodeResult("Accessibility.getFullAXTree", {});
+ assert.equal(r.success, false);
+ });
+
+ test("keeps an AxValue's value untyped so any Chrome value type decodes", () => {
+ const r = decodeResult("Accessibility.getFullAXTree", {
+ nodes: [{ nodeId: "1", value: { type: "number", value: 42 } }, { nodeId: "2", name: { type: "x", value: null } }],
+ });
+ assert.equal(r.success, true);
+ });
+});
diff --git a/test/cdp/event-queue-test.ts b/test/cdp/event-queue-test.ts
new file mode 100644
index 0000000..cae8143
--- /dev/null
+++ b/test/cdp/event-queue-test.ts
@@ -0,0 +1,136 @@
+import { test, describe } from "node:test";
+import assert from "node:assert/strict";
+import { makeEventQueue, rejectAllPending, sendWithTimeout, type Pending } from "../../src/cdp/event-queue";
+
+describe("event queue iteration", () => {
+ test("yields pushed events in order", async () => {
+ const q = makeEventQueue();
+ q.push({ method: "Page.loadEventFired", params: {} });
+ q.push({ method: "Page.frameNavigated", params: {} });
+ const seen: string[] = [];
+ for await (const ev of q.iter) {
+ seen.push(ev.method);
+ if (seen.length === 2) break;
+ }
+ assert.deepEqual(seen, ["Page.loadEventFired", "Page.frameNavigated"]);
+ });
+
+ test("ending the queue terminates the iteration after draining the buffer", async () => {
+ const q = makeEventQueue();
+ q.push({ method: "Page.loadEventFired", params: {} });
+ q.end();
+ const seen: string[] = [];
+ for await (const ev of q.iter) seen.push(ev.method);
+ assert.deepEqual(seen, ["Page.loadEventFired"]);
+ });
+
+ test("a waiter pending at end() is released", async () => {
+ const q = makeEventQueue();
+ const done = (async () => {
+ const seen: string[] = [];
+ for await (const ev of q.iter) seen.push(ev.method);
+ return seen;
+ })();
+ await Promise.resolve();
+ q.end();
+ assert.deepEqual(await done, []);
+ });
+
+ test("push after end() is dropped", async () => {
+ const q = makeEventQueue();
+ q.end();
+ q.push({ method: "Page.loadEventFired", params: {} });
+ const seen: string[] = [];
+ for await (const ev of q.iter) seen.push(ev.method);
+ assert.deepEqual(seen, []);
+ });
+
+ test("a waiter is handed the next pushed event directly", async () => {
+ const q = makeEventQueue();
+ const it = q.iter[Symbol.asyncIterator]();
+ const pending = it.next();
+ await Promise.resolve();
+ q.push({ method: "Runtime.consoleAPICalled", params: { a: 1 } });
+ const r = await pending;
+ assert.equal(r.done, false);
+ assert.deepEqual(r.value, { method: "Runtime.consoleAPICalled", params: { a: 1 } });
+ });
+
+ test("the terminal result reports done with an undefined value", async () => {
+ const q = makeEventQueue();
+ q.end();
+ const it = q.iter[Symbol.asyncIterator]();
+ const r = await it.next();
+ assert.equal(r.done, true);
+ assert.equal(r.value, undefined);
+ });
+
+ test("sessionId is preserved on yielded events", async () => {
+ const q = makeEventQueue();
+ q.push({ method: "Page.loadEventFired", params: {}, sessionId: "s1" });
+ q.end();
+ const seen: Array<string | undefined> = [];
+ for await (const ev of q.iter) seen.push(ev.sessionId);
+ assert.deepEqual(seen, ["s1"]);
+ });
+});
+
+describe("pending request bookkeeping", () => {
+ test("rejectAllPending settles every entry with transport_closed and clears the map", () => {
+ const pending = new Map<number, Pending>();
+ const settled: string[] = [];
+ for (const id of [1, 2]) {
+ pending.set(id, {
+ resolve: (r) => settled.push(r.success ? "ok" : r.error.kind),
+ timer: setTimeout(() => {}, 10_000),
+ method: `M.m${id}`,
+ });
+ }
+ rejectAllPending(pending, "gone");
+ assert.deepEqual(settled, ["transport_closed", "transport_closed"]);
+ assert.equal(pending.size, 0);
+ });
+
+ test("sendWithTimeout resolves transport_closed when send throws synchronously", async () => {
+ const pending = new Map<number, Pending>();
+ const r = await sendWithTimeout(pending, 7, "M.m", 10_000, "Test", () => {
+ throw new Error("socket gone");
+ });
+ assert.equal(r.success, false);
+ if (!r.success) {
+ assert.equal(r.error.kind, "transport_closed");
+ assert.equal(r.error.message, "socket gone");
+ assert.equal(r.error.method, "M.m");
+ }
+ assert.equal(pending.size, 0);
+ });
+
+ test("sendWithTimeout registers a pending entry the caller can resolve", async () => {
+ const pending = new Map<number, Pending>();
+ let sent = false;
+ const p = sendWithTimeout(pending, 9, "M.m", 10_000, "Test", () => {
+ sent = true;
+ });
+ assert.equal(sent, true);
+ const entry = pending.get(9);
+ assert.notEqual(entry, undefined);
+ if (entry) {
+ clearTimeout(entry.timer);
+ entry.resolve({ success: true, data: { hello: "world" } });
+ }
+ const r = await p;
+ assert.equal(r.success, true);
+ if (r.success) assert.deepEqual(r.data, { hello: "world" });
+ });
+
+ test("sendWithTimeout resolves a timeout error and drops the pending entry", async () => {
+ const pending = new Map<number, Pending>();
+ const r = await sendWithTimeout(pending, 11, "M.slow", 1, "Test", () => {});
+ assert.equal(r.success, false);
+ if (!r.success) {
+ assert.equal(r.error.kind, "timeout");
+ assert.match(r.error.message, /Test timeout after 1ms: M\.slow/);
+ }
+ assert.equal(pending.size, 0);
+ });
+});
diff --git a/test/util/guards-test.ts b/test/util/guards-test.ts
new file mode 100644
index 0000000..7c7b426
--- /dev/null
+++ b/test/util/guards-test.ts
@@ -0,0 +1,95 @@
+import { test, describe } from "node:test";
+import assert from "node:assert/strict";
+import { asArrayOf, asBoolean, asNumber, asRecord, asString, errnoCode, isRecord } from "../../src/util/guards";
+
+describe("isRecord", () => {
+ test("accepts plain objects", () => {
+ assert.equal(isRecord({}), true);
+ assert.equal(isRecord({ a: 1 }), true);
+ });
+
+ test("rejects null, arrays and primitives", () => {
+ assert.equal(isRecord(null), false);
+ assert.equal(isRecord(undefined), false);
+ assert.equal(isRecord([]), false);
+ assert.equal(isRecord([{ a: 1 }]), false);
+ assert.equal(isRecord("s"), false);
+ assert.equal(isRecord(0), false);
+ assert.equal(isRecord(false), false);
+ });
+});
+
+describe("scalar readers", () => {
+ test("asString accepts only strings", () => {
+ assert.equal(asString(""), "");
+ assert.equal(asString("x"), "x");
+ assert.equal(asString(1), undefined);
+ assert.equal(asString(null), undefined);
+ assert.equal(asString(undefined), undefined);
+ assert.equal(asString({}), undefined);
+ });
+
+ test("asNumber accepts only numbers, including 0 and NaN", () => {
+ assert.equal(asNumber(0), 0);
+ assert.equal(asNumber(-1.5), -1.5);
+ assert.equal(Number.isNaN(asNumber(Number.NaN)), true);
+ assert.equal(asNumber("1"), undefined);
+ assert.equal(asNumber(null), undefined);
+ });
+
+ test("asBoolean accepts only booleans", () => {
+ assert.equal(asBoolean(false), false);
+ assert.equal(asBoolean(true), true);
+ assert.equal(asBoolean(0), undefined);
+ assert.equal(asBoolean("true"), undefined);
+ });
+
+ test("asRecord mirrors isRecord but yields the value", () => {
+ assert.deepEqual(asRecord({ a: 1 }), { a: 1 });
+ assert.equal(asRecord([]), undefined);
+ assert.equal(asRecord(null), undefined);
+ });
+});
+
+describe("asArrayOf", () => {
+ test("parses every element", () => {
+ assert.deepEqual(asArrayOf(["a", "b"], asString), ["a", "b"]);
+ assert.deepEqual(asArrayOf([], asString), []);
+ });
+
+ test("rejects the whole array when any element fails, rather than dropping it", () => {
+ assert.equal(asArrayOf(["a", 2], asString), undefined);
+ assert.equal(asArrayOf([1, "b"], asString), undefined);
+ });
+
+ test("rejects non-arrays", () => {
+ assert.equal(asArrayOf({ 0: "a" }, asString), undefined);
+ assert.equal(asArrayOf(undefined, asString), undefined);
+ assert.equal(asArrayOf("ab", asString), undefined);
+ });
+
+ test("treats an element parsed as undefined as a failure", () => {
+ assert.equal(asArrayOf([undefined], asString), undefined);
+ });
+});
+
+describe("errnoCode", () => {
+ test("reads .code off a real Error", () => {
+ const e = Object.assign(new Error("nope"), { code: "ENOENT" });
+ assert.equal(errnoCode(e), "ENOENT");
+ });
+
+ test("returns undefined for an Error without a code", () => {
+ assert.equal(errnoCode(new Error("plain")), undefined);
+ });
+
+ test("returns undefined for a non-string code", () => {
+ assert.equal(errnoCode(Object.assign(new Error("x"), { code: 13 })), undefined);
+ });
+
+ test("returns undefined for non-Errors that merely carry a code", () => {
+ assert.equal(errnoCode({ code: "ENOENT" }), undefined);
+ assert.equal(errnoCode("ENOENT"), undefined);
+ assert.equal(errnoCode(null), undefined);
+ });
+});