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