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,851 @@
1
+ /**
2
+ * BiDi implementation of the browser-neutral Driver contract in ../driver.ts (ADR-001), for
3
+ * Firefox. lifetime is "session": a BiDi socket cannot be resumed, so createFirefoxDriver(port)
4
+ * memoizes ONE BidiConnection per port at module scope (the connections map below), page()
5
+ * retains a holder, release() decrements it, and only BrowserDriver.dispose() ever closes the
6
+ * socket. There is deliberately no reconnect-and-resume path: if the socket dies, every pending
7
+ * and future call on it rejects with "disconnected" rather than silently starting a fresh
8
+ * session underneath a caller who still thinks they hold the old one. (driver.ts's LifetimeModel
9
+ * doc says a "session" driver "MUST re-establish itself transparently if the transport dies";
10
+ * this driver does not, on purpose, per the verified BiDi behavior in bidi/client.ts's own
11
+ * header. See the final report for why that doc comment and this implementation disagree.)
12
+ *
13
+ * Uid codec (scheme "bidi"): `bidi:<stamp>`, per THE UID CODEC block in ../driver.ts. A stamp is
14
+ * page state (an attribute written by ../bidi/snapshot.ts's in-page walker, or by this file's
15
+ * STAMP_ONE_SOURCE for a single located element), never protocol state, so it survives a fresh
16
+ * session and a fresh CLI process. It does NOT survive navigation or a page re-render, per
17
+ * uidStability "document-stamp".
18
+ *
19
+ * take_snapshot has no BiDi equivalent domain, so ../bidi/snapshot.ts's takeStampedSnapshot walks
20
+ * the DOM in-page and returns a pre-formatted indented text tree (the same line grammar
21
+ * ../tools/snapshot.ts and formatSnapshotLine use). This file's job is the adapter: run that
22
+ * function through script.callFunction and PARSE its text back into SnapshotNode[], which is
23
+ * genuinely new integration work, not a reimplementation of the walker itself.
24
+ */
25
+ import type { TargetSelector } from "../types.ts";
26
+ import {
27
+ UID_STAMP_ATTR, isDriverError,
28
+ type BrowserDriver, type Capability, type HandledDialogInfo, type DriverError, type DriverErrorCode, type DriverEvent,
29
+ type DriverUid, type ElementLocator, type EmulationOptions, type InterceptRule, type KeyPress, type LifetimeModel,
30
+ type MouseButtonOptions, type NavigateOptions, type NavigateResult, type PageDriver, type PageInfo,
31
+ type ScreenshotOptions, type SnapshotNode, type UidStability,
32
+ } from "../driver.ts";
33
+ import { assertLeaseOk } from "../leases.ts";
34
+ import { BidiConnection, BidiError, connectBidiSession } from "./client.ts";
35
+ import { takeStampedSnapshot } from "./snapshot.ts";
36
+ import { selectRule, buildFulfillParams, effectiveAction } from "../tools/network_mock.ts";
37
+ import type {
38
+ BrowsingContextId, BrowsingContextInfo, BrowsingContextLocator, BrowsingContextReadinessState,
39
+ BrowsingContextCaptureScreenshotParameters, ScriptSharedReference, ScriptRemoteValue, ScriptLocalValue,
40
+ ScriptNodeRemoteValue, ScriptExceptionDetails, InputSourceActions, InputPointerSourceAction, InputKeySourceAction,
41
+ } from "./protocol.ts";
42
+
43
+ /* ------------------------------ error + uid codec ------------------------------ */
44
+ function driverError(code: DriverErrorCode, message: string, data?: unknown): DriverError {
45
+ return Object.assign(new Error(message), { code, ...(data !== undefined ? { data } : {}) }) as DriverError;
46
+ }
47
+ function encodeUid(stamp: string): DriverUid {
48
+ return `bidi:${stamp}`;
49
+ }
50
+ function decodeStamp(uid: DriverUid): string {
51
+ if (!uid.startsWith("bidi:")) throw driverError("foreign-uid", `uid is not owned by the bidi driver: ${uid}`);
52
+ const payload = uid.slice(5);
53
+ if (!payload.length) throw driverError("foreign-uid", `malformed bidi uid payload: ${uid}`);
54
+ return payload;
55
+ }
56
+ function cssEscape(s: string): string {
57
+ return s.replace(/["\\]/g, (c) => `\\${c}`);
58
+ }
59
+ /** Maps a thrown BidiError (or anything else) onto the neutral DriverErrorCode vocabulary. */
60
+ function mapBidiError(e: unknown): DriverError {
61
+ if (e instanceof BidiError) {
62
+ const code = e.code;
63
+ if (code === "no such node" || code === "no such element") return driverError("no-such-element", e.message);
64
+ if (code === "no such frame") return driverError("no-such-target", e.message);
65
+ if (code === "invalid session id") return driverError("disconnected", e.message);
66
+ if (code === "unsupported operation") return driverError("unsupported", e.message);
67
+ if (!code && /timed out/i.test(e.message)) return driverError("timeout", e.message);
68
+ if (!code && /(disposed|not open|connection closed)/i.test(e.message)) return driverError("disconnected", e.message);
69
+ return driverError("page-error", e.message);
70
+ }
71
+ return driverError("page-error", e instanceof Error ? e.message : String(e));
72
+ }
73
+
74
+ /* ---------------------------- script value (de)serialization ---------------------------- */
75
+ function serializeArg(v: unknown): ScriptLocalValue {
76
+ if (v === undefined) return { type: "undefined" };
77
+ if (v === null) return { type: "null" };
78
+ if (typeof v === "string") return { type: "string", value: v };
79
+ if (typeof v === "number") return { type: "number", value: Number.isNaN(v) ? "NaN" : v };
80
+ if (typeof v === "boolean") return { type: "boolean", value: v };
81
+ if (Array.isArray(v)) return { type: "array", value: v.map(serializeArg) };
82
+ if (typeof v === "object") return { type: "object", value: Object.entries(v as Record<string, unknown>).map(([k, val]) => [k, serializeArg(val)]) };
83
+ return { type: "string", value: String(v) };
84
+ }
85
+ /** Inverse of serializeArg for the subset of ScriptRemoteValue shapes our tools actually produce. */
86
+ function deserializeRemote(rv: ScriptRemoteValue): unknown {
87
+ switch (rv.type) {
88
+ case "undefined": return undefined;
89
+ case "null": return null;
90
+ case "string": return rv.value;
91
+ case "boolean": return rv.value;
92
+ case "bigint": return rv.value;
93
+ case "number": return typeof rv.value === "number" ? rv.value : rv.value;
94
+ case "array": return (rv.value ?? []).map(deserializeRemote);
95
+ case "object": {
96
+ const out: Record<string, unknown> = {};
97
+ for (const [k, v] of rv.value ?? []) out[typeof k === "string" ? k : String(deserializeRemote(k))] = deserializeRemote(v);
98
+ return out;
99
+ }
100
+ case "node": return { bidiSharedId: rv.sharedId };
101
+ default: return undefined;
102
+ }
103
+ }
104
+ function exceptionMessage(d: ScriptExceptionDetails): string {
105
+ if (d.text) return d.text;
106
+ const ex = deserializeRemote(d.exception);
107
+ return typeof ex === "string" ? ex : "evaluation threw";
108
+ }
109
+
110
+ /* -------------------------------- snapshot line parsing -------------------------------- */
111
+ // Mirrors formatSnapshotLine's grammar in ./snapshot.ts: `<indent>[<uid>] <role>[ "<name>"][ [<extras>]]`.
112
+ const LINE_RE = /^((?: {2})*)\[([0-9a-f]+)\] ([a-zA-Z_][\w-]*)(?: ("(?:[^"\\]|\\.)*"))?(?: \[(.*)\])?$/;
113
+ function splitExtras(s: string): string[] {
114
+ const out: string[] = [];
115
+ let cur = "";
116
+ let inQuotes = false;
117
+ for (let i = 0; i < s.length; i++) {
118
+ const ch = s[i]!;
119
+ if (ch === '"' && s[i - 1] !== "\\") inQuotes = !inQuotes;
120
+ if (ch === " " && !inQuotes) { if (cur) out.push(cur); cur = ""; } else cur += ch;
121
+ }
122
+ if (cur) out.push(cur);
123
+ return out;
124
+ }
125
+ function parseSnapshotLine(line: string): SnapshotNode | undefined {
126
+ const m = LINE_RE.exec(line);
127
+ if (!m) return undefined;
128
+ const indent = m[1] ?? "", stamp = m[2]!, role = m[3]!, nameLit = m[4], extrasRaw = m[5];
129
+ const depth = indent.length / 2;
130
+ let name: string | undefined;
131
+ if (nameLit) {
132
+ try { name = JSON.parse(nameLit) as string; } catch { name = nameLit; }
133
+ }
134
+ const extras: Record<string, string> = {};
135
+ if (extrasRaw) {
136
+ for (const tok of splitExtras(extrasRaw)) {
137
+ const eq = tok.indexOf("=");
138
+ if (eq === -1) { extras[tok] = "true"; continue; }
139
+ const key = tok.slice(0, eq);
140
+ let val = tok.slice(eq + 1);
141
+ if (val.startsWith('"')) { try { val = JSON.parse(val) as string; } catch { /* keep raw */ } }
142
+ extras[key] = val;
143
+ }
144
+ }
145
+ return { uid: encodeUid(stamp), role, depth, ...(name !== undefined ? { name } : {}), ...(Object.keys(extras).length ? { extras } : {}) };
146
+ }
147
+ const INTERACTIVE_ROLES = new Set<string>([
148
+ "button", "link", "textbox", "searchbox", "checkbox", "radio", "combobox",
149
+ "listbox", "option", "slider", "spinbutton", "progressbar", "meter",
150
+ ]);
151
+ // Stamps ONE already-located element (locate()'s mint path), reusing an existing stamp per the
152
+ // same rule ../bidi/snapshot.ts's nextStamp()/stampOf() apply during a full-tree walk. Payload is
153
+ // two padded random hex blocks (8+4), matching THE UID CODEC's documented exactly-12-lowercase-
154
+ // hex-char bidi payload grammar exactly and the same padding technique nextStamp() now uses.
155
+ const STAMP_ONE_SOURCE = `function(){var a=${JSON.stringify(UID_STAMP_ATTR)};var e=this.getAttribute(a);if(e)return e;var s=Math.floor(Math.random()*0x100000000).toString(16).padStart(8,"0")+Math.floor(Math.random()*0x10000).toString(16).padStart(4,"0");this.setAttribute(a,s);return s;}`;
156
+
157
+ /* ------------------------------- element locator resolution ------------------------------- */
158
+ async function locateNodes(conn: BidiConnection, contextId: BrowsingContextId, locator: BrowsingContextLocator): Promise<ScriptNodeRemoteValue[]> {
159
+ try {
160
+ const { nodes } = await conn.send("browsingContext.locateNodes", { context: contextId, locator, maxNodeCount: 1 });
161
+ return nodes;
162
+ } catch (e) {
163
+ throw mapBidiError(e);
164
+ }
165
+ }
166
+ function sharedRefOf(node: ScriptNodeRemoteValue, describe: string): ScriptSharedReference {
167
+ if (!node.sharedId) throw driverError("page-error", `located node has no sharedId (${describe})`);
168
+ return { sharedId: node.sharedId };
169
+ }
170
+ /** THE UID CODEC: "More than one match MUST take the first node in document order", which is
171
+ * what locateNodes returns nodes in; we always ask for maxNodeCount 1 so there is only one to take. */
172
+ async function resolveElementLocator(conn: BidiConnection, contextId: BrowsingContextId, loc: ElementLocator): Promise<ScriptSharedReference> {
173
+ if ("uid" in loc) {
174
+ const stamp = decodeStamp(loc.uid);
175
+ const nodes = await locateNodes(conn, contextId, { type: "css", value: `[${UID_STAMP_ATTR}="${cssEscape(stamp)}"]` });
176
+ if (!nodes.length) throw driverError("stale-uid", `uid does not resolve: ${loc.uid}`);
177
+ return sharedRefOf(nodes[0]!, loc.uid);
178
+ }
179
+ if ("css" in loc) {
180
+ const nodes = await locateNodes(conn, contextId, { type: "css", value: loc.css });
181
+ if (!nodes.length) throw driverError("no-such-element", `css '${loc.css}' matched no element`);
182
+ return sharedRefOf(nodes[0]!, `css '${loc.css}'`);
183
+ }
184
+ if ("xpath" in loc) {
185
+ const nodes = await locateNodes(conn, contextId, { type: "xpath", value: loc.xpath });
186
+ if (!nodes.length) throw driverError("no-such-element", `xpath '${loc.xpath}' matched no element`);
187
+ return sharedRefOf(nodes[0]!, `xpath '${loc.xpath}'`);
188
+ }
189
+ const nodes = await locateNodes(conn, contextId, { type: "innerText", value: loc.text, matchType: "partial" });
190
+ if (!nodes.length) throw driverError("no-such-element", `text '${loc.text}' matched no element`);
191
+ return sharedRefOf(nodes[0]!, `text '${loc.text}'`);
192
+ }
193
+
194
+ /* ---------------------------------- keys ---------------------------------- */
195
+ // WebDriver normalized key values (Unicode PUA), the BiDi-side counterpart of CDP's NAMED_KEYS.
196
+ const KEY_VALUES: Record<string, string> = {
197
+ enter: "\uE007", return: "\uE007", tab: "\uE004", escape: "\uE00C", esc: "\uE00C",
198
+ backspace: "\uE003", delete: "\uE017", space: " ",
199
+ arrowup: "\uE013", arrowdown: "\uE015", arrowleft: "\uE012", arrowright: "\uE014",
200
+ up: "\uE013", down: "\uE015", left: "\uE012", right: "\uE014",
201
+ home: "\uE011", end: "\uE010", pageup: "\uE00E", pagedown: "\uE00F",
202
+ };
203
+ const MODIFIER_VALUES: Record<string, string> = { shift: "\uE008", control: "\uE009", ctrl: "\uE009", alt: "\uE00A", meta: "\uE03D", cmd: "\uE03D", command: "\uE03D" };
204
+ const BUTTON_CODES: Record<"left" | "right" | "middle", number> = { left: 0, middle: 1, right: 2 };
205
+
206
+ /**
207
+ * Firefox 153 capabilities. Only what was empirically verified is declared: emulate.deviceMetrics
208
+ * (browsingContext.setViewport) and the userAgent path (universal, no capability token) both work.
209
+ * emulate.mediaFeatures, emulate.cpuThrottling, emulate.networkConditions, trace.performance,
210
+ * heap.snapshot, audit.lighthouse, input.insertTextAtomic and snapshot.accessibilityTree are all
211
+ * deliberately absent, several because Firefox 153 does not implement the underlying BiDi command
212
+ * at all (see protocol.ts's "Firefox 153: not implemented" tags), the rest because this driver has
213
+ * no accessibility-tree source or CPU-multiplier primitive to offer. locate.xpath rides
214
+ * browsingContext.locateNodes({type:"xpath"}), verified working. locate.text is NOT declared:
215
+ * locateNodes({type:"innerText"}) throws "locator.type argument with value: innerText is not
216
+ * supported yet" on real Firefox 153.0.3, contradicting the initial assumption that both text and
217
+ * xpath locators were live; ElementLocator's `text` branch still resolves and correctly surfaces
218
+ * "unsupported" rather than silently misbehaving, it is simply not offered as a capability.
219
+ * network.intercept rides network.addIntercept + network.beforeRequestSent, verified working
220
+ * (see intercept()'s comment on why urlPatterns is never populated). screenshot.fullPage uses
221
+ * captureScreenshot's origin:"document", screenshot.element uses its clip:{type:"element"}, both verified.
222
+ */
223
+ const BIDI_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
224
+ "emulate.deviceMetrics", "screenshot.fullPage", "screenshot.element", "network.intercept", "locate.xpath",
225
+ ]);
226
+
227
+ /* --------------------------------- module-scope session memoization --------------------------------- */
228
+ // "session" LifetimeModel: ONE BidiConnection per port, shared by every page() acquisition on that
229
+ // port. getConnection retains a holder on every call (fresh or reused); callers release() it.
230
+ const connections = new Map<number, BidiConnection>();
231
+ async function getConnection(port: number, timeoutMs?: number): Promise<BidiConnection> {
232
+ const existing = connections.get(port);
233
+ if (existing) { existing.retain(); return existing; }
234
+ // Firefox 153's default unhandledPromptBehavior is "dismiss": a page-blocking alert()/confirm()
235
+ // gets auto-dismissed before handleDialog() ever runs, and Firefox's own userPromptOpened event
236
+ // reports handler:"dismiss" on it (verified: a bare session.new left "no such alert" on the very
237
+ // next handleUserPrompt call). Setting "ignore" here is what makes handleDialog() able to see
238
+ // and answer a live prompt at all.
239
+ const conn = await connectBidiSession(port, {
240
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
241
+ capabilities: { alwaysMatch: { unhandledPromptBehavior: { default: "ignore" } } },
242
+ }); // already retained once
243
+ connections.set(port, conn);
244
+ return conn;
245
+ }
246
+
247
+ /* ----------------------------------- context/page lookup ----------------------------------- */
248
+ async function safeTitle(conn: BidiConnection, contextId: BrowsingContextId): Promise<string> {
249
+ try {
250
+ const res = await conn.send("script.evaluate", { expression: "document.title", target: { context: contextId }, awaitPromise: false });
251
+ return res.type === "success" && res.result.type === "string" ? res.result.value : "";
252
+ } catch {
253
+ return "";
254
+ }
255
+ }
256
+ async function pageInfoOf(conn: BidiConnection, ctx: BrowsingContextInfo): Promise<PageInfo> {
257
+ return { id: ctx.context, url: ctx.url, title: await safeTitle(conn, ctx.context) };
258
+ }
259
+ /** Same TargetSelector grammar as client.ts's resolveTarget, but resolved entirely over BiDi
260
+ * (browsingContext.getTree): there is no HTTP discovery endpoint here, per client.ts's own footer.
261
+ *
262
+ * THE Firefox choke point for tab leases. Keyed "firefox", never a bare id: a CDP targetId and a
263
+ * BiDi context id are not disjoint by construction, so one bare key space could produce a false
264
+ * collision between an unrelated Chrome tab and a Firefox context. Note this backend needs the
265
+ * lease far less than Chrome does (backend.ts: every MCP server process launches its OWN Firefox,
266
+ * so two sessions cannot race over one instance the way they do over Chrome on port 9222). It is
267
+ * here for consistency and for a future shared-instance setup, not to close an observed gap. */
268
+ async function resolveContext(
269
+ conn: BidiConnection,
270
+ selector: TargetSelector,
271
+ opts: { lease?: string } = {},
272
+ ): Promise<BrowsingContextInfo> {
273
+ const { contexts } = await conn.send("browsingContext.getTree", {}).catch((e) => { throw mapBidiError(e); });
274
+ if (!contexts.length) throw driverError("no-such-target", "no browsing contexts available");
275
+ const hit = await pickContext(conn, contexts, selector);
276
+ // liveIds deliberately not passed: hit is by construction a member of contexts, so target-gone
277
+ // could never fire for the context just resolved (see leases.ts's assertLeaseOk callers).
278
+ await assertLeaseOk("firefox", hit.context, { lease: opts.lease, url: hit.url });
279
+ return hit;
280
+ }
281
+
282
+ async function pickContext(
283
+ conn: BidiConnection,
284
+ contexts: BrowsingContextInfo[],
285
+ selector: TargetSelector,
286
+ ): Promise<BrowsingContextInfo> {
287
+ if (selector === undefined || selector === "active") return contexts[0]!;
288
+ if (selector.startsWith("index:")) {
289
+ const t = contexts[Number(selector.slice(6))];
290
+ if (!t) throw driverError("no-such-target", `no context at ${selector}`);
291
+ return t;
292
+ }
293
+ if (selector.startsWith("url:")) {
294
+ const needle = selector.slice(4);
295
+ const t = contexts.find((c) => c.url.includes(needle));
296
+ if (!t) throw driverError("no-such-target", `no context with url containing '${needle}'`);
297
+ return t;
298
+ }
299
+ if (selector.startsWith("title:")) {
300
+ const needle = selector.slice(6);
301
+ for (const c of contexts) if ((await safeTitle(conn, c.context)).includes(needle)) return c;
302
+ throw driverError("no-such-target", `no context with title containing '${needle}'`);
303
+ }
304
+ const exact = contexts.find((c) => c.context === selector);
305
+ if (exact) return exact;
306
+ throw driverError("no-such-target", `no context matching '${selector}'`);
307
+ }
308
+
309
+ /* ---------------------------------- PageDriver ---------------------------------- */
310
+ class BidiPageDriver implements PageDriver {
311
+ private released = false;
312
+ private bodyCollector?: string;
313
+ constructor(private readonly conn: BidiConnection, private readonly contextId: BrowsingContextId, readonly info: PageInfo, readonly browser: BrowserDriver) {}
314
+
315
+ async navigate(opts: NavigateOptions): Promise<NavigateResult> {
316
+ const reload = opts.reload === true;
317
+ if (!reload && !opts.url) throw driverError("page-error", "navigate: 'url' is required unless reload:true");
318
+ const waitUntil = opts.waitUntil ?? "load";
319
+ const wait: BrowsingContextReadinessState = waitUntil === "domcontentloaded" ? "interactive" : "complete";
320
+ const sendOpts = opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {};
321
+ try {
322
+ if (reload) {
323
+ const r = await this.conn.send("browsingContext.reload", { context: this.contextId, ignoreCache: opts.ignoreCache === true, wait }, sendOpts);
324
+ return { url: r.url, contextId: this.contextId, reloaded: true, waitedFor: waitUntil };
325
+ }
326
+ const r = await this.conn.send("browsingContext.navigate", { context: this.contextId, url: opts.url as string, wait }, sendOpts);
327
+ return { url: r.url, contextId: this.contextId, waitedFor: waitUntil };
328
+ } catch (e) {
329
+ // NavigateResult.waitedFor's "committed"/"timeout" values assume CDP's fire-and-forget
330
+ // navigate plus separate milestone events (see cdp/driver.ts's own comment on this). BiDi's
331
+ // navigate blocks server-side until `wait` is satisfied, so under this driver there is no
332
+ // soft "timeout" return: an unreachable milestone surfaces as a thrown timeout error instead.
333
+ throw mapBidiError(e);
334
+ }
335
+ }
336
+ async waitForText(text: string, timeoutMs = 15_000, pollMs = 250): Promise<{ found: true; elapsedMs: number }> {
337
+ const start = Date.now();
338
+ const expr = `(() => { const b = document.body; return !!b && typeof b.innerText === 'string' && b.innerText.includes(${JSON.stringify(text)}); })()`;
339
+ for (;;) {
340
+ const found = await this.conn.send("script.evaluate", { expression: expr, target: { context: this.contextId }, awaitPromise: true })
341
+ .then((r) => r.type === "success" && deserializeRemote(r.result) === true)
342
+ .catch(() => false);
343
+ if (found) return { found: true, elapsedMs: Date.now() - start };
344
+ if (Date.now() - start >= timeoutMs) throw driverError("timeout", `waitForText: '${text}' not found within ${timeoutMs}ms`);
345
+ await new Promise((r) => setTimeout(r, Math.min(pollMs, Math.max(0, timeoutMs - (Date.now() - start)))));
346
+ }
347
+ }
348
+
349
+ private async callFunctionValue(functionDeclaration: string, args: unknown[], thisRef?: ScriptSharedReference): Promise<unknown> {
350
+ try {
351
+ const res = await this.conn.send("script.callFunction", {
352
+ functionDeclaration, awaitPromise: true, target: { context: this.contextId },
353
+ arguments: args.map(serializeArg), ...(thisRef ? { this: thisRef as ScriptLocalValue } : {}),
354
+ });
355
+ if (res.type === "exception") throw driverError("page-error", exceptionMessage(res.exceptionDetails));
356
+ return deserializeRemote(res.result);
357
+ } catch (e) {
358
+ throw isDriverError(e) ? e : mapBidiError(e);
359
+ }
360
+ }
361
+ async evaluate(expression: string, opts?: { args?: readonly unknown[]; awaitPromise?: boolean }): Promise<unknown> {
362
+ const awaitPromise = opts?.awaitPromise ?? true;
363
+ if (opts?.args && opts.args.length > 0) return this.callFunctionValue(expression, [...opts.args]);
364
+ try {
365
+ const res = await this.conn.send("script.evaluate", { expression, awaitPromise, target: { context: this.contextId } });
366
+ if (res.type === "exception") throw driverError("page-error", exceptionMessage(res.exceptionDetails));
367
+ return deserializeRemote(res.result);
368
+ } catch (e) {
369
+ throw isDriverError(e) ? e : mapBidiError(e);
370
+ }
371
+ }
372
+ async callOnElement(loc: ElementLocator, functionDeclaration: string, args: readonly unknown[] = []): Promise<unknown> {
373
+ const ref = await resolveElementLocator(this.conn, this.contextId, loc);
374
+ return this.callFunctionValue(functionDeclaration, [...args], ref);
375
+ }
376
+
377
+ async snapshot(opts?: { interactiveOnly?: boolean }): Promise<SnapshotNode[]> {
378
+ const { snapshot } = await takeStampedSnapshot((src) => this.callFunctionValue(src, []));
379
+ const nodes: SnapshotNode[] = [];
380
+ for (const line of snapshot.split("\n")) {
381
+ if (!line) continue;
382
+ const n = parseSnapshotLine(line);
383
+ if (n) nodes.push(n);
384
+ }
385
+ return opts?.interactiveOnly ? nodes.filter((n) => INTERACTIVE_ROLES.has(n.role)) : nodes;
386
+ }
387
+ async locate(loc: ElementLocator): Promise<DriverUid> {
388
+ if ("uid" in loc) { await this.resolve(loc.uid); return loc.uid; }
389
+ const ref = await resolveElementLocator(this.conn, this.contextId, loc);
390
+ const stamp = await this.callFunctionValue(STAMP_ONE_SOURCE, [], ref);
391
+ if (typeof stamp !== "string") throw driverError("page-error", "stamping the located element did not return a string");
392
+ return encodeUid(stamp);
393
+ }
394
+ async resolve(uid: DriverUid): Promise<void> {
395
+ const stamp = decodeStamp(uid);
396
+ const nodes = await locateNodes(this.conn, this.contextId, { type: "css", value: `[${UID_STAMP_ATTR}="${cssEscape(stamp)}"]` });
397
+ if (!nodes.length) throw driverError("stale-uid", `uid does not resolve: ${uid}`);
398
+ }
399
+
400
+ private async performActions(actions: InputSourceActions[]): Promise<void> {
401
+ try {
402
+ await this.conn.send("input.performActions", { context: this.contextId, actions });
403
+ } catch (e) {
404
+ throw mapBidiError(e);
405
+ }
406
+ }
407
+ // Scroll into view + read the viewport-space center, mirroring cdp/driver.ts's centerOf.
408
+ private async centerOf(ref: ScriptSharedReference): Promise<{ x: number; y: number }> {
409
+ 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};}";
410
+ const val = await this.callFunctionValue(fn, [], ref);
411
+ if (!val || typeof val !== "object") throw driverError("page-error", "element has zero size / is not visible; cannot compute a click point");
412
+ return val as { x: number; y: number };
413
+ }
414
+ async hover(loc: ElementLocator): Promise<{ x: number; y: number }> {
415
+ const ref = await resolveElementLocator(this.conn, this.contextId, loc);
416
+ const { x, y } = await this.centerOf(ref);
417
+ await this.performActions([{ type: "pointer", id: "cdp-mouse", actions: [{ type: "pointerMove", x, y, duration: 0 }] }]);
418
+ return { x, y };
419
+ }
420
+ async click(loc: ElementLocator, opts?: MouseButtonOptions): Promise<{ x: number; y: number }> {
421
+ const ref = await resolveElementLocator(this.conn, this.contextId, loc);
422
+ const { x, y } = await this.centerOf(ref);
423
+ const button = BUTTON_CODES[opts?.button ?? "left"];
424
+ const actions: InputPointerSourceAction[] = [{ type: "pointerMove", x, y, duration: 0 }];
425
+ for (let i = 0; i < (opts?.clickCount ?? 1); i++) actions.push({ type: "pointerDown", button }, { type: "pointerUp", button });
426
+ await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
427
+ return { x, y };
428
+ }
429
+ async drag(from: ElementLocator, to: ElementLocator): Promise<{ from: { x: number; y: number }; to: { x: number; y: number } }> {
430
+ const fromPt = await this.centerOf(await resolveElementLocator(this.conn, this.contextId, from));
431
+ const toPt = await this.centerOf(await resolveElementLocator(this.conn, this.contextId, to));
432
+ const actions: InputPointerSourceAction[] = [
433
+ { type: "pointerMove", x: fromPt.x, y: fromPt.y, duration: 0 }, { type: "pointerDown", button: 0 },
434
+ { type: "pointerMove", x: (fromPt.x + toPt.x) / 2, y: (fromPt.y + toPt.y) / 2, duration: 50 },
435
+ { type: "pointerMove", x: toPt.x, y: toPt.y, duration: 50 }, { type: "pointerUp", button: 0 },
436
+ ];
437
+ await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
438
+ return { from: fromPt, to: toPt };
439
+ }
440
+ // No atomic value-commit primitive over BiDi (established fact): every key goes through
441
+ // input.performActions, batched into ONE call regardless of string length, so setValue/typeText
442
+ // cost exactly 2 round trips (one evaluate to focus/clear, one performActions for the keys).
443
+ private async sendKeys(text: string): Promise<void> {
444
+ if (!text.length) return;
445
+ const actions: InputKeySourceAction[] = [];
446
+ for (const ch of text) actions.push({ type: "keyDown", value: ch }, { type: "keyUp", value: ch });
447
+ await this.performActions([{ type: "key", id: "cdp-keyboard", actions }]);
448
+ }
449
+ // <select> is not text input: "synthesized keystrokes" (typeahead-by-first-letter) cannot
450
+ // reliably commit an exact option, so a SELECT element gets a direct value/selectedIndex
451
+ // assignment plus input+change events, the same effective outcome a real option click has.
452
+ // Every other element still goes through focus+clear+sendKeys, honestly synthesized keystrokes.
453
+ private static readonly SELECT_SOURCE = "function(v){var found=false;for(var i=0;i<this.options.length;i++){if(this.options[i].value===v){this.selectedIndex=i;found=true;break;}}if(!found)this.value=v;this.dispatchEvent(new Event('input',{bubbles:true}));this.dispatchEvent(new Event('change',{bubbles:true}));}";
454
+ // Contenteditable is the one element class where synthesized keystrokes do nothing at all.
455
+ // Measured against Firefox 153.0.3, not inferred:
456
+ // - The raw key route (sendKeys -> input.performActions) fires keydown and keyup on a
457
+ // contenteditable target and NOTHING else: no beforeinput, no input, no DOM mutation.
458
+ // That is why fill/type_text silently no-opped there before this branch existed.
459
+ // - document.execCommand('insertText', ...) performs a real native edit, but ONLY with an
460
+ // explicit Range/Selection set first. focus() alone is the discriminator: with focus and
461
+ // no selection anchor execCommand returns false and nothing happens; with the Range
462
+ // installed it returns true, the DOM mutates, and a real 'input' event fires. Both cases
463
+ // were probed separately, so this is a proven precondition and not defensive belt-and-braces.
464
+ // - This route does NOT fire 'beforeinput'. Checked at element level AND at document level in
465
+ // the capture phase, isTrusted included, so it is not a listener-placement artifact;
466
+ // queryCommandSupported('insertText') stays true throughout. Chrome's path (CDP
467
+ // Input.insertText) DOES fire a trusted beforeinput with correct inputType/data before
468
+ // 'input'. That asymmetry matters for rich editors (Lexical, ProseMirror, Slate) that build
469
+ // their model from beforeinput: on Firefox they will not observe this edit and can desync.
470
+ // Plain, unmanaged contenteditable, which is what fill/type_text's contract promises, is fine.
471
+ // - Embedded '\n' is silently dropped by this route. It is dropped on Chrome's Input.insertText
472
+ // path too, so multi-line contenteditable insertion is a shared gap, not a Firefox one.
473
+ // Deliberately NOT capability-gated: see the footer comment for why no token was added.
474
+ private static readonly INSERT_TEXT_SOURCE =
475
+ "function(v,collapseAtEnd){this.focus();var r=document.createRange();" +
476
+ "r.selectNodeContents(this);if(collapseAtEnd)r.collapse(false);" +
477
+ "var s=window.getSelection();s.removeAllRanges();s.addRange(r);" +
478
+ "return document.execCommand('insertText', false, v);}";
479
+ private static readonly IS_CONTENT_EDITABLE = "function(){return this.isContentEditable===true;}";
480
+ async setValue(loc: ElementLocator, value: string): Promise<void> {
481
+ const ref = await resolveElementLocator(this.conn, this.contextId, loc);
482
+ const isSelect = await this.callFunctionValue("function(){return this.tagName==='SELECT';}", [], ref);
483
+ if (isSelect === true) {
484
+ await this.callFunctionValue(BidiPageDriver.SELECT_SOURCE, [value], ref);
485
+ return;
486
+ }
487
+ const isContentEditable = await this.callFunctionValue(BidiPageDriver.IS_CONTENT_EDITABLE, [], ref);
488
+ if (isContentEditable === true) {
489
+ // collapseAtEnd=false: the Range spans the whole node, so the native insert REPLACES the
490
+ // existing contents in one edit. Do not pre-clear via textContent: that mutation is not a
491
+ // native edit, and it also destroys the selection the execCommand call depends on.
492
+ await this.callFunctionValue(BidiPageDriver.INSERT_TEXT_SOURCE, [value, false], ref);
493
+ return;
494
+ }
495
+ const clear = "function(){this.focus&&this.focus();if('value' in this){this.value='';this.dispatchEvent(new Event('input',{bubbles:true}));}}";
496
+ await this.callFunctionValue(clear, [], ref);
497
+ await this.sendKeys(value);
498
+ }
499
+ async typeText(loc: ElementLocator, text: string): Promise<void> {
500
+ const ref = await resolveElementLocator(this.conn, this.contextId, loc);
501
+ const isContentEditable = await this.callFunctionValue(BidiPageDriver.IS_CONTENT_EDITABLE, [], ref);
502
+ if (isContentEditable === true) {
503
+ // collapseAtEnd=true: caret at the end of the existing contents, so typeText appends.
504
+ await this.callFunctionValue(BidiPageDriver.INSERT_TEXT_SOURCE, [text, true], ref);
505
+ return;
506
+ }
507
+ await this.callFunctionValue("function(){this.focus&&this.focus();}", [], ref);
508
+ await this.sendKeys(text);
509
+ }
510
+ async pressKey(press: KeyPress): Promise<void> {
511
+ const value = KEY_VALUES[press.key.toLowerCase()] ?? (press.key.length === 1 ? press.key : undefined);
512
+ if (value === undefined) throw driverError("page-error", `unknown key '${press.key}' (use a named key like Enter/Tab/ArrowDown or a single character)`);
513
+ const mods: string[] = [];
514
+ for (const m of press.modifiers ?? []) {
515
+ const v = MODIFIER_VALUES[m.toLowerCase()];
516
+ if (v === undefined) throw driverError("page-error", `unknown modifier '${m}'`);
517
+ mods.push(v);
518
+ }
519
+ const actions: InputKeySourceAction[] = [...mods.map((v): InputKeySourceAction => ({ type: "keyDown", value: v })), { type: "keyDown", value }, { type: "keyUp", value }, ...[...mods].reverse().map((v): InputKeySourceAction => ({ type: "keyUp", value: v }))];
520
+ await this.performActions([{ type: "key", id: "cdp-keyboard", actions }]);
521
+ }
522
+ async setFiles(loc: ElementLocator, files: readonly string[]): Promise<void> {
523
+ const ref = await resolveElementLocator(this.conn, this.contextId, loc);
524
+ try {
525
+ await this.conn.send("input.setFiles", { context: this.contextId, element: ref, files: [...files] });
526
+ } catch (e) {
527
+ throw mapBidiError(e);
528
+ }
529
+ }
530
+
531
+ async screenshot(opts?: ScreenshotOptions): Promise<{ data: Uint8Array; format: "png" | "jpeg" }> {
532
+ const format = opts?.format ?? "png";
533
+ const params: BrowsingContextCaptureScreenshotParameters = {
534
+ context: this.contextId,
535
+ format: { type: format === "jpeg" ? "image/jpeg" : "image/png", ...(format === "jpeg" ? { quality: (opts?.quality ?? 80) / 100 } : {}) },
536
+ };
537
+ if (opts?.clip) params.clip = { type: "element", element: await resolveElementLocator(this.conn, this.contextId, opts.clip) };
538
+ else if (opts?.fullPage) params.origin = "document";
539
+ try {
540
+ const { data } = await this.conn.send("browsingContext.captureScreenshot", params);
541
+ return { data: new Uint8Array(Buffer.from(data, "base64")), format };
542
+ } catch (e) {
543
+ throw mapBidiError(e);
544
+ }
545
+ }
546
+ // Only userAgent and width/height/deviceScaleFactor are applied: mediaFeatures, cpuThrottling
547
+ // and networkConditions are not in BIDI_CAPABILITIES (see its comment) and this method never
548
+ // claims them. `mobile` has no BiDi viewport-emulation equivalent and is silently ignored,
549
+ // a real parity gap noted in the footer rather than smoothed over.
550
+ async emulate(opts: EmulationOptions): Promise<{ applied: string[] }> {
551
+ if (opts.clearOverrides) {
552
+ await this.conn.send("emulation.setUserAgentOverride", { userAgent: null, contexts: [this.contextId] }).catch(() => undefined);
553
+ await this.conn.send("browsingContext.setViewport", { context: this.contextId, viewport: null, devicePixelRatio: null }).catch(() => undefined);
554
+ return { applied: [] };
555
+ }
556
+ const applied: string[] = [];
557
+ try {
558
+ if (opts.width != null && opts.height != null) {
559
+ await this.conn.send("browsingContext.setViewport", { context: this.contextId, viewport: { width: opts.width, height: opts.height }, ...(opts.deviceScaleFactor != null ? { devicePixelRatio: opts.deviceScaleFactor } : {}) });
560
+ applied.push("deviceMetrics");
561
+ }
562
+ if (opts.userAgent != null) {
563
+ await this.conn.send("emulation.setUserAgentOverride", { userAgent: opts.userAgent, contexts: [this.contextId] });
564
+ applied.push("userAgent");
565
+ }
566
+ } catch (e) {
567
+ throw mapBidiError(e);
568
+ }
569
+ return { applied };
570
+ }
571
+
572
+ private static readonly EVENT_MAP: Record<DriverEvent, string> = {
573
+ navigated: "browsingContext.navigationCommitted", console: "log.entryAdded",
574
+ "network.request": "network.beforeRequestSent", "network.response": "network.responseCompleted",
575
+ dialog: "browsingContext.userPromptOpened", "page.closed": "browsingContext.contextDestroyed",
576
+ };
577
+ // log.entryAdded carries no top-level `context` field (it nests under source.context), so console
578
+ // events are passed through unfiltered rather than dropped; every other mapped event does carry
579
+ // `context` and is filtered to this page. Payload shapes are BiDi-native, NOT CDP's Runtime.
580
+ // consoleAPICalled/Network.* shapes, a real cross-driver difference callers must not assume away.
581
+ private contextMatches(event: DriverEvent, payload: Record<string, unknown>): boolean {
582
+ if (event === "console") return true;
583
+ return payload.context === undefined || payload.context === this.contextId;
584
+ }
585
+ on(event: DriverEvent, handler: (payload: Record<string, unknown>) => void): () => void {
586
+ // biome-ignore lint: BidiEventName is a literal union our EVENT_MAP values satisfy at runtime.
587
+ return this.conn.on(BidiPageDriver.EVENT_MAP[event] as never, (params) => {
588
+ const p = params as unknown as Record<string, unknown>;
589
+ if (this.contextMatches(event, p)) handler(p);
590
+ });
591
+ }
592
+ async waitForEvent(event: DriverEvent, predicate?: (p: Record<string, unknown>) => boolean, timeoutMs?: number): Promise<Record<string, unknown>> {
593
+ try {
594
+ const p = await this.conn.waitFor(
595
+ BidiPageDriver.EVENT_MAP[event] as never,
596
+ (params) => {
597
+ const p = params as unknown as Record<string, unknown>;
598
+ return this.contextMatches(event, p) && (!predicate || predicate(p));
599
+ },
600
+ timeoutMs,
601
+ );
602
+ return p as unknown as Record<string, unknown>;
603
+ } catch (e) {
604
+ throw mapBidiError(e);
605
+ }
606
+ }
607
+
608
+ private async handleOneDialog(accept: boolean, promptText: string | undefined, timeoutMs: number): Promise<HandledDialogInfo> {
609
+ const opened = await this.conn
610
+ .waitFor("browsingContext.userPromptOpened", (p) => p.context === this.contextId, timeoutMs)
611
+ .catch(() => { throw driverError("timeout", `handleDialog: no dialog opened within ${timeoutMs}ms`); });
612
+ try {
613
+ await this.conn.send("browsingContext.handleUserPrompt", { context: this.contextId, accept, ...(promptText !== undefined ? { userText: promptText } : {}) });
614
+ } catch (e) {
615
+ throw mapBidiError(e);
616
+ }
617
+ return {
618
+ type: opened.type, message: opened.message, url: this.info.url,
619
+ ...(opened.defaultValue !== undefined ? { defaultPrompt: opened.defaultValue } : {}),
620
+ accept, ...(promptText !== undefined ? { promptText } : {}), handled: true,
621
+ };
622
+ }
623
+ /** autoMs: no persistent-listener primitive on this transport, so this polls handleOneDialog with a
624
+ * short per-iteration timeout until the window elapses. A dialog opening in the few-ms gap between
625
+ * iterations could theoretically be missed; Firefox has no prior handle_dialog contract to preserve,
626
+ * so this is an accepted approximation rather than a byte-identical port of anything. */
627
+ async handleDialog(
628
+ accept: boolean,
629
+ promptText?: string,
630
+ opts?: { timeoutMs?: number; autoMs?: number },
631
+ ): Promise<HandledDialogInfo | { handled: HandledDialogInfo[]; count: number }> {
632
+ if (opts?.autoMs !== undefined) {
633
+ const deadline = Date.now() + opts.autoMs;
634
+ const handled: HandledDialogInfo[] = [];
635
+ while (Date.now() < deadline) {
636
+ const remaining = deadline - Date.now();
637
+ if (remaining <= 0) break;
638
+ const d = await this.handleOneDialog(accept, promptText, Math.min(250, remaining)).catch(() => undefined);
639
+ if (d) handled.push(d);
640
+ }
641
+ return { handled, count: handled.length };
642
+ }
643
+ return this.handleOneDialog(accept, promptText, opts?.timeoutMs ?? 15_000);
644
+ }
645
+
646
+ // Response bodies need network.addDataCollector armed BEFORE the request, per the established
647
+ // fact this driver was handed: no way to read a body for a request that happened before
648
+ // capture was armed. That is the mirror of the CDP driver's own honest omission of
649
+ // network.responseBodyPostHoc (see cdp/driver.ts's footer), so this driver makes the same call
650
+ // and does not declare it either.
651
+ async startBodyCapture(): Promise<() => Promise<void>> {
652
+ let collector: string;
653
+ try {
654
+ const res = await this.conn.send("network.addDataCollector", { dataTypes: ["response"], maxEncodedDataSize: 100_000_000, contexts: [this.contextId] });
655
+ collector = res.collector;
656
+ } catch (e) {
657
+ throw mapBidiError(e);
658
+ }
659
+ this.bodyCollector = collector;
660
+ return async () => {
661
+ await this.conn.send("network.removeDataCollector", { collector }).catch(() => undefined);
662
+ if (this.bodyCollector === collector) this.bodyCollector = undefined;
663
+ };
664
+ }
665
+ async getResponseBody(requestId: string): Promise<{ body: string; base64Encoded: boolean }> {
666
+ if (!this.bodyCollector) throw driverError("unsupported", "getResponseBody: call startBodyCapture() first (network.responseBodyPostHoc is not supported)");
667
+ try {
668
+ const { bytes } = await this.conn.send("network.getData", { dataType: "response", collector: this.bodyCollector, request: requestId });
669
+ return bytes.type === "base64" ? { body: bytes.value, base64Encoded: true } : { body: bytes.value, base64Encoded: false };
670
+ } catch (e) {
671
+ throw mapBidiError(e);
672
+ }
673
+ }
674
+ // Reuses ../tools/network_mock.ts's pure logic layer (selectRule/buildFulfillParams/
675
+ // effectiveAction), same as cdp/driver.ts's intercept, mapped onto network.addIntercept's
676
+ // beforeRequestSent phase instead of CDP's Fetch domain.
677
+ async intercept(rules: readonly InterceptRule[]): Promise<() => Promise<void>> {
678
+ let intercept: string;
679
+ try {
680
+ // network.addIntercept's urlPatterns are LITERAL/structured matches (network.UrlPattern),
681
+ // not CDP's glob syntax: passing our `*`-glob urlPattern strings through verbatim throws
682
+ // "unescaped forbidden character *" (verified against Firefox 153.0.3). So every request in
683
+ // this context is intercepted and selectRule()'s own glob matching decides continue vs
684
+ // fulfill vs fail below, exactly as urlMatches() already does for the CDP driver's globs.
685
+ const res = await this.conn.send("network.addIntercept", { phases: ["beforeRequestSent"], contexts: [this.contextId] });
686
+ intercept = res.intercept;
687
+ } catch (e) {
688
+ throw mapBidiError(e);
689
+ }
690
+ const off = this.conn.on("network.beforeRequestSent", (params) => {
691
+ void (async () => {
692
+ const req = params.request;
693
+ const rule = selectRule(rules as Array<InterceptRule & { urlPattern: string }>, req.url, req.method);
694
+ if (!rule) { await this.conn.send("network.continueRequest", { request: req.request }).catch(() => undefined); return; }
695
+ if (rule.delayMs) await new Promise((r) => setTimeout(r, rule.delayMs));
696
+ const act = effectiveAction(rule, Math.random());
697
+ if (act === "fulfill") {
698
+ const built = buildFulfillParams(req.request, rule);
699
+ await this.conn.send("network.provideResponse", {
700
+ request: req.request, statusCode: built.responseCode,
701
+ headers: built.responseHeaders.map((h) => ({ name: h.name, value: { type: "string" as const, value: h.value } })),
702
+ body: { type: "base64" as const, value: built.body },
703
+ }).catch(() => undefined);
704
+ } else if (act === "fail") {
705
+ await this.conn.send("network.failRequest", { request: req.request }).catch(() => undefined);
706
+ } else {
707
+ await this.conn.send("network.continueRequest", { request: req.request }).catch(() => undefined);
708
+ }
709
+ })();
710
+ });
711
+ return async () => {
712
+ off();
713
+ await this.conn.send("network.removeIntercept", { intercept }).catch(() => undefined);
714
+ };
715
+ }
716
+
717
+ async release(): Promise<void> {
718
+ if (this.released) return;
719
+ this.released = true;
720
+ this.conn.release();
721
+ }
722
+ }
723
+
724
+ /* --------------------------------- BrowserDriver --------------------------------- */
725
+ class BidiBrowserDriver implements BrowserDriver {
726
+ readonly scheme = "bidi";
727
+ readonly lifetime: LifetimeModel = "session";
728
+ readonly capabilities = BIDI_CAPABILITIES;
729
+ readonly uidStability: UidStability = "document-stamp";
730
+ readonly snapshotFidelity = "dom-heuristic" as const;
731
+ constructor(private readonly port: number, private readonly timeoutMs?: number) {}
732
+
733
+ async listPages(): Promise<PageInfo[]> {
734
+ const conn = await getConnection(this.port, this.timeoutMs);
735
+ try {
736
+ const { contexts } = await conn.send("browsingContext.getTree", {}).catch((e) => { throw mapBidiError(e); });
737
+ return Promise.all(contexts.map((c) => pageInfoOf(conn, c)));
738
+ } finally {
739
+ conn.release();
740
+ }
741
+ }
742
+ async newPage(url?: string): Promise<PageInfo> {
743
+ const conn = await getConnection(this.port, this.timeoutMs);
744
+ try {
745
+ const { context } = await conn.send("browsingContext.create", { type: "tab" }).catch((e) => { throw mapBidiError(e); });
746
+ // Established fact: navigating the default about:home context to a data: URL is rejected,
747
+ // so a fresh context is created here FIRST and only then navigated, never reused as-is.
748
+ if (url) await conn.send("browsingContext.navigate", { context, url, wait: "complete" }).catch((e) => { throw mapBidiError(e); });
749
+ return { id: context, url: url ?? "about:blank", title: await safeTitle(conn, context) };
750
+ } finally {
751
+ conn.release();
752
+ }
753
+ }
754
+ async closePage(id: string): Promise<{ success: boolean }> {
755
+ const conn = await getConnection(this.port, this.timeoutMs);
756
+ try {
757
+ await conn.send("browsingContext.close", { context: id }).catch((e) => { throw mapBidiError(e); });
758
+ return { success: true };
759
+ } finally {
760
+ conn.release();
761
+ }
762
+ }
763
+ async activatePage(id: string): Promise<PageInfo> {
764
+ const conn = await getConnection(this.port, this.timeoutMs);
765
+ try {
766
+ await conn.send("browsingContext.activate", { context: id }).catch((e) => { throw mapBidiError(e); });
767
+ const ctx = await resolveContext(conn, id);
768
+ return await pageInfoOf(conn, ctx);
769
+ } finally {
770
+ conn.release();
771
+ }
772
+ }
773
+ async page(selector: TargetSelector): Promise<PageDriver> {
774
+ const conn = await getConnection(this.port, this.timeoutMs);
775
+ let ctx: BrowsingContextInfo;
776
+ try {
777
+ ctx = await resolveContext(conn, selector);
778
+ } catch (e) {
779
+ conn.release();
780
+ throw e;
781
+ }
782
+ const info = await pageInfoOf(conn, ctx);
783
+ return new BidiPageDriver(conn, ctx.context, info, this);
784
+ }
785
+ async dispose(): Promise<void> {
786
+ const conn = connections.get(this.port);
787
+ if (!conn) return;
788
+ connections.delete(this.port);
789
+ conn.dispose();
790
+ }
791
+ }
792
+
793
+ /** Construct the BiDi implementation of the frozen Driver contract. `port` is a Firefox debug
794
+ * port already listening (see ./launch.ts's launchFirefox), never 9222/CDP's convention. */
795
+ export function createFirefoxDriver(port: number, opts?: { timeoutMs?: number }): BrowserDriver {
796
+ return new BidiBrowserDriver(port, opts?.timeoutMs);
797
+ }
798
+ // resolveContext is exported for ONE reason: it is the Firefox lease choke
799
+ // point, and a choke point nothing can call directly is a choke point nothing
800
+ // can test. Its gate was deletable with a green suite until test/leases.test.ts
801
+ // could drive it against a stub connection. Not part of the public surface.
802
+ export { BidiBrowserDriver, BidiPageDriver, resolveContext };
803
+
804
+ /* ------------------------------------------------------------------------------
805
+ * BiDi modules/commands used: session (new, subscribe/unsubscribe, via client.ts); browsingContext
806
+ * (create, close, activate, getTree, navigate, reload, locateNodes, captureScreenshot, setViewport,
807
+ * handleUserPrompt); script (evaluate, callFunction); input (performActions, setFiles); network
808
+ * (addDataCollector, removeDataCollector, getData, addIntercept, removeIntercept, continueRequest,
809
+ * provideResponse, failRequest, beforeRequestSent event); emulation (setUserAgentOverride). Events
810
+ * subscribed via BidiConnection.on/waitFor: browsingContext.navigationCommitted/userPromptOpened/
811
+ * contextDestroyed, log.entryAdded, network.beforeRequestSent/responseCompleted.
812
+ *
813
+ * Parity gaps vs cdp/driver.ts, one line each:
814
+ * - locate.text: not declared, unlike CDP (which has it via DOM.performSearch). Firefox 153's
815
+ * browsingContext.locateNodes rejects locator.type "innerText" as not-yet-supported; the
816
+ * ElementLocator `text` branch still resolves and correctly surfaces "unsupported" if called.
817
+ * - snapshot.accessibilityTree: not declared. snapshotFidelity is "dom-heuristic" (a DOM walk in
818
+ * ../bidi/snapshot.ts), not a native a11y-tree dump; Firefox 153 BiDi has no such domain.
819
+ * - input.insertTextAtomic: not declared. There is no atomic value-commit primitive over BiDi.
820
+ * WebDriver BiDi has no input.insertText command at all (input.performActions and
821
+ * input.setFiles are the whole input module), so there is nothing to fall back on. For
822
+ * <input>/<textarea> setValue/typeText synthesize keystrokes via input.performActions (see
823
+ * sendKeys); for contenteditable that route fires keydown/keyup and nothing else, so those
824
+ * take the execCommand('insertText')-with-explicit-Range branch instead (see setValue).
825
+ * - beforeinput on contenteditable: NOT fired under Firefox 153.0.3, while Chrome's
826
+ * Input.insertText path fires a trusted one with correct inputType/data. Verified on both
827
+ * backends at document/capture level. Stated here as a known limit rather than declared as a
828
+ * capability token on purpose: the Capability union exists solely so tools/list can hide a
829
+ * tool an agent must not call (REQUIRED_CAPABILITIES, src/driver.ts), and no tool should
830
+ * REQUIRE this. Gating fill/type_text on it would delete two working tools from Firefox, where
831
+ * they are correct for inputs, textareas, selects and plain contenteditables. A token that
832
+ * gates nothing is dead code shaped like a safety mechanism, and MCP tool descriptions are a
833
+ * static MANIFEST (src/manifest.ts) that tools/list forwards verbatim, filtered by name only
834
+ * (src/mcp.ts), so there is no per-backend description channel to carry the caveat either.
835
+ * - Multi-line contenteditable: an embedded '\n' is silently dropped on BOTH backends, not just
836
+ * this one. Neither insertText route synthesizes a <br> or a block break.
837
+ * - trace.performance / heap.snapshot / audit.lighthouse: not declared, Chrome-only domains with
838
+ * no BiDi equivalent at all.
839
+ * - emulate.mediaFeatures / emulate.cpuThrottling / emulate.networkConditions: not declared.
840
+ * Firefox 153 does not implement setMediaFeaturesOverride; CPU throttling and network condition
841
+ * emulation have no verified-working BiDi path in this environment, so emulate() never claims
842
+ * them and silently ignores the `mobile` flag (no BiDi mobile-viewport emulation exists either).
843
+ * - network.responseBodyPostHoc: not declared, deliberately, mirroring cdp/driver.ts's own call.
844
+ * network.addDataCollector must be armed before the request; there is no reading a body for a
845
+ * request that predates arming, so an honest capability set omits it exactly as Chrome's does.
846
+ * - NavigateResult.waitedFor never returns "timeout": BiDi's navigate/reload block server-side on
847
+ * `wait`, so an unreachable milestone is a thrown "timeout" DriverError, not CDP's soft return.
848
+ * - LifetimeModel's "MUST re-establish itself transparently if the transport dies" is NOT
849
+ * implemented: a dead BiDi socket surfaces as "disconnected" on every subsequent call instead
850
+ * of silently starting a new session underneath the caller. See the top-of-file comment.
851
+ * ---------------------------------------------------------------------------- */