cdp-toolkit 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,660 @@
1
+ /**
2
+ * CDP implementation of the browser-neutral Driver contract in ../driver.ts (ADR-001). A thin
3
+ * wrapper: every command goes over client.ts's CdpConnection, so the per-command timeout that
4
+ * prevents a wedged-tab hang applies exactly as in tools/. lifetime is "per-call": page() opens
5
+ * one connection via openPage and holds it for the PageDriver's life; release() closes it. Uid
6
+ * codec (scheme "cdp"): `cdp:<backendDOMNodeId>`, or a bare legacy numeric uid, per THE UID CODEC block in ../driver.ts.
7
+ */
8
+ import { type CdpConnection, CdpError, listTargets, openBrowser, openPage, resolveTarget } from "../client.ts";
9
+ import type { Target, TargetSelector } from "../types.ts";
10
+ import { resolveUid } from "../tools/snapshot.ts";
11
+ import { buildFulfillParams, effectiveAction, selectRule } from "../tools/network_mock.ts";
12
+ import {
13
+ LEGACY_NUMERIC_UID, type BrowserDriver, type Capability, type HandledDialogInfo, type DriverError, type DriverErrorCode, type DriverEvent,
14
+ type DriverUid, type ElementLocator, type EmulationOptions, type InterceptRule, type KeyPress, type LifetimeModel, type MouseButtonOptions,
15
+ type NavigateOptions, type NavigateResult, type PageDriver, type PageInfo, type ScreenshotOptions, type SnapshotNode, type UidStability,
16
+ } from "../driver.ts";
17
+ /* ------------------------------ error + uid codec ------------------------------ */
18
+ function driverError(code: DriverErrorCode, message: string, data?: unknown): DriverError {
19
+ return Object.assign(new Error(message), { code, ...(data !== undefined ? { data } : {}) }) as DriverError;
20
+ }
21
+ function encodeUid(backendNodeId: number): DriverUid {
22
+ return `cdp:${backendNodeId}`;
23
+ }
24
+ /** Accepts `cdp:<digits>` and, per the LEGACY rule, a bare numeric uid. */
25
+ function decodeUid(uid: DriverUid): number {
26
+ if (LEGACY_NUMERIC_UID.test(uid)) return Number(uid);
27
+ if (!uid.startsWith("cdp:")) throw driverError("foreign-uid", `uid is not owned by the cdp driver: ${uid}`);
28
+ const n = Number(uid.slice(4));
29
+ if (!Number.isSafeInteger(n) || n <= 0) throw driverError("foreign-uid", `malformed cdp uid payload: ${uid}`);
30
+ return n;
31
+ }
32
+ /* ---------------------- copied helpers, see attributions below ---------------------- */
33
+ // Copied verbatim from src/tools/input.ts `centerOf`: scroll into view, read viewport-space center.
34
+ async function centerOf(conn: CdpConnection, objectId: string): Promise<{ x: number; y: number }> {
35
+ const fn = "function(){this.scrollIntoView({block:'center',inline:'center'});const r=this.getBoundingClientRect();if(r.width===0&&r.height===0)return null;return {x:r.left+r.width/2,y:r.top+r.height/2};}";
36
+ const { result, exceptionDetails } = await conn.send<{ result: { value?: { x: number; y: number } | null }; exceptionDetails?: { text?: string } }>(
37
+ "Runtime.callFunctionOn", { objectId, functionDeclaration: fn, returnByValue: true },
38
+ );
39
+ if (exceptionDetails) throw driverError("page-error", `could not measure element: ${exceptionDetails.text ?? "error"}`);
40
+ if (!result.value) throw driverError("page-error", "element has zero size / is not visible; cannot compute a click point");
41
+ return result.value;
42
+ }
43
+ async function focusElement(conn: CdpConnection, objectId: string): Promise<void> {
44
+ await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: "function(){this.focus&&this.focus();}", returnByValue: true });
45
+ }
46
+ // Copied verbatim (behaviorally) from src/tools/input.ts `setValue`. CRITICAL: must stay
47
+ // byte-identical to input.ts; see the side-by-side quote in the driver report.
48
+ async function setValueOnObject(conn: CdpConnection, objectId: string, value: string): Promise<void> {
49
+ await focusElement(conn, objectId);
50
+ const fn = "function(){if('value'in this){this.value='';this.dispatchEvent(new Event('input',{bubbles:true}));}else if(this.isContentEditable){this.textContent='';}}";
51
+ await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: fn, returnByValue: true });
52
+ if (value.length) await conn.send("Input.insertText", { text: value });
53
+ }
54
+ interface KeySpec { key: string; code: string; keyCode: number; text?: string }
55
+ // Copied verbatim from src/tools/input.ts `NAMED_KEYS`.
56
+ const NAMED_KEYS: Record<string, KeySpec> = {
57
+ enter: { key: "Enter", code: "Enter", keyCode: 13, text: "\r" }, return: { key: "Enter", code: "Enter", keyCode: 13, text: "\r" },
58
+ tab: { key: "Tab", code: "Tab", keyCode: 9 }, escape: { key: "Escape", code: "Escape", keyCode: 27 }, esc: { key: "Escape", code: "Escape", keyCode: 27 },
59
+ backspace: { key: "Backspace", code: "Backspace", keyCode: 8 }, delete: { key: "Delete", code: "Delete", keyCode: 46 },
60
+ space: { key: " ", code: "Space", keyCode: 32, text: " " },
61
+ arrowup: { key: "ArrowUp", code: "ArrowUp", keyCode: 38 }, arrowdown: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 },
62
+ arrowleft: { key: "ArrowLeft", code: "ArrowLeft", keyCode: 37 }, arrowright: { key: "ArrowRight", code: "ArrowRight", keyCode: 39 },
63
+ up: { key: "ArrowUp", code: "ArrowUp", keyCode: 38 }, down: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 },
64
+ left: { key: "ArrowLeft", code: "ArrowLeft", keyCode: 37 }, right: { key: "ArrowRight", code: "ArrowRight", keyCode: 39 },
65
+ home: { key: "Home", code: "Home", keyCode: 36 }, end: { key: "End", code: "End", keyCode: 35 },
66
+ pageup: { key: "PageUp", code: "PageUp", keyCode: 33 }, pagedown: { key: "PageDown", code: "PageDown", keyCode: 34 },
67
+ };
68
+ const MODIFIER_BITS: Record<string, number> = { alt: 1, control: 2, ctrl: 2, meta: 4, cmd: 4, command: 4, shift: 8 };
69
+ // Copied verbatim from src/tools/input.ts `resolveKey`.
70
+ function resolveKey(key: string): KeySpec {
71
+ const named = NAMED_KEYS[key.toLowerCase()];
72
+ if (named) return named;
73
+ if (key.length === 1) {
74
+ const upper = key.toUpperCase();
75
+ const isLetter = upper >= "A" && upper <= "Z";
76
+ const isDigit = key >= "0" && key <= "9";
77
+ const code = isLetter ? `Key${upper}` : isDigit ? `Digit${key}` : `Key${upper}`;
78
+ return { key, code, keyCode: upper.charCodeAt(0), text: key };
79
+ }
80
+ throw driverError("page-error", `unknown key '${key}' (use a named key like Enter/Tab/ArrowDown or a single character)`);
81
+ }
82
+ interface RemoteObject { type: string; value?: unknown; unserializableValue?: string; description?: string }
83
+ interface ExceptionDetails { text?: string; exception?: RemoteObject }
84
+ function unwrap(obj: RemoteObject): unknown {
85
+ if (Object.prototype.hasOwnProperty.call(obj, "value")) return obj.value;
86
+ if (obj.unserializableValue !== undefined) return obj.unserializableValue;
87
+ return obj.type === "undefined" ? undefined : (obj.description ?? null);
88
+ }
89
+ function exceptionMessage(details: ExceptionDetails): string {
90
+ const ex = details.exception;
91
+ const fromObject = ex?.description ?? (typeof ex?.value === "string" ? ex.value : ex?.value !== undefined ? JSON.stringify(ex.value) : undefined);
92
+ return fromObject ?? details.text ?? "evaluation threw";
93
+ }
94
+ /* -------------------------- AX-tree walk (snapshot) -------------------------- */
95
+ interface AxValue { type?: string; value?: unknown }
96
+ interface AxProperty { name: string; value?: AxValue }
97
+ interface AxNode {
98
+ nodeId: string; ignored?: boolean; role?: AxValue; name?: AxValue; value?: AxValue;
99
+ properties?: AxProperty[]; childIds?: string[]; backendDOMNodeId?: number; parentId?: string;
100
+ }
101
+ const INTERACTIVE_ROLES = new Set<string>([
102
+ "button", "link", "textbox", "searchbox", "checkbox", "radio", "combobox",
103
+ "listbox", "option", "menuitem", "menuitemcheckbox", "menuitemradio",
104
+ "tab", "switch", "slider", "spinbutton", "textfield",
105
+ ]);
106
+ function axString(v: AxValue | undefined): string | undefined {
107
+ if (!v || v.value === undefined || v.value === null) return undefined;
108
+ const s = String(v.value).trim();
109
+ return s.length ? s : undefined;
110
+ }
111
+ function propValue(node: AxNode, name: string): string | undefined {
112
+ return axString(node.properties?.find((p) => p.name === name)?.value);
113
+ }
114
+ function isInteractive(role: string | undefined): boolean {
115
+ return !!role && (INTERACTIVE_ROLES.has(role) || INTERACTIVE_ROLES.has(role.toLowerCase()));
116
+ }
117
+ /**
118
+ * Copied verbatim (semantics, not just shape) from src/tools/snapshot.ts's `formatNode` extras
119
+ * block, key order and all: value(quoted)/checked(unquoted, excluded only when literally
120
+ * "false")/expanded(unquoted, included whenever the property is present AT ALL, INCLUDING the
121
+ * literal string "false" - a legacy quirk, not a bug this migration is allowed to fix)/
122
+ * disabled(bare token)/url(unquoted)/focused(bare token). The render step (shared-tools.ts)
123
+ * reproduces the exact quoting per key; this function only decides which keys are present and
124
+ * with what value, in this order (a Record preserves string-key insertion order).
125
+ */
126
+ function axExtras(node: AxNode): Record<string, string> {
127
+ const extras: Record<string, string> = {};
128
+ const value = axString(node.value);
129
+ if (value) extras.value = value;
130
+ const checked = propValue(node, "checked");
131
+ if (checked && checked !== "false") extras.checked = checked;
132
+ const expanded = propValue(node, "expanded");
133
+ if (expanded) extras.expanded = expanded;
134
+ if (propValue(node, "disabled") === "true") extras.disabled = "true";
135
+ const url = propValue(node, "url");
136
+ if (url) extras.url = url;
137
+ if (propValue(node, "focused") === "true") extras.focused = "true";
138
+ return extras;
139
+ }
140
+ /* ------------------------------- locator helpers ------------------------------- */
141
+ // css branch copied from src/tools/input.ts `resolveElement`; uid/text/xpath branches are new.
142
+ async function resolveElementLocator(conn: CdpConnection, loc: ElementLocator): Promise<{ objectId: string }> {
143
+ if ("uid" in loc) {
144
+ return resolveUid(conn, decodeUid(loc.uid)).catch(() => {
145
+ throw driverError("stale-uid", `uid does not resolve: ${loc.uid}`);
146
+ });
147
+ }
148
+ if ("css" in loc) {
149
+ const { result, exceptionDetails } = await conn.send<{ result: { objectId?: string; subtype?: string }; exceptionDetails?: { text?: string } }>(
150
+ "Runtime.evaluate", { expression: `document.querySelector(${JSON.stringify(loc.css)})`, returnByValue: false },
151
+ );
152
+ if (exceptionDetails) throw driverError("page-error", `css '${loc.css}' evaluation failed: ${exceptionDetails.text ?? "error"}`);
153
+ if (!result.objectId || result.subtype === "null") throw driverError("no-such-element", `css '${loc.css}' matched no element`);
154
+ return { objectId: result.objectId };
155
+ }
156
+ const backendNodeId = await searchLocate(conn, "xpath" in loc ? loc.xpath : loc.text);
157
+ return resolveUid(conn, backendNodeId).catch(() => {
158
+ throw driverError("stale-uid", "text/xpath match no longer resolves");
159
+ });
160
+ }
161
+
162
+ /**
163
+ * locate.text / locate.xpath: DOM.performSearch accepts plain text, a CSS selector, or an
164
+ * XPath expression and disambiguates them itself. Not copied from any tools/ module (none
165
+ * implement this); built only on the client primitives per CONTRACT.md rule 2.
166
+ */
167
+ async function searchLocate(conn: CdpConnection, query: string): Promise<number> {
168
+ await conn.send("DOM.getDocument", { depth: 0 }).catch(() => undefined);
169
+ const { searchId, resultCount } = await conn.send<{ searchId: string; resultCount: number }>("DOM.performSearch", { query });
170
+ try {
171
+ if (!resultCount) throw driverError("no-such-element", `locator matched nothing: ${query}`);
172
+ const { nodeIds } = await conn.send<{ nodeIds: number[] }>("DOM.getSearchResults", { searchId, fromIndex: 0, toIndex: 1 });
173
+ const nodeId = nodeIds[0];
174
+ if (nodeId === undefined) throw driverError("no-such-element", `locator matched nothing: ${query}`);
175
+ const { node } = await conn.send<{ node: { backendNodeId: number } }>("DOM.describeNode", { nodeId });
176
+ return node.backendNodeId;
177
+ } finally {
178
+ await conn.send("DOM.discardSearchResults", { searchId }).catch(() => undefined);
179
+ }
180
+ }
181
+
182
+ async function backendNodeIdOf(conn: CdpConnection, loc: ElementLocator): Promise<number> {
183
+ if ("uid" in loc) return decodeUid(loc.uid);
184
+ if ("css" in loc) {
185
+ const { objectId } = await resolveElementLocator(conn, loc);
186
+ const { node } = await conn.send<{ node: { backendNodeId: number } }>("DOM.describeNode", { objectId });
187
+ return node.backendNodeId;
188
+ }
189
+ return searchLocate(conn, "xpath" in loc ? loc.xpath : loc.text);
190
+ }
191
+ async function fullPageClip(conn: CdpConnection): Promise<{ x: number; y: number; width: number; height: number }> {
192
+ const m = await conn.send<{ cssContentSize?: { width: number; height: number }; contentSize?: { width: number; height: number } }>("Page.getLayoutMetrics");
193
+ const size = m.cssContentSize ?? m.contentSize;
194
+ if (!size) throw driverError("page-error", "Page.getLayoutMetrics returned no content size");
195
+ return { x: 0, y: 0, width: Math.ceil(size.width), height: Math.ceil(size.height) };
196
+ }
197
+ async function elementClip(conn: CdpConnection, loc: ElementLocator): Promise<{ x: number; y: number; width: number; height: number }> {
198
+ const backendNodeId = await backendNodeIdOf(conn, loc);
199
+ await conn.send("DOM.scrollIntoViewIfNeeded", { backendNodeId }).catch(() => undefined);
200
+ const box = await conn.send<{ model: { content: number[] } }>("DOM.getBoxModel", { backendNodeId });
201
+ const q = box.model.content;
202
+ const xs = [q[0], q[2], q[4], q[6]].filter((n): n is number => n != null);
203
+ const ys = [q[1], q[3], q[5], q[7]].filter((n): n is number => n != null);
204
+ if (!xs.length || !ys.length) throw driverError("page-error", "DOM.getBoxModel returned an empty content quad");
205
+ const minX = Math.min(...xs), minY = Math.min(...ys);
206
+ const width = Math.ceil(Math.max(...xs) - minX), height = Math.ceil(Math.max(...ys) - minY);
207
+ if (width <= 0 || height <= 0) throw driverError("page-error", "resolved element has zero area");
208
+ return { x: minX, y: minY, width, height };
209
+ }
210
+ /**
211
+ * Chrome capabilities. network.responseBodyPostHoc is deliberately NOT declared: the Network
212
+ * domain is enabled lazily (only by startBodyCapture/intercept), so a body is only readable for
213
+ * a request that occurred after something armed capture first. That is arming by another name,
214
+ * not "not armed in advance", so an honest capability set omits it rather than reading true by
215
+ * construction. locate.text/locate.xpath both ride DOM.performSearch (see searchLocate above),
216
+ * which is a real Chrome capability, not a line-budget artifact, so both ARE declared.
217
+ */
218
+ const CDP_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
219
+ "trace.performance", "heap.snapshot", "audit.lighthouse", "emulate.cpuThrottling",
220
+ "emulate.mediaFeatures", "emulate.deviceMetrics", "emulate.networkConditions",
221
+ "screenshot.fullPage", "screenshot.element", "network.intercept",
222
+ "snapshot.accessibilityTree", "input.insertTextAtomic", "locate.text", "locate.xpath",
223
+ ]);
224
+ /* ---------------------------------- PageDriver ---------------------------------- */
225
+ class CdpPageDriver implements PageDriver {
226
+ readonly info: PageInfo;
227
+ // Deliberately empty: page() does NOT eagerly enable Page/Runtime. Every domain is turned on
228
+ // lazily, by the same method and with the same catch/no-catch nuance as the legacy tools/*.ts
229
+ // module it replaces, so no tool call gets an extra CDP round-trip (or extra domain-enabled
230
+ // side effect) it did not have before this migration. See the per-method comments below.
231
+ private readonly enabledDomains = new Set<string>();
232
+ private released = false;
233
+ constructor(private readonly conn: CdpConnection, target: Target, readonly browser: BrowserDriver) {
234
+ this.info = { id: target.id, url: target.url, title: target.title, type: target.type };
235
+ }
236
+ private async ensureDomain(name: string): Promise<void> {
237
+ if (this.enabledDomains.has(name)) return;
238
+ this.enabledDomains.add(name);
239
+ await this.conn.send(`${name}.enable`);
240
+ }
241
+ /** Same as ensureDomain but tolerates the enable call itself failing (matches a legacy
242
+ * `.catch(() => undefined)` site); a failed enable is NOT re-attempted on the next call. */
243
+ private async ensureDomainSoft(name: string): Promise<void> {
244
+ if (this.enabledDomains.has(name)) return;
245
+ this.enabledDomains.add(name);
246
+ await this.conn.send(`${name}.enable`).catch(() => undefined);
247
+ }
248
+ async navigate(opts: NavigateOptions): Promise<NavigateResult> {
249
+ await this.ensureDomain("Page");
250
+ const reload = opts.reload === true;
251
+ if (!reload && !opts.url) throw driverError("page-error", "navigate: 'url' is required unless reload:true");
252
+ const waitUntil = opts.waitUntil ?? "load";
253
+ const milestoneMethod = waitUntil === "load" ? "Page.loadEventFired" : "Page.domContentEventFired";
254
+ const milestone = this.conn.waitFor(milestoneMethod, undefined, opts.timeoutMs).then(() => waitUntil as NavigateResult["waitedFor"]).catch(() => undefined);
255
+ const stopped = this.conn.waitFor("Page.frameStoppedLoading", undefined, opts.timeoutMs).then(() => "committed" as NavigateResult["waitedFor"]).catch(() => undefined);
256
+ let url: string, contextId: string, reloaded: boolean | undefined;
257
+ if (reload) {
258
+ await this.conn.send("Page.reload", { ignoreCache: opts.ignoreCache === true }, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {});
259
+ const tree = await this.conn.send<{ frameTree?: { frame?: { id?: string; url?: string } } }>("Page.getFrameTree").catch(() => undefined);
260
+ contextId = tree?.frameTree?.frame?.id ?? "";
261
+ url = tree?.frameTree?.frame?.url ?? opts.url ?? "";
262
+ reloaded = true;
263
+ } else {
264
+ const nav = await this.conn.send<{ frameId: string; errorText?: string }>("Page.navigate", { url: opts.url }, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {});
265
+ if (nav.errorText) throw driverError("page-error", `navigate: ${nav.errorText} (${opts.url})`);
266
+ contextId = nav.frameId;
267
+ url = opts.url as string;
268
+ }
269
+ const settled = await Promise.race([milestone, stopped]);
270
+ // "timeout" here means the navigation already committed (Page.navigate/reload resolved) but
271
+ // no load/domcontentloaded milestone arrived before opts.timeoutMs; it is NOT the navigate
272
+ // call itself timing out. NavigateResult.waitedFor's vocabulary is BiDi-shaped (BiDi's
273
+ // navigate takes an explicit readiness state) rather than CDP-native, so this is the
274
+ // closest honest mapping onto CDP's milestone events.
275
+ return { url, contextId, ...(reloaded ? { reloaded } : {}), waitedFor: settled ?? "timeout" };
276
+ }
277
+ async waitForText(text: string, timeoutMs = 15_000, pollMs = 250): Promise<{ found: true; elapsedMs: number }> {
278
+ await this.ensureDomain("Runtime");
279
+ const start = Date.now();
280
+ const expr = `(() => { const b = document.body; return !!b && typeof b.innerText === 'string' && b.innerText.includes(${JSON.stringify(text)}); })()`;
281
+ for (;;) {
282
+ const { result } = await this.conn.send<{ result: { value?: unknown } }>("Runtime.evaluate", { expression: expr, returnByValue: true });
283
+ if (result?.value === true) return { found: true, elapsedMs: Date.now() - start };
284
+ if (Date.now() - start >= timeoutMs) throw driverError("timeout", `waitForText: '${text}' not found within ${timeoutMs}ms`);
285
+ await new Promise((r) => setTimeout(r, Math.min(pollMs, Math.max(0, timeoutMs - (Date.now() - start)))));
286
+ }
287
+ }
288
+ private async callFn(objectId: string, functionDeclaration: string, args: readonly unknown[], awaitPromise: boolean): Promise<unknown> {
289
+ const res = await this.conn.send<{ result: RemoteObject; exceptionDetails?: ExceptionDetails }>(
290
+ "Runtime.callFunctionOn", { objectId, functionDeclaration, arguments: args.map((value) => ({ value })), returnByValue: true, awaitPromise },
291
+ );
292
+ if (res.exceptionDetails) throw driverError("page-error", exceptionMessage(res.exceptionDetails));
293
+ return unwrap(res.result);
294
+ }
295
+ async evaluate(expression: string, opts?: { args?: readonly unknown[]; awaitPromise?: boolean }): Promise<unknown> {
296
+ // Uncaught, matching tools/evaluate.ts's evaluateScript. resize_page's legacy Runtime.enable is
297
+ // soft-caught instead; since Runtime.enable does not fail against a live connected page, the two
298
+ // call sites converging on one hard enable here is not expected to be observable in practice.
299
+ await this.ensureDomain("Runtime");
300
+ const awaitPromise = opts?.awaitPromise ?? true;
301
+ if (opts?.args && opts.args.length > 0) {
302
+ const globalObj = await this.conn.send<{ result: { objectId?: string } }>("Runtime.evaluate", { expression: "globalThis", returnByValue: false });
303
+ const objectId = globalObj.result.objectId;
304
+ if (!objectId) throw driverError("page-error", "could not resolve global object for function call");
305
+ return this.callFn(objectId, expression, opts.args, awaitPromise);
306
+ }
307
+ const res = await this.conn.send<{ result: RemoteObject; exceptionDetails?: ExceptionDetails }>("Runtime.evaluate", { expression, returnByValue: true, awaitPromise });
308
+ if (res.exceptionDetails) throw driverError("page-error", exceptionMessage(res.exceptionDetails));
309
+ return unwrap(res.result);
310
+ }
311
+ async callOnElement(loc: ElementLocator, functionDeclaration: string, args: readonly unknown[] = []): Promise<unknown> {
312
+ const { objectId } = await resolveElementLocator(this.conn, loc);
313
+ return this.callFn(objectId, functionDeclaration, args, true);
314
+ }
315
+ async snapshot(opts?: { interactiveOnly?: boolean }): Promise<SnapshotNode[]> {
316
+ const interactiveOnly = opts?.interactiveOnly ?? false;
317
+ await this.ensureDomain("Accessibility");
318
+ const { nodes } = await this.conn.send<{ nodes: AxNode[] }>("Accessibility.getFullAXTree");
319
+ const byId = new Map<string, AxNode>();
320
+ for (const n of nodes) byId.set(n.nodeId, n);
321
+ const roots = nodes.filter((n) => !n.parentId || !byId.has(n.parentId));
322
+ const out: SnapshotNode[] = [];
323
+ // Depth propagation copied verbatim from src/tools/snapshot.ts's `walk`: a node that was NOT
324
+ // emitted (ignored, or a structurally-empty full-mode container) does not consume an indent
325
+ // level, so its children render as if it were never there. In interactiveOnly mode nothing
326
+ // increments, matching the legacy flat (unindented) rendering. This must stay exact: it is a
327
+ // real difference from "always depth+1", and getting it wrong changes indentation on any real
328
+ // page with generic wrapper elements.
329
+ const walk = (node: AxNode, depth: number): void => {
330
+ const role = axString(node.role);
331
+ const name = axString(node.name);
332
+ const backendNodeId = node.backendDOMNodeId;
333
+ const emitted = !node.ignored && backendNodeId !== undefined && (interactiveOnly ? isInteractive(role) : !!(role || name));
334
+ if (emitted) {
335
+ const extras = axExtras(node);
336
+ out.push({ uid: encodeUid(backendNodeId as number), role: role ?? "generic", depth, ...(name ? { name } : {}), ...(Object.keys(extras).length ? { extras } : {}) });
337
+ }
338
+ const childDepth = emitted && !interactiveOnly ? depth + 1 : depth;
339
+ for (const childId of node.childIds ?? []) {
340
+ const child = byId.get(childId);
341
+ if (child) walk(child, childDepth);
342
+ }
343
+ };
344
+ for (const root of roots) walk(root, 0);
345
+ return out;
346
+ }
347
+ async locate(loc: ElementLocator): Promise<DriverUid> {
348
+ return encodeUid(await backendNodeIdOf(this.conn, loc));
349
+ }
350
+ async resolve(uid: DriverUid): Promise<void> {
351
+ await resolveUid(this.conn, decodeUid(uid)).catch(() => {
352
+ throw driverError("stale-uid", `uid does not resolve: ${uid}`);
353
+ });
354
+ }
355
+ // Copied verbatim from src/tools/input.ts `hover`.
356
+ async hover(loc: ElementLocator): Promise<{ x: number; y: number }> {
357
+ const { objectId } = await resolveElementLocator(this.conn, loc);
358
+ const { x, y } = await centerOf(this.conn, objectId);
359
+ await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
360
+ return { x, y };
361
+ }
362
+ // Copied dispatch sequence verbatim from src/tools/input.ts `click` (its resolve+centerOf+mouseMoved prefix IS hover, reused here).
363
+ async click(loc: ElementLocator, opts?: MouseButtonOptions): Promise<{ x: number; y: number }> {
364
+ const button = opts?.button ?? "left";
365
+ const clickCount = opts?.clickCount ?? 1;
366
+ const { x, y } = await this.hover(loc);
367
+ await this.conn.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount });
368
+ await this.conn.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, clickCount });
369
+ return { x, y };
370
+ }
371
+ // Copied dispatch sequence verbatim from src/tools/input.ts `drag` (its "from" prefix IS hover, reused here).
372
+ async drag(from: ElementLocator, to: ElementLocator): Promise<{ from: { x: number; y: number }; to: { x: number; y: number } }> {
373
+ const fromPt = await this.hover(from);
374
+ const { objectId: dstId } = await resolveElementLocator(this.conn, to);
375
+ const toPt = await centerOf(this.conn, dstId);
376
+ await this.conn.send("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
377
+ await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: (fromPt.x + toPt.x) / 2, y: (fromPt.y + toPt.y) / 2, button: "left" });
378
+ await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: toPt.x, y: toPt.y, button: "left" });
379
+ await this.conn.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
380
+ return { from: fromPt, to: toPt };
381
+ }
382
+ async setValue(loc: ElementLocator, value: string): Promise<void> {
383
+ const { objectId } = await resolveElementLocator(this.conn, loc);
384
+ await setValueOnObject(this.conn, objectId, value);
385
+ }
386
+ async typeText(loc: ElementLocator, text: string): Promise<void> {
387
+ const { objectId } = await resolveElementLocator(this.conn, loc);
388
+ await focusElement(this.conn, objectId);
389
+ if (text.length) await this.conn.send("Input.insertText", { text });
390
+ }
391
+ // Copied dispatch logic verbatim from src/tools/input.ts `pressKey`.
392
+ async pressKey(press: KeyPress): Promise<void> {
393
+ const spec = resolveKey(press.key);
394
+ let modBits = 0;
395
+ for (const m of press.modifiers ?? []) {
396
+ const bit = MODIFIER_BITS[m.toLowerCase()];
397
+ if (bit === undefined) throw driverError("page-error", `unknown modifier '${m}'`);
398
+ modBits |= bit;
399
+ }
400
+ const suppressText = (modBits & ~(MODIFIER_BITS.shift as number)) !== 0;
401
+ const down: Record<string, unknown> = { type: "keyDown", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits };
402
+ if (spec.text && !suppressText) down.text = spec.text;
403
+ await this.conn.send("Input.dispatchKeyEvent", down);
404
+ await this.conn.send("Input.dispatchKeyEvent", { type: "keyUp", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits });
405
+ }
406
+ async setFiles(loc: ElementLocator, files: readonly string[]): Promise<void> {
407
+ const { objectId } = await resolveElementLocator(this.conn, loc);
408
+ await this.conn.send("DOM.setFileInputFiles", { files: [...files], objectId });
409
+ }
410
+ async screenshot(opts?: ScreenshotOptions): Promise<{ data: Uint8Array; format: "png" | "jpeg" }> {
411
+ await this.ensureDomain("Page");
412
+ const format = opts?.format ?? "png";
413
+ const quality = format === "jpeg" ? (opts?.quality ?? 80) : undefined;
414
+ const params: Record<string, unknown> = { format, captureBeyondViewport: true };
415
+ if (quality != null) params.quality = quality;
416
+ if (opts?.clip) params.clip = { ...(await elementClip(this.conn, opts.clip)), scale: 1 };
417
+ else if (opts?.fullPage) params.clip = { ...(await fullPageClip(this.conn)), scale: 1 };
418
+ const { data } = await this.conn.send<{ data: string }>("Page.captureScreenshot", params);
419
+ return { data: new Uint8Array(Buffer.from(data, "base64")), format };
420
+ }
421
+ // Adapted from src/tools/emulation.ts `emulate`.
422
+ async emulate(opts: EmulationOptions): Promise<{ applied: string[] }> {
423
+ if (opts.clearOverrides) {
424
+ const c = () => undefined;
425
+ await this.conn.send("Emulation.clearDeviceMetricsOverride").catch(c);
426
+ await this.conn.send("Emulation.setUserAgentOverride", { userAgent: "" }).catch(c);
427
+ await this.conn.send("Emulation.setCPUThrottlingRate", { rate: 1 }).catch(c);
428
+ await this.conn.send("Emulation.setEmulatedMedia", { media: "", features: [] }).catch(c);
429
+ await this.ensureDomainSoft("Network");
430
+ await this.conn.send("Network.emulateNetworkConditions", { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 }).catch(c);
431
+ return { applied: [] };
432
+ }
433
+ const applied: string[] = [];
434
+ // Copied verbatim from src/tools/emulation.ts `emulate`: width/height are required TOGETHER,
435
+ // not silently skipped when only one is given.
436
+ if (opts.width != null || opts.height != null) {
437
+ if (opts.width == null || opts.height == null) {
438
+ throw driverError("page-error", "emulate device metrics require both width and height");
439
+ }
440
+ await this.conn.send("Emulation.setDeviceMetricsOverride", {
441
+ width: opts.width,
442
+ height: opts.height,
443
+ deviceScaleFactor: opts.deviceScaleFactor ?? 0,
444
+ mobile: opts.mobile ?? false,
445
+ });
446
+ applied.push("deviceMetrics");
447
+ }
448
+ if (opts.userAgent != null) {
449
+ await this.conn.send("Emulation.setUserAgentOverride", { userAgent: opts.userAgent });
450
+ applied.push("userAgent");
451
+ }
452
+ if (opts.cpuThrottlingRate != null) {
453
+ if (opts.cpuThrottlingRate < 1) throw driverError("page-error", "cpuThrottlingRate must be >= 1");
454
+ await this.conn.send("Emulation.setCPUThrottlingRate", { rate: opts.cpuThrottlingRate });
455
+ applied.push("cpuThrottlingRate");
456
+ }
457
+ if (opts.media != null || opts.mediaFeatures != null) {
458
+ await this.conn.send("Emulation.setEmulatedMedia", { media: opts.media ?? "", features: opts.mediaFeatures ?? [] });
459
+ // Legacy string is "emulatedMedia", NOT "mediaFeatures" - a real applied[] value, not a label.
460
+ applied.push("emulatedMedia");
461
+ }
462
+ if (opts.networkConditions != null) {
463
+ const n = opts.networkConditions;
464
+ // Network.enable is soft-caught here, matching legacy exactly; the following
465
+ // emulateNetworkConditions call is NOT caught, so a genuine failure still propagates.
466
+ await this.ensureDomainSoft("Network");
467
+ await this.conn.send("Network.emulateNetworkConditions", {
468
+ offline: n.offline ?? false,
469
+ latency: n.latency ?? 0,
470
+ downloadThroughput: n.downloadThroughput ?? -1,
471
+ uploadThroughput: n.uploadThroughput ?? -1,
472
+ ...(n.connectionType ? { connectionType: n.connectionType } : {}),
473
+ });
474
+ applied.push("networkConditions");
475
+ }
476
+ return { applied };
477
+ }
478
+ private static readonly EVENT_MAP: Record<DriverEvent, { method: string; domain?: string }> = {
479
+ navigated: { method: "Page.frameNavigated" }, console: { method: "Runtime.consoleAPICalled" },
480
+ "network.request": { method: "Network.requestWillBeSent", domain: "Network" },
481
+ "network.response": { method: "Network.responseReceived", domain: "Network" },
482
+ dialog: { method: "Page.javascriptDialogOpening" }, "page.closed": { method: "Inspector.detached", domain: "Inspector" },
483
+ };
484
+ on(event: DriverEvent, handler: (payload: Record<string, unknown>) => void): () => void {
485
+ const spec = CdpPageDriver.EVENT_MAP[event];
486
+ if (spec.domain && !this.enabledDomains.has(spec.domain)) {
487
+ this.enabledDomains.add(spec.domain);
488
+ void this.conn.send(`${spec.domain}.enable`);
489
+ }
490
+ return this.conn.on(spec.method, (params) => handler(params));
491
+ }
492
+ async waitForEvent(event: DriverEvent, predicate?: (p: Record<string, unknown>) => boolean, timeoutMs?: number): Promise<Record<string, unknown>> {
493
+ const spec = CdpPageDriver.EVENT_MAP[event];
494
+ if (spec.domain) await this.ensureDomain(spec.domain);
495
+ return this.conn.waitFor<Record<string, unknown>>(spec.method, predicate, timeoutMs);
496
+ }
497
+ // Copied verbatim (behaviorally) from src/tools/dialogs.ts `armDialogHandler` / `handleDialog`:
498
+ // one persistent Page.javascriptDialogOpening listener, answered as each dialog opens (not a
499
+ // poll loop), so timing/ordering under rapid-succession dialogs matches legacy exactly.
500
+ private armDialog(accept: boolean, promptText: string | undefined, onHandled: (d: HandledDialogInfo) => void): () => void {
501
+ return this.conn.on("Page.javascriptDialogOpening", (params) => {
502
+ const p = params as { type: string; message: string; url: string; defaultPrompt?: string };
503
+ void this.conn.send("Page.handleJavaScriptDialog", { accept, ...(promptText !== undefined ? { promptText } : {}) })
504
+ .then(() => onHandled({
505
+ type: p.type, message: p.message, url: p.url,
506
+ ...(p.defaultPrompt !== undefined ? { defaultPrompt: p.defaultPrompt } : {}),
507
+ accept, ...(promptText !== undefined ? { promptText } : {}), handled: true,
508
+ }))
509
+ .catch(() => undefined); // connection may have raced closed; the awaiter times out
510
+ });
511
+ }
512
+ async handleDialog(
513
+ accept: boolean,
514
+ promptText?: string,
515
+ opts?: { timeoutMs?: number; autoMs?: number },
516
+ ): Promise<HandledDialogInfo | { handled: HandledDialogInfo[]; count: number }> {
517
+ await this.ensureDomain("Page");
518
+ if (opts?.autoMs !== undefined) {
519
+ const handled: HandledDialogInfo[] = [];
520
+ const off = this.armDialog(accept, promptText, (d) => handled.push(d));
521
+ await new Promise<void>((resolve) => setTimeout(resolve, opts.autoMs));
522
+ off();
523
+ return { handled, count: handled.length };
524
+ }
525
+ const timeoutMs = opts?.timeoutMs ?? 15_000;
526
+ return new Promise<HandledDialogInfo>((resolve, reject) => {
527
+ const timer = setTimeout(() => {
528
+ off();
529
+ reject(driverError("timeout", `handleDialog: no dialog opened within ${timeoutMs}ms`));
530
+ }, timeoutMs);
531
+ const off = this.armDialog(accept, promptText, (d) => {
532
+ clearTimeout(timer);
533
+ off();
534
+ resolve(d);
535
+ });
536
+ });
537
+ }
538
+ async startBodyCapture(): Promise<() => Promise<void>> {
539
+ await this.ensureDomain("Network");
540
+ return async () => {
541
+ await this.conn.send("Network.disable").catch(() => undefined);
542
+ this.enabledDomains.delete("Network");
543
+ };
544
+ }
545
+ async getResponseBody(requestId: string): Promise<{ body: string; base64Encoded: boolean }> {
546
+ return this.conn.send<{ body: string; base64Encoded: boolean }>("Network.getResponseBody", { requestId });
547
+ }
548
+ // Adapted from src/tools/network_mock.ts `registerHandler`, reusing its exported pure-logic layer.
549
+ async intercept(rules: readonly InterceptRule[]): Promise<() => Promise<void>> {
550
+ await this.conn.send("Fetch.enable", { patterns: rules.map((r) => ({ urlPattern: r.urlPattern })) });
551
+ let chain = Promise.resolve();
552
+ const off = this.conn.on("Fetch.requestPaused", (params) => {
553
+ const p = params as { requestId: string; request: { url: string; method: string } };
554
+ chain = chain
555
+ .then(async () => {
556
+ const rule = selectRule(rules as Array<InterceptRule & { urlPattern: string }>, p.request.url, p.request.method);
557
+ if (!rule) {
558
+ await this.conn.send("Fetch.continueRequest", { requestId: p.requestId });
559
+ return;
560
+ }
561
+ if (rule.delayMs) await new Promise((r) => setTimeout(r, rule.delayMs));
562
+ const act = effectiveAction(rule, Math.random());
563
+ if (act === "fulfill") await this.conn.send("Fetch.fulfillRequest", { ...buildFulfillParams(p.requestId, rule) });
564
+ else if (act === "fail") await this.conn.send("Fetch.failRequest", { requestId: p.requestId, errorReason: rule.errorReason ?? "Failed" });
565
+ else await this.conn.send("Fetch.continueRequest", { requestId: p.requestId });
566
+ })
567
+ .catch(() => undefined);
568
+ });
569
+ return async () => {
570
+ off();
571
+ await this.conn.send("Fetch.disable").catch(() => undefined);
572
+ };
573
+ }
574
+ async release(): Promise<void> {
575
+ if (this.released) return;
576
+ this.released = true;
577
+ this.conn.close();
578
+ }
579
+ }
580
+ /* --------------------------------- BrowserDriver --------------------------------- */
581
+ // Runs fn on a throwaway browser-endpoint connection, always closing it.
582
+ async function withBrowserConn<T>(fn: (conn: CdpConnection) => Promise<T>): Promise<T> {
583
+ const conn = await openBrowser();
584
+ try {
585
+ return await fn(conn);
586
+ } finally {
587
+ conn.close();
588
+ }
589
+ }
590
+ function noSuchTarget(e: unknown): never {
591
+ throw driverError("no-such-target", e instanceof CdpError ? e.message : String(e));
592
+ }
593
+ class CdpBrowserDriver implements BrowserDriver {
594
+ readonly scheme = "cdp";
595
+ readonly lifetime: LifetimeModel = "per-call";
596
+ readonly capabilities = CDP_CAPABILITIES;
597
+ readonly uidStability: UidStability = "browser-node";
598
+ readonly snapshotFidelity = "accessibility-tree" as const;
599
+ // Copied verbatim from src/tools/pages.ts `listPages`: "all" toggles the type filter, and
600
+ // "type" rides on every returned entry (both were dropped by the earlier generic mapping).
601
+ async listPages(opts?: { all?: boolean }): Promise<PageInfo[]> {
602
+ const targets = await listTargets();
603
+ const filtered = opts?.all ? targets : targets.filter((t) => t.type === "page");
604
+ return filtered.map((t) => ({ id: t.id, url: t.url, title: t.title, type: t.type }));
605
+ }
606
+ async newPage(url?: string): Promise<PageInfo> {
607
+ return withBrowserConn(async (conn) => {
608
+ const { targetId } = await conn.send<{ targetId: string }>("Target.createTarget", { url: url ?? "about:blank" });
609
+ if (!targetId) throw driverError("page-error", "Target.createTarget returned no targetId");
610
+ return { id: targetId, url: url ?? "about:blank", title: "" };
611
+ });
612
+ }
613
+ /** Copied verbatim from src/tools/pages.ts `closePage`: the actual `success` field is
614
+ * propagated (defaulting true only when the response omits it, matching newer Chromium
615
+ * builds), never hardcoded. */
616
+ async closePage(id: string): Promise<{ success: boolean }> {
617
+ return withBrowserConn(async (conn) => {
618
+ const res = await conn.send<{ success?: boolean }>("Target.closeTarget", { targetId: id });
619
+ return { success: res.success ?? true };
620
+ });
621
+ }
622
+ async activatePage(id: string): Promise<PageInfo> {
623
+ const target = await resolveTarget(id).catch(noSuchTarget);
624
+ await withBrowserConn((conn) => conn.send("Target.activateTarget", { targetId: target.id }));
625
+ return { id: target.id, url: target.url, title: target.title };
626
+ }
627
+ async page(selector: TargetSelector): Promise<PageDriver> {
628
+ const { conn, target } = await openPage(selector).catch(noSuchTarget);
629
+ // Deliberately NOT enabling ANY domain here (previously eagerly enabled Page+Runtime on every
630
+ // acquisition, which is exactly the "no eager domain enabling" policy violation this migration
631
+ // fixed: click/hover/drag/fill/etc never enabled any domain under the legacy tools/input.ts,
632
+ // so a Chrome click through this driver must not either). Every domain is enabled lazily by
633
+ // the PageDriver method that actually needs it, matching each legacy tool's own timing.
634
+ return new CdpPageDriver(conn, target, this);
635
+ }
636
+ async dispose(): Promise<void> {}
637
+ }
638
+ /** Construct the CDP implementation of the frozen Driver contract. */
639
+ export function createCdpDriver(): BrowserDriver { return new CdpBrowserDriver(); }
640
+ export { CdpBrowserDriver, CdpPageDriver };
641
+ /* ------------------------------------------------------------------------------
642
+ * CDP methods/domains used: Target.createTarget/closeTarget/activateTarget; Page.enable/
643
+ * navigate/reload/getFrameTree/loadEventFired/domContentEventFired/frameStoppedLoading/
644
+ * captureScreenshot/getLayoutMetrics/javascriptDialogOpening/handleJavaScriptDialog;
645
+ * Runtime.enable/evaluate/callFunctionOn/consoleAPICalled; Accessibility.enable/getFullAXTree;
646
+ * DOM.resolveNode (via resolveUid)/describeNode/performSearch/getSearchResults/
647
+ * discardSearchResults/scrollIntoViewIfNeeded/getBoxModel/setFileInputFiles/getDocument;
648
+ * Input.dispatchMouseEvent/dispatchKeyEvent/insertText; Emulation.setDeviceMetricsOverride/
649
+ * clearDeviceMetricsOverride/setUserAgentOverride/setCPUThrottlingRate/setEmulatedMedia;
650
+ * Network.enable/disable/emulateNetworkConditions/getResponseBody/requestWillBeSent/
651
+ * responseReceived; Fetch.enable/disable/requestPaused/fulfillRequest/failRequest/
652
+ * continueRequest; Inspector.detached.
653
+ * Parity/behavior notes:
654
+ * - locate.text/locate.xpath ARE implemented (searchLocate, via DOM.performSearch) and ARE
655
+ * declared in CDP_CAPABILITIES.
656
+ * - network.responseBodyPostHoc is NOT declared: Network is enabled lazily (only by
657
+ * startBodyCapture / emulate's networkConditions branch), never eagerly by page(), so a
658
+ * body is only readable for a request that occurred after something armed capture first.
659
+ * That is arming by another name, not "not armed in advance".
660
+ * ---------------------------------------------------------------------------- */