cdp-toolkit 1.0.0 → 1.1.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,419 @@
1
+ /**
2
+ * Firefox accessibility-style snapshot, built by walking the live DOM in-page.
3
+ *
4
+ * Chrome gets take_snapshot for free from Accessibility.getFullAXTree. BiDi
5
+ * has no equivalent domain at all, so this module assembles the same shape
6
+ * of output (see src/tools/snapshot.ts, the format spec) by generating a
7
+ * self-contained JavaScript function SOURCE and handing it to whatever
8
+ * `evaluate` the caller supplies. This file never opens a socket and never
9
+ * imports a transport: takeStampedSnapshot is testable with a fake evaluate,
10
+ * which is the whole point of the split (driver.ts owns BiDi wiring).
11
+ *
12
+ * Per ADR-001 (src/driver.ts), Firefox uids are PAGE STATE, not protocol
13
+ * state: every emitted node gets a stamp written onto it as the STAMP_ATTR
14
+ * attribute, and that stamp (not a script/browsingContext handle) is what
15
+ * survives a dropped socket, a fresh session, and a fresh CLI process.
16
+ */
17
+
18
+ /** Attribute stamped onto every emitted element, mirroring driver.ts UID_STAMP_ATTR. */
19
+ export const STAMP_ATTR = "data-cdp-uid";
20
+
21
+ export interface StampedSnapshot {
22
+ snapshot: string;
23
+ nodeCount: number;
24
+ }
25
+
26
+ /**
27
+ * Take a snapshot of the current page by evaluating a generated JS function
28
+ * in it. `evaluate` is handed the function SOURCE, not a closure: the source
29
+ * is stringified and must not reference anything outside itself. It must
30
+ * resolve to whatever that function returns.
31
+ */
32
+ export async function takeStampedSnapshot(
33
+ evaluate: (functionDeclaration: string) => Promise<unknown>,
34
+ ): Promise<StampedSnapshot> {
35
+ const raw = await evaluate(buildSnapshotFunctionSource());
36
+ return coerceSnapshotResult(raw);
37
+ }
38
+
39
+ /** Validate + narrow the untyped evaluate() return into a StampedSnapshot. */
40
+ export function coerceSnapshotResult(raw: unknown): StampedSnapshot {
41
+ if (
42
+ typeof raw !== "object" ||
43
+ raw === null ||
44
+ typeof (raw as { snapshot?: unknown }).snapshot !== "string" ||
45
+ typeof (raw as { nodeCount?: unknown }).nodeCount !== "number"
46
+ ) {
47
+ throw new Error("takeStampedSnapshot: evaluate() returned an unexpected shape");
48
+ }
49
+ return { snapshot: (raw as StampedSnapshot).snapshot, nodeCount: (raw as StampedSnapshot).nodeCount };
50
+ }
51
+
52
+ /**
53
+ * The in-page function source. Zero-argument, self-contained: no closures
54
+ * over TypeScript scope, no imports, nothing but plain DOM/ARIA calls that
55
+ * exist in every browser this ships against. Kept as one string builder
56
+ * (rather than a literal template) so MAX_NAME_LENGTH and the STAMP_ATTR
57
+ * value are injected once instead of duplicated between TS and JS.
58
+ */
59
+ export function buildSnapshotFunctionSource(): string {
60
+ return `function() {
61
+ var STAMP_ATTR = ${JSON.stringify(STAMP_ATTR)};
62
+ var MAX_NAME_LENGTH = ${MAX_NAME_LENGTH};
63
+ var lines = [];
64
+ var nodeCount = 0;
65
+
66
+ // THE UID CODEC (driver.ts) documents a bidi payload as exactly 12 lowercase hex chars,
67
+ // /^[0-9a-f]{12}$/. Build it from two padded random blocks so the length is exact regardless
68
+ // of the random value drawn, never from string concatenation that could come up short.
69
+ function nextStamp() {
70
+ var hi = Math.floor(Math.random() * 0x100000000).toString(16).padStart(8, "0");
71
+ var lo = Math.floor(Math.random() * 0x10000).toString(16).padStart(4, "0");
72
+ return hi + lo;
73
+ }
74
+
75
+ function stampOf(el) {
76
+ var existing = el.getAttribute(STAMP_ATTR);
77
+ if (existing) return existing;
78
+ var stamp = nextStamp();
79
+ el.setAttribute(STAMP_ATTR, stamp);
80
+ return stamp;
81
+ }
82
+
83
+ // skipSizeCheck exempts <option>/<optgroup> from the zero-size prune: a
84
+ // closed select's popup is unpainted (0x0) though its options are still
85
+ // present and pickable. Other visibility rules still apply to them.
86
+ function isVisible(el, skipSizeCheck) {
87
+ if (el.hidden) return false;
88
+ if (el.getAttribute("aria-hidden") === "true") return false;
89
+ var style;
90
+ try {
91
+ style = el.ownerDocument.defaultView.getComputedStyle(el);
92
+ } catch (e) {
93
+ style = null;
94
+ }
95
+ if (style) {
96
+ if (style.display === "none" || style.visibility === "hidden") return false;
97
+ }
98
+ if (!skipSizeCheck) {
99
+ var rect = el.getBoundingClientRect();
100
+ if (rect.width === 0 && rect.height === 0) return false;
101
+ }
102
+ return true;
103
+ }
104
+
105
+ function truncate(s) {
106
+ s = s.trim().replace(/\\s+/g, " ");
107
+ if (s.length > MAX_NAME_LENGTH) return s.slice(0, MAX_NAME_LENGTH - 1) + "\\u2026";
108
+ return s;
109
+ }
110
+
111
+ function textOf(el) {
112
+ return el.textContent || "";
113
+ }
114
+
115
+ function idRefsText(doc, ids) {
116
+ var parts = [];
117
+ var list = ids.trim().split(/\\s+/);
118
+ for (var i = 0; i < list.length; i++) {
119
+ var ref = doc.getElementById(list[i]);
120
+ if (ref) parts.push(textOf(ref));
121
+ }
122
+ return parts.join(" ").trim();
123
+ }
124
+
125
+ // Text of a <label>, minus any nested form control's own content: a
126
+ // label that WRAPS its control ("<label>Color <select>...</select></label>")
127
+ // would otherwise include every option's text in the control's own name.
128
+ function labelOwnText(label) {
129
+ var clone = label.cloneNode(true);
130
+ var controls = clone.querySelectorAll("input, select, textarea, button");
131
+ for (var i = 0; i < controls.length; i++) {
132
+ controls[i].parentNode.removeChild(controls[i]);
133
+ }
134
+ return textOf(clone);
135
+ }
136
+
137
+ // Guard: without this, an <option> inside a labelled <select> would
138
+ // inherit the select's own label via closest("label"), not its text.
139
+ var LABELABLE_TAGS = { INPUT: 1, TEXTAREA: 1, SELECT: 1, BUTTON: 1, METER: 1, OUTPUT: 1, PROGRESS: 1 };
140
+
141
+ function nativeLabelText(el) {
142
+ if (!LABELABLE_TAGS[el.tagName]) return "";
143
+ var doc = el.ownerDocument;
144
+ if (el.id) {
145
+ var forLabel = doc.querySelector('label[for="' + el.id + '"]');
146
+ if (forLabel) return labelOwnText(forLabel);
147
+ }
148
+ var ancestor = el.closest ? el.closest("label") : null;
149
+ if (ancestor) return labelOwnText(ancestor);
150
+ return "";
151
+ }
152
+
153
+ // Reduced accname: aria-labelledby, then aria-label, then native label,
154
+ // then alt/title/placeholder, then trimmed own text for content-named
155
+ // elements. Not the full W3C accname algorithm (no recursive traversal
156
+ // rules for nested presentational content) but covers the common cases.
157
+ function accessibleName(el, contentNamed) {
158
+ var doc = el.ownerDocument;
159
+ var labelledby = el.getAttribute("aria-labelledby");
160
+ if (labelledby) {
161
+ var viaIds = idRefsText(doc, labelledby);
162
+ if (viaIds) return truncate(viaIds);
163
+ }
164
+ var ariaLabel = el.getAttribute("aria-label");
165
+ if (ariaLabel && ariaLabel.trim()) return truncate(ariaLabel);
166
+ var native = nativeLabelText(el);
167
+ if (native && native.trim()) return truncate(native);
168
+ var alt = el.getAttribute("alt");
169
+ if (alt && alt.trim()) return truncate(alt);
170
+ var title = el.getAttribute("title");
171
+ if (title && title.trim()) return truncate(title);
172
+ var placeholder = el.getAttribute("placeholder");
173
+ if (placeholder && placeholder.trim()) return truncate(placeholder);
174
+ if (contentNamed) {
175
+ var text = textOf(el);
176
+ if (text && text.trim()) return truncate(text);
177
+ }
178
+ return "";
179
+ }
180
+
181
+ var IMPLICIT_ROLE_BY_TAG = {
182
+ A: "link", BUTTON: "button", NAV: "navigation", MAIN: "main", FORM: "form", DIALOG: "dialog",
183
+ UL: "list", OL: "list", LI: "listitem", TABLE: "table", TR: "row", TD: "cell", TH: "columnheader",
184
+ SELECT: "combobox", OPTION: "option", TEXTAREA: "textbox", IMG: "img",
185
+ H1: "heading", H2: "heading", H3: "heading", H4: "heading", H5: "heading", H6: "heading",
186
+ PROGRESS: "progressbar", METER: "meter"
187
+ };
188
+
189
+ var INPUT_TYPE_ROLE = {
190
+ button: "button", submit: "button", reset: "button", checkbox: "checkbox", radio: "radio",
191
+ range: "slider", number: "spinbutton", search: "searchbox", email: "textbox", tel: "textbox",
192
+ url: "textbox", password: "textbox", text: "textbox", image: "button"
193
+ };
194
+
195
+ var CONTENT_NAMED_ROLES = {
196
+ button: true, link: true, heading: true, option: true, cell: true,
197
+ columnheader: true, listitem: true, menuitem: true, tab: true
198
+ };
199
+
200
+ function implicitRole(el) {
201
+ var explicit = el.getAttribute("role");
202
+ if (explicit) return explicit;
203
+ var tag = el.tagName;
204
+ if (tag === "INPUT") {
205
+ var type = (el.getAttribute("type") || "text").toLowerCase();
206
+ return INPUT_TYPE_ROLE[type] || "textbox";
207
+ }
208
+ if (tag === "A" && !el.getAttribute("href")) return "";
209
+ if (Object.prototype.hasOwnProperty.call(IMPLICIT_ROLE_BY_TAG, tag)) {
210
+ return IMPLICIT_ROLE_BY_TAG[tag];
211
+ }
212
+ // Deliberate divergence: Chrome's real AX tree says "generic" here,
213
+ // but "textbox" is the useful signal an agent needs. Chrome is out of
214
+ // step, do not "fix" this toward parity.
215
+ if (el.isContentEditable) return "textbox";
216
+ return "";
217
+ }
218
+
219
+ function headingLevel(el) {
220
+ var m = /^H([1-6])$/.exec(el.tagName);
221
+ return m ? m[1] : null;
222
+ }
223
+
224
+ function currentValue(el) {
225
+ var tag = el.tagName;
226
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") {
227
+ return el.value;
228
+ }
229
+ if (el.isContentEditable) return textOf(el);
230
+ return undefined;
231
+ }
232
+
233
+ function isChecked(el) {
234
+ var tag = el.tagName;
235
+ if (tag === "INPUT") {
236
+ var type = (el.getAttribute("type") || "").toLowerCase();
237
+ if (type === "checkbox" || type === "radio") return !!el.checked;
238
+ }
239
+ var aria = el.getAttribute("aria-checked");
240
+ if (aria === "true") return true;
241
+ return false;
242
+ }
243
+
244
+ function describeNode(el, role) {
245
+ var contentNamed = !!CONTENT_NAMED_ROLES[role];
246
+ var name = accessibleName(el, contentNamed);
247
+ var extras = [];
248
+ var value = currentValue(el);
249
+ if (value !== undefined && value !== "") extras.push("value=" + JSON.stringify(String(value)));
250
+ if (isChecked(el)) extras.push("checked=true");
251
+ if (el.tagName === "OPTION" && el.selected) extras.push("selected");
252
+ var href = el.getAttribute("href");
253
+ if (href) extras.push("url=" + href);
254
+ if (el.disabled || el.getAttribute("aria-disabled") === "true") extras.push("disabled");
255
+ var level = headingLevel(el);
256
+ if (level) extras.push("level=" + level);
257
+ var label = name ? " " + JSON.stringify(name) : "";
258
+ var extra = extras.length ? " [" + extras.join(" ") + "]" : "";
259
+ return { role: role, label: label, extra: extra, name: name };
260
+ }
261
+
262
+ // Traverses light DOM, open shadow roots, and same-origin iframe documents.
263
+ // A cross-origin iframe throws on contentDocument access; caught and
264
+ // skipped rather than aborting the whole walk, and noted in a comment
265
+ // here rather than silently vanishing from the tree without a trace.
266
+ function walk(root, depth) {
267
+ var children = root.children ? Array.prototype.slice.call(root.children) : [];
268
+ for (var i = 0; i < children.length; i++) {
269
+ var el = children[i];
270
+ var isSelectChild = el.tagName === "OPTION" || el.tagName === "OPTGROUP";
271
+ if (!isVisible(el, isSelectChild)) continue;
272
+
273
+ var role = implicitRole(el);
274
+ var isLandmarkOrControl = !!role;
275
+ var described = null;
276
+ if (isLandmarkOrControl) {
277
+ described = describeNode(el, role);
278
+ }
279
+
280
+ var hasContent = described && (described.name || described.extra);
281
+ var isStructural = isLandmarkOrControl && (hasContent || isInteractiveRole(role) || isLandmarkRole(role));
282
+
283
+ // Role-less visible leaf text (measured vs Chrome's take_snapshot,
284
+ // which keeps a StaticText under a generic wrapper for this case,
285
+ // e.g. a plain status <div>). Only a leaf (no element children, so
286
+ // its text is not already captured by a nested control/label).
287
+ var hasElementChildren = !!(el.children && el.children.length > 0);
288
+ var leafName = !isStructural && !hasElementChildren ? truncate(textOf(el)) : "";
289
+ var isLeafText = !!leafName;
290
+
291
+ var nextDepth = depth;
292
+ if (isStructural || isLeafText) {
293
+ var stamp = stampOf(el);
294
+ var indent = new Array(depth + 1).join(" ");
295
+ var lineTail = isStructural
296
+ ? described.role + described.label + described.extra
297
+ : "text " + JSON.stringify(leafName);
298
+ lines.push(indent + "[" + stamp + "] " + lineTail);
299
+ nodeCount += 1;
300
+ nextDepth = depth + 1;
301
+ }
302
+
303
+ if (el.shadowRoot) {
304
+ walk(el.shadowRoot, nextDepth);
305
+ } else if (el.tagName === "IFRAME") {
306
+ try {
307
+ var doc = el.contentDocument;
308
+ if (doc && doc.body) walk(doc.body, nextDepth);
309
+ } catch (e) {
310
+ // cross-origin iframe: intentionally skipped, not silently merged
311
+ // into the parent tree as though it were empty.
312
+ }
313
+ } else {
314
+ walk(el, nextDepth);
315
+ }
316
+ }
317
+ }
318
+
319
+ function isInteractiveRole(role) {
320
+ return (
321
+ role === "button" ||
322
+ role === "link" ||
323
+ role === "textbox" ||
324
+ role === "searchbox" ||
325
+ role === "checkbox" ||
326
+ role === "radio" ||
327
+ role === "combobox" ||
328
+ role === "listbox" ||
329
+ role === "option" ||
330
+ role === "slider" ||
331
+ role === "spinbutton" ||
332
+ role === "progressbar" ||
333
+ role === "meter"
334
+ );
335
+ }
336
+
337
+ function isLandmarkRole(role) {
338
+ return (
339
+ role === "navigation" ||
340
+ role === "main" ||
341
+ role === "form" ||
342
+ role === "dialog" ||
343
+ role === "region" ||
344
+ role === "heading" ||
345
+ role === "list" ||
346
+ role === "listitem" ||
347
+ role === "table" ||
348
+ role === "row" ||
349
+ role === "cell" ||
350
+ role === "columnheader" ||
351
+ role === "img"
352
+ );
353
+ }
354
+
355
+ walk(document.body, 0);
356
+ return { snapshot: lines.join("\\n"), nodeCount: nodeCount };
357
+ }`;
358
+ }
359
+
360
+ /** Truncation length applied to accessible names, shared by TS tests and the JS source above. */
361
+ export const MAX_NAME_LENGTH = 120;
362
+
363
+ /* -------------------------- pure helpers (unit-testable) -------------------------- */
364
+
365
+ /**
366
+ * Mirrors the in-page stamp-reuse rule: reuse a stamp an element already
367
+ * carries so a second snapshot in the same document does not renumber uids
368
+ * the caller may still be holding from the first one.
369
+ */
370
+ export function reuseOrMintStamp(existing: string | undefined, mint: () => string): string {
371
+ return existing && existing.length > 0 ? existing : mint();
372
+ }
373
+
374
+ /** Same truncation rule the in-page source applies to accessible names. */
375
+ export function truncateName(raw: string, maxLength: number = MAX_NAME_LENGTH): string {
376
+ const collapsed = raw.trim().replace(/\s+/g, " ");
377
+ if (collapsed.length > maxLength) return `${collapsed.slice(0, maxLength - 1)}…`;
378
+ return collapsed;
379
+ }
380
+
381
+ /** Assembles one Chrome-format tree line, matching src/tools/snapshot.ts's formatNode shape. */
382
+ export function formatSnapshotLine(
383
+ uid: string,
384
+ role: string,
385
+ name: string | undefined,
386
+ extras: string[],
387
+ depth: number,
388
+ ): string {
389
+ const indent = " ".repeat(depth);
390
+ const label = name ? ` ${JSON.stringify(name)}` : "";
391
+ const extra = extras.length ? ` [${extras.join(" ")}]` : "";
392
+ return `${indent}[${uid}] ${role}${label}${extra}`;
393
+ }
394
+
395
+ /*
396
+ * DOM APIs used by the generated in-page function: getComputedStyle,
397
+ * getBoundingClientRect, getAttribute/setAttribute, closest, getElementById,
398
+ * shadowRoot, contentDocument. No CDP or BiDi calls happen in this file at
399
+ * all; the transport is entirely the caller's `evaluate` parameter.
400
+ * Parity gaps vs Chrome's Accessibility.getFullAXTree:
401
+ * - Role coverage is a deliberate subset (interactive controls + the
402
+ * landmark/heading/list/table roles that matter for driving a page), not
403
+ * the full HTML-AAM/ARIA-AAM mapping Chrome's native tree implements.
404
+ * - Name computation is a reduced accname order (labelledby, label,
405
+ * native label, alt/title/placeholder, content text) and skips the
406
+ * full recursive "name from content" traversal rules for nested
407
+ * presentational descendants.
408
+ * - Shadow DOM: open shadow roots are walked; closed shadow roots are
409
+ * invisible to this code by construction (no API reaches them).
410
+ * - Iframes: same-origin iframes are walked; cross-origin iframes are
411
+ * skipped (contentDocument throws) rather than silently merged in.
412
+ * - Stamps are minted with Math.random(), not a CSPRNG; adequate for
413
+ * page-local uniqueness, not adequate as a security token.
414
+ * - Role-less visible leaf text (e.g. a plain status <div>) is emitted as
415
+ * a synthetic "text" role, matching a measured behavior of Chrome's
416
+ * take_snapshot (StaticText under a generic wrapper); a non-leaf
417
+ * element's own direct text (siblings of nested elements) is not
418
+ * walked, so mixed inline content only surfaces its element children.
419
+ */
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Firefox-only tool implementations, built on the browser-neutral Driver contract (ADR-001).
3
+ * These are the 7 tools NOT unified with Chrome's src/tools/*.ts implementations: Chrome's
4
+ * console/network/mock tools are built on src/tools/recorder.ts, a disk-persisted,
5
+ * cross-process capture keyed by target id; these are a same-process, in-memory event buffer.
6
+ * See src/shared-tools.ts's file header for why unifying them was not a safe behavior-preserving
7
+ * move and was left as two implementations. This file is what src/neutral.ts shrank to once its
8
+ * other 20 tools became src/shared-tools.ts's single real implementation.
9
+ */
10
+ import type { BrowserDriver, InterceptRule, PageDriver, PageInfo } from "./driver.ts";
11
+ import { selectRule, buildFulfillParams, effectiveAction } from "./tools/network_mock.ts";
12
+ import type { TargetSelector } from "./types.ts";
13
+
14
+ class BidiToolError extends Error {}
15
+
16
+ async function withPage<T>(driver: BrowserDriver, target: TargetSelector | undefined, fn: (page: PageDriver) => Promise<T>): Promise<T> {
17
+ const page = await driver.page(target);
18
+ try {
19
+ return await fn(page);
20
+ } finally {
21
+ await page.release();
22
+ }
23
+ }
24
+
25
+ /* ------------------------------ console + network (4) ------------------------------ */
26
+ // No recorder-style raw-CDP buffer exists for the neutral driver. Buffering rides
27
+ // PageDriver.on(), scoped to the calling process's lifetime, an identical constraint
28
+ // to the CDP recorder (both are per-process). Payloads are the driver's own event
29
+ // shape (BiDi's log.entryAdded / network.* records), NOT normalized to CDP's
30
+ // Runtime.consoleAPICalled / Network.* shapes, a real cross-driver difference,
31
+ // documented in README.md rather than smoothed over.
32
+
33
+ const consoleBuffers = new Map<string, Array<Record<string, unknown>>>();
34
+ const networkBuffers = new Map<string, Array<Record<string, unknown>>>();
35
+
36
+ function bufferFor(map: Map<string, Array<Record<string, unknown>>>, id: string): Array<Record<string, unknown>> {
37
+ let b = map.get(id);
38
+ if (!b) { b = []; map.set(id, b); }
39
+ return b;
40
+ }
41
+
42
+ async function captureFresh(page: PageDriver, durationMs: number): Promise<void> {
43
+ const cBuf = bufferFor(consoleBuffers, page.info.id);
44
+ const nBuf = bufferFor(networkBuffers, page.info.id);
45
+ cBuf.length = 0;
46
+ nBuf.length = 0;
47
+ const offC = page.on("console", (p) => cBuf.push(p));
48
+ const offReq = page.on("network.request", (p) => nBuf.push({ ...p, phase: "request" }));
49
+ const offRes = page.on("network.response", (p) => nBuf.push({ ...p, phase: "response" }));
50
+ try {
51
+ await page.navigate({ reload: true }).catch(() => undefined);
52
+ await new Promise((r) => setTimeout(r, durationMs));
53
+ } finally {
54
+ offC(); offReq(); offRes();
55
+ }
56
+ }
57
+
58
+ export async function listConsoleMessages(
59
+ driver: BrowserDriver,
60
+ args: { target?: TargetSelector; reload?: boolean; durationMs?: number } = {},
61
+ ): Promise<{ messages: Array<Record<string, unknown>>; count: number }> {
62
+ return withPage(driver, args.target, async (page) => {
63
+ if (args.reload) await captureFresh(page, args.durationMs ?? 2500);
64
+ const buf = bufferFor(consoleBuffers, page.info.id);
65
+ return { messages: [...buf], count: buf.length };
66
+ });
67
+ }
68
+
69
+ export async function getConsoleMessage(
70
+ driver: BrowserDriver,
71
+ args: { target?: TargetSelector; index?: number } = {},
72
+ ): Promise<Record<string, unknown>> {
73
+ return withPage(driver, args.target, async (page) => {
74
+ const buf = bufferFor(consoleBuffers, page.info.id);
75
+ const idx = args.index ?? 0;
76
+ const entry = buf[idx];
77
+ if (!entry) throw new BidiToolError(`no console message at index ${idx}; run list_console_messages first`);
78
+ return entry;
79
+ });
80
+ }
81
+
82
+ export async function listNetworkRequests(
83
+ driver: BrowserDriver,
84
+ args: { target?: TargetSelector; reload?: boolean; durationMs?: number; filterUrl?: string } = {},
85
+ ): Promise<{ requests: Array<Record<string, unknown>>; count: number }> {
86
+ return withPage(driver, args.target, async (page) => {
87
+ if (args.reload) await captureFresh(page, args.durationMs ?? 2500);
88
+ let buf = bufferFor(networkBuffers, page.info.id);
89
+ if (args.filterUrl) buf = buf.filter((r) => typeof r.url === "string" && (r.url as string).includes(args.filterUrl as string));
90
+ return { requests: buf, count: buf.length };
91
+ });
92
+ }
93
+
94
+ export async function getNetworkRequest(
95
+ driver: BrowserDriver,
96
+ args: { target?: TargetSelector; requestId?: string; url?: string } = {},
97
+ ): Promise<Record<string, unknown>> {
98
+ if (!args.requestId && !args.url) throw new BidiToolError("get_network_request requires requestId or url");
99
+ return withPage(driver, args.target, async (page) => {
100
+ const buf = bufferFor(networkBuffers, page.info.id);
101
+ const found = args.requestId
102
+ ? buf.find((r) => r.requestId === args.requestId || r.request === args.requestId)
103
+ : buf.find((r) => typeof r.url === "string" && (r.url as string).includes(args.url as string));
104
+ if (!found) throw new BidiToolError("no matching network request in the current buffer; run list_network_requests first");
105
+ return found;
106
+ });
107
+ }
108
+
109
+ /* ------------------------------- network mocking (3) ------------------------------- */
110
+ // Reuses tools/network_mock.ts's pure logic layer (selectRule/buildFulfillParams/
111
+ // effectiveAction) over PageDriver.intercept(), same pattern bidi/driver.ts's own
112
+ // intercept() uses internally. Session-scoped: intercept() takes the FULL rule set
113
+ // on every (re)arm, so adding a rule disposes the old interceptor and re-arms with
114
+ // the appended array. Persistent only for the life of the calling process: the MCP
115
+ // server (long-lived) keeps it across calls; the CLI (one-shot) does not.
116
+
117
+ interface MockSession {
118
+ rules: Array<InterceptRule & { hits: number }>;
119
+ dispose: () => Promise<void>;
120
+ }
121
+ const mockSessions = new Map<string, MockSession>();
122
+
123
+ export async function mockRequest(
124
+ driver: BrowserDriver,
125
+ args: {
126
+ target?: TargetSelector; urlPattern: string; action?: "fulfill" | "fail" | "continue"; status?: number;
127
+ body?: string; contentType?: string; headers?: Record<string, string>; errorReason?: string; method?: string;
128
+ delayMs?: number; failRate?: number; reload?: boolean;
129
+ },
130
+ ): Promise<{ target: PageInfo; rule: { urlPattern: string; action: string }; ruleCount: number }> {
131
+ if (!args.urlPattern) throw new BidiToolError("mock_request requires a { urlPattern }");
132
+ return withPage(driver, args.target, async (page) => {
133
+ const existing = mockSessions.get(page.info.id);
134
+ const rule: InterceptRule & { hits: number } = {
135
+ urlPattern: args.urlPattern, method: args.method, action: args.action ?? "fulfill",
136
+ status: args.status, body: args.body, contentType: args.contentType, headers: args.headers,
137
+ errorReason: args.errorReason, delayMs: args.delayMs, failRate: args.failRate, hits: 0,
138
+ };
139
+ const rules = existing ? [...existing.rules, rule] : [rule];
140
+ if (existing) await existing.dispose().catch(() => undefined);
141
+ const dispose = await page.intercept(rules);
142
+ mockSessions.set(page.info.id, { rules, dispose });
143
+ if (args.reload) await page.navigate({ reload: true }).catch(() => undefined);
144
+ return { target: page.info, rule: { urlPattern: rule.urlPattern, action: rule.action }, ruleCount: rules.length };
145
+ });
146
+ }
147
+
148
+ export async function listMocks(
149
+ _driver: BrowserDriver,
150
+ _args: { target?: TargetSelector } = {},
151
+ ): Promise<{ sessions: Array<{ target: string; rules: Array<{ urlPattern: string; action: string; method?: string }> }> }> {
152
+ const sessions = [...mockSessions.entries()].map(([id, s]) => ({
153
+ target: id,
154
+ rules: s.rules.map((r) => ({ urlPattern: r.urlPattern, action: r.action, method: r.method })),
155
+ }));
156
+ return { sessions };
157
+ }
158
+
159
+ export async function clearMocks(
160
+ driver: BrowserDriver,
161
+ args: { target?: TargetSelector; all?: boolean } = {},
162
+ ): Promise<{ cleared: number }> {
163
+ if (args.all) {
164
+ let n = 0;
165
+ for (const [, s] of mockSessions) { await s.dispose().catch(() => undefined); n++; }
166
+ mockSessions.clear();
167
+ return { cleared: n };
168
+ }
169
+ return withPage(driver, args.target, async (page) => {
170
+ const existing = mockSessions.get(page.info.id);
171
+ if (!existing) return { cleared: 0 };
172
+ await existing.dispose().catch(() => undefined);
173
+ mockSessions.delete(page.info.id);
174
+ return { cleared: 1 };
175
+ });
176
+ }
177
+
178
+ // selectRule/buildFulfillParams/effectiveAction are imported above purely so this module's
179
+ // footer can name them as reused; the actual call sites live inside bidi/driver.ts's
180
+ // PageDriver.intercept() implementation, which this file drives through the neutral
181
+ // `intercept(rules)` surface rather than re-implementing.
182
+ void selectRule; void buildFulfillParams; void effectiveAction;
183
+
184
+ /** The 7 Firefox-only tools: no Chrome equivalent is wired through this file (see header). */
185
+ export const BIDI_ONLY_TOOLS = {
186
+ list_console_messages: listConsoleMessages,
187
+ get_console_message: getConsoleMessage,
188
+ list_network_requests: listNetworkRequests,
189
+ get_network_request: getNetworkRequest,
190
+ mock_request: mockRequest,
191
+ list_mocks: listMocks,
192
+ clear_mocks: clearMocks,
193
+ } satisfies Record<string, (driver: BrowserDriver, args: never) => Promise<unknown>>;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Per-backend tool availability: filters the 33-tool registry down to what a
3
+ * given DriverKind can actually run, per ADR-001's rule that tools/list
4
+ * discovers gaps, not runtime "unsupported" throws. Pure and I/O-free: driver
5
+ * construction reads a static readonly `capabilities` set, it does not dial a
6
+ * browser (verified against both cdp/driver.ts and bidi/driver.ts).
7
+ */
8
+ import { createCdpDriver } from "./cdp/driver.ts";
9
+ import { createFirefoxDriver } from "./bidi/driver.ts";
10
+ import { REQUIRED_CAPABILITIES, type Capability, type DriverKind } from "./driver.ts";
11
+ import { TOOL_NAMES, type ToolName } from "./index.ts";
12
+
13
+ function capabilitiesFor(kind: DriverKind): ReadonlySet<Capability> {
14
+ // Port 0 is never dialed here: only the driver's static `capabilities` set is read.
15
+ return kind === "chrome" ? createCdpDriver().capabilities : createFirefoxDriver(0).capabilities;
16
+ }
17
+
18
+ export interface UnavailableTool {
19
+ name: ToolName;
20
+ missing: Capability[];
21
+ }
22
+
23
+ export interface ToolAvailability {
24
+ kind: DriverKind;
25
+ available: ToolName[];
26
+ unavailable: UnavailableTool[];
27
+ }
28
+
29
+ /** Every tool name, split into what `kind` can run vs. what it can't and why. */
30
+ export function toolAvailability(kind: DriverKind): ToolAvailability {
31
+ const caps = capabilitiesFor(kind);
32
+ const available: ToolName[] = [];
33
+ const unavailable: UnavailableTool[] = [];
34
+ for (const name of TOOL_NAMES) {
35
+ const required = REQUIRED_CAPABILITIES[name];
36
+ if (!required || required.every((c) => caps.has(c))) {
37
+ available.push(name);
38
+ } else {
39
+ unavailable.push({ name, missing: required.filter((c) => !caps.has(c)) });
40
+ }
41
+ }
42
+ return { kind, available, unavailable };
43
+ }