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
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
// src/tools/pages.ts
|
|
4
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
5
|
-
import { dirname } from "node:path";
|
|
6
|
-
|
|
7
3
|
// src/client.ts
|
|
8
4
|
var BASE = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
|
|
9
5
|
var DEFAULT_TIMEOUT_MS = Number(process.env.CDP_TIMEOUT_MS ?? 15000);
|
|
@@ -207,191 +203,6 @@ async function withPage(selector, fn, opts = {}) {
|
|
|
207
203
|
}
|
|
208
204
|
}
|
|
209
205
|
|
|
210
|
-
// src/tools/pages.ts
|
|
211
|
-
var STATE_DIR = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
|
|
212
|
-
var SELECTED_FILE = `${STATE_DIR}/selected`;
|
|
213
|
-
function toPageInfo(t) {
|
|
214
|
-
return { id: t.id, url: t.url, title: t.title, type: t.type };
|
|
215
|
-
}
|
|
216
|
-
async function listPages(args = {}) {
|
|
217
|
-
const targets = await listTargets();
|
|
218
|
-
const filtered = args.all ? targets : targets.filter((t) => t.type === "page");
|
|
219
|
-
const pages = filtered.map(toPageInfo);
|
|
220
|
-
return { pages, count: pages.length };
|
|
221
|
-
}
|
|
222
|
-
async function newPage(args = {}) {
|
|
223
|
-
const url = args.url ?? "about:blank";
|
|
224
|
-
const conn = await openBrowser();
|
|
225
|
-
try {
|
|
226
|
-
const { targetId } = await conn.send("Target.createTarget", { url });
|
|
227
|
-
if (!targetId)
|
|
228
|
-
throw new CdpError("Target.createTarget returned no targetId");
|
|
229
|
-
return { targetId, url };
|
|
230
|
-
} finally {
|
|
231
|
-
conn.close();
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
async function closePage(args) {
|
|
235
|
-
if (args.target === undefined || args.target === "") {
|
|
236
|
-
throw new CdpError("close_page requires an explicit target; refusing to guess which page to close");
|
|
237
|
-
}
|
|
238
|
-
const target = await resolveTarget(args.target);
|
|
239
|
-
const conn = await openBrowser();
|
|
240
|
-
try {
|
|
241
|
-
const res = await conn.send("Target.closeTarget", { targetId: target.id });
|
|
242
|
-
return { closed: target.id, success: res.success ?? true };
|
|
243
|
-
} finally {
|
|
244
|
-
conn.close();
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
async function selectPage(args) {
|
|
248
|
-
if (args.target === undefined || args.target === "") {
|
|
249
|
-
throw new CdpError("select_page requires an explicit target");
|
|
250
|
-
}
|
|
251
|
-
const target = await resolveTarget(args.target);
|
|
252
|
-
const conn = await openBrowser();
|
|
253
|
-
try {
|
|
254
|
-
await conn.send("Target.activateTarget", { targetId: target.id });
|
|
255
|
-
} finally {
|
|
256
|
-
conn.close();
|
|
257
|
-
}
|
|
258
|
-
await mkdir(dirname(SELECTED_FILE), { recursive: true });
|
|
259
|
-
await writeFile(SELECTED_FILE, target.id, "utf8");
|
|
260
|
-
return { selected: target.id };
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// src/tools/navigation.ts
|
|
264
|
-
async function navigatePage(args) {
|
|
265
|
-
const reload = args.reload === true;
|
|
266
|
-
if (!reload && (!args.url || typeof args.url !== "string")) {
|
|
267
|
-
throw new CdpError("navigate_page: 'url' is required (or pass reload:true to reload the current page)");
|
|
268
|
-
}
|
|
269
|
-
const waitUntil = args.waitUntil ?? "load";
|
|
270
|
-
return withPage(args.target, async (conn) => {
|
|
271
|
-
await conn.send("Page.enable");
|
|
272
|
-
const milestoneMethod = waitUntil === "load" ? "Page.loadEventFired" : "Page.domContentEventFired";
|
|
273
|
-
const milestone = conn.waitFor(milestoneMethod, undefined, args.timeoutMs).then(() => waitUntil).catch(() => {
|
|
274
|
-
return;
|
|
275
|
-
});
|
|
276
|
-
const stopped = conn.waitFor("Page.frameStoppedLoading", undefined, args.timeoutMs).then(() => "frameStoppedLoading").catch(() => {
|
|
277
|
-
return;
|
|
278
|
-
});
|
|
279
|
-
let resolvedUrl;
|
|
280
|
-
let frameId;
|
|
281
|
-
if (reload) {
|
|
282
|
-
await conn.send("Page.reload", { ignoreCache: args.ignoreCache === true }, args.timeoutMs ? { timeoutMs: args.timeoutMs } : {});
|
|
283
|
-
const tree = await conn.send("Page.getFrameTree").catch(() => {
|
|
284
|
-
return;
|
|
285
|
-
});
|
|
286
|
-
frameId = tree?.frameTree?.frame?.id ?? "";
|
|
287
|
-
resolvedUrl = tree?.frameTree?.frame?.url ?? args.url ?? "";
|
|
288
|
-
} else {
|
|
289
|
-
const nav = await conn.send("Page.navigate", { url: args.url }, args.timeoutMs ? { timeoutMs: args.timeoutMs } : {});
|
|
290
|
-
if (nav.errorText) {
|
|
291
|
-
throw new CdpError(`navigate_page: ${nav.errorText} (${args.url})`);
|
|
292
|
-
}
|
|
293
|
-
frameId = nav.frameId;
|
|
294
|
-
resolvedUrl = args.url;
|
|
295
|
-
}
|
|
296
|
-
const settled = await Promise.race([milestone, stopped]);
|
|
297
|
-
const waitedFor = settled ?? "navigate-only";
|
|
298
|
-
return reload ? { url: resolvedUrl, frameId, reloaded: true, waitedFor } : { url: resolvedUrl, frameId, waitedFor };
|
|
299
|
-
}, args.timeoutMs ? { timeoutMs: args.timeoutMs } : {});
|
|
300
|
-
}
|
|
301
|
-
var DEFAULT_WAIT_TIMEOUT_MS = 15000;
|
|
302
|
-
var DEFAULT_POLL_MS = 250;
|
|
303
|
-
async function waitForText(args) {
|
|
304
|
-
if (typeof args.text !== "string" || args.text.length === 0) {
|
|
305
|
-
throw new CdpError("wait_for: 'text' is required");
|
|
306
|
-
}
|
|
307
|
-
const timeoutMs = args.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
|
|
308
|
-
const pollMs = args.pollMs ?? DEFAULT_POLL_MS;
|
|
309
|
-
const start = Date.now();
|
|
310
|
-
return withPage(args.target, async (conn, _target) => {
|
|
311
|
-
await conn.send("Runtime.enable");
|
|
312
|
-
const expr = `(() => { const b = document.body; return !!b && typeof b.innerText === 'string' && b.innerText.includes(${JSON.stringify(args.text)}); })()`;
|
|
313
|
-
for (;; ) {
|
|
314
|
-
const { result } = await conn.send("Runtime.evaluate", {
|
|
315
|
-
expression: expr,
|
|
316
|
-
returnByValue: true
|
|
317
|
-
});
|
|
318
|
-
if (result?.value === true) {
|
|
319
|
-
return { found: true, waitedMs: Date.now() - start };
|
|
320
|
-
}
|
|
321
|
-
if (Date.now() - start >= timeoutMs) {
|
|
322
|
-
throw new CdpError(`wait_for: text not found within ${timeoutMs}ms: ${JSON.stringify(args.text)}`);
|
|
323
|
-
}
|
|
324
|
-
await delay(Math.min(pollMs, Math.max(0, timeoutMs - (Date.now() - start))));
|
|
325
|
-
}
|
|
326
|
-
}, { timeoutMs: Math.max(timeoutMs, DEFAULT_WAIT_TIMEOUT_MS) });
|
|
327
|
-
}
|
|
328
|
-
function delay(ms) {
|
|
329
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// src/tools/evaluate.ts
|
|
333
|
-
function exceptionMessage(details) {
|
|
334
|
-
const ex = details.exception;
|
|
335
|
-
const fromObject = ex?.description ?? (typeof ex?.value === "string" ? ex.value : ex?.value !== undefined ? JSON.stringify(ex.value) : undefined);
|
|
336
|
-
return fromObject ?? details.text ?? "evaluation threw";
|
|
337
|
-
}
|
|
338
|
-
function unwrap(obj) {
|
|
339
|
-
if (Object.prototype.hasOwnProperty.call(obj, "value"))
|
|
340
|
-
return obj.value;
|
|
341
|
-
if (obj.unserializableValue !== undefined)
|
|
342
|
-
return obj.unserializableValue;
|
|
343
|
-
if (obj.type === "undefined")
|
|
344
|
-
return;
|
|
345
|
-
return obj.description ?? null;
|
|
346
|
-
}
|
|
347
|
-
async function evaluateExpression(conn, expression, awaitPromise) {
|
|
348
|
-
const res = await conn.send("Runtime.evaluate", {
|
|
349
|
-
expression,
|
|
350
|
-
returnByValue: true,
|
|
351
|
-
awaitPromise,
|
|
352
|
-
replMode: false,
|
|
353
|
-
silent: false
|
|
354
|
-
});
|
|
355
|
-
if (res.exceptionDetails)
|
|
356
|
-
throw new CdpError(exceptionMessage(res.exceptionDetails));
|
|
357
|
-
return unwrap(res.result);
|
|
358
|
-
}
|
|
359
|
-
async function evaluateFunction(conn, expression, awaitPromise, args) {
|
|
360
|
-
const globalObj = await conn.send("Runtime.evaluate", {
|
|
361
|
-
expression: "globalThis",
|
|
362
|
-
returnByValue: false
|
|
363
|
-
});
|
|
364
|
-
const objectId = globalObj.result.objectId;
|
|
365
|
-
if (!objectId)
|
|
366
|
-
throw new CdpError("could not resolve global object for function call");
|
|
367
|
-
const res = await conn.send("Runtime.callFunctionOn", {
|
|
368
|
-
objectId,
|
|
369
|
-
functionDeclaration: expression,
|
|
370
|
-
arguments: args.map((value) => ({ value })),
|
|
371
|
-
returnByValue: true,
|
|
372
|
-
awaitPromise,
|
|
373
|
-
silent: false
|
|
374
|
-
});
|
|
375
|
-
if (res.exceptionDetails)
|
|
376
|
-
throw new CdpError(exceptionMessage(res.exceptionDetails));
|
|
377
|
-
return unwrap(res.result);
|
|
378
|
-
}
|
|
379
|
-
async function evaluateScript(args) {
|
|
380
|
-
const { expression } = args;
|
|
381
|
-
if (typeof expression !== "string" || expression.length === 0) {
|
|
382
|
-
throw new CdpError("evaluateScript: 'expression' must be a non-empty string");
|
|
383
|
-
}
|
|
384
|
-
const awaitPromise = args.awaitPromise ?? true;
|
|
385
|
-
const callArgs = args.args;
|
|
386
|
-
return withPage(args.target, async (conn) => {
|
|
387
|
-
await conn.send("Runtime.enable");
|
|
388
|
-
if (callArgs && callArgs.length > 0) {
|
|
389
|
-
return evaluateFunction(conn, expression, awaitPromise, callArgs);
|
|
390
|
-
}
|
|
391
|
-
return evaluateExpression(conn, expression, awaitPromise);
|
|
392
|
-
});
|
|
393
|
-
}
|
|
394
|
-
|
|
395
206
|
// src/tools/snapshot.ts
|
|
396
207
|
var INTERACTIVE_ROLES = new Set([
|
|
397
208
|
"button",
|
|
@@ -414,92 +225,6 @@ var INTERACTIVE_ROLES = new Set([
|
|
|
414
225
|
"MenuItem",
|
|
415
226
|
"Button"
|
|
416
227
|
]);
|
|
417
|
-
function axString(v) {
|
|
418
|
-
if (!v)
|
|
419
|
-
return;
|
|
420
|
-
const raw = v.value;
|
|
421
|
-
if (raw === undefined || raw === null)
|
|
422
|
-
return;
|
|
423
|
-
const s = String(raw).trim();
|
|
424
|
-
return s.length ? s : undefined;
|
|
425
|
-
}
|
|
426
|
-
function propValue(node, name) {
|
|
427
|
-
const p = node.properties?.find((x) => x.name === name);
|
|
428
|
-
return axString(p?.value);
|
|
429
|
-
}
|
|
430
|
-
function isInteractive(role) {
|
|
431
|
-
if (!role)
|
|
432
|
-
return false;
|
|
433
|
-
return INTERACTIVE_ROLES.has(role) || INTERACTIVE_ROLES.has(role.toLowerCase());
|
|
434
|
-
}
|
|
435
|
-
function formatNode(node, interactiveOnly) {
|
|
436
|
-
const role = axString(node.role);
|
|
437
|
-
const name = axString(node.name);
|
|
438
|
-
const uid = node.backendDOMNodeId;
|
|
439
|
-
if (uid === undefined)
|
|
440
|
-
return;
|
|
441
|
-
if (interactiveOnly && !isInteractive(role))
|
|
442
|
-
return;
|
|
443
|
-
if (!interactiveOnly && !role && !name)
|
|
444
|
-
return;
|
|
445
|
-
const extras = [];
|
|
446
|
-
const value = axString(node.value);
|
|
447
|
-
if (value)
|
|
448
|
-
extras.push(`value=${JSON.stringify(value)}`);
|
|
449
|
-
const checked = propValue(node, "checked");
|
|
450
|
-
if (checked && checked !== "false")
|
|
451
|
-
extras.push(`checked=${checked}`);
|
|
452
|
-
const expanded = propValue(node, "expanded");
|
|
453
|
-
if (expanded)
|
|
454
|
-
extras.push(`expanded=${expanded}`);
|
|
455
|
-
const disabled = propValue(node, "disabled");
|
|
456
|
-
if (disabled === "true")
|
|
457
|
-
extras.push("disabled");
|
|
458
|
-
const url = propValue(node, "url");
|
|
459
|
-
if (url)
|
|
460
|
-
extras.push(`url=${url}`);
|
|
461
|
-
const focused = propValue(node, "focused");
|
|
462
|
-
if (focused === "true")
|
|
463
|
-
extras.push("focused");
|
|
464
|
-
const label = name ? ` ${JSON.stringify(name)}` : "";
|
|
465
|
-
const extra = extras.length ? ` [${extras.join(" ")}]` : "";
|
|
466
|
-
return `[${uid}] ${role ?? "generic"}${label}${extra}`;
|
|
467
|
-
}
|
|
468
|
-
async function takeSnapshot(args = {}) {
|
|
469
|
-
const interactiveOnly = args.interactiveOnly ?? false;
|
|
470
|
-
return withPage(args.target, async (conn, target) => {
|
|
471
|
-
await conn.send("Accessibility.enable");
|
|
472
|
-
const { nodes } = await conn.send("Accessibility.getFullAXTree");
|
|
473
|
-
const byId = new Map;
|
|
474
|
-
for (const n of nodes)
|
|
475
|
-
byId.set(n.nodeId, n);
|
|
476
|
-
const roots = nodes.filter((n) => !n.parentId || !byId.has(n.parentId));
|
|
477
|
-
const lines = [];
|
|
478
|
-
let nodeCount = 0;
|
|
479
|
-
const walk = (node, depth) => {
|
|
480
|
-
const line = node.ignored ? undefined : formatNode(node, interactiveOnly);
|
|
481
|
-
if (line !== undefined) {
|
|
482
|
-
const indent = interactiveOnly ? "" : " ".repeat(depth);
|
|
483
|
-
lines.push(`${indent}${line}`);
|
|
484
|
-
nodeCount++;
|
|
485
|
-
}
|
|
486
|
-
const childDepth = line !== undefined && !interactiveOnly ? depth + 1 : depth;
|
|
487
|
-
for (const childId of node.childIds ?? []) {
|
|
488
|
-
const child = byId.get(childId);
|
|
489
|
-
if (child)
|
|
490
|
-
walk(child, childDepth);
|
|
491
|
-
}
|
|
492
|
-
};
|
|
493
|
-
for (const root of roots)
|
|
494
|
-
walk(root, 0);
|
|
495
|
-
return {
|
|
496
|
-
snapshot: lines.join(`
|
|
497
|
-
`),
|
|
498
|
-
target: { id: target.id, url: target.url, title: target.title },
|
|
499
|
-
nodeCount
|
|
500
|
-
};
|
|
501
|
-
});
|
|
502
|
-
}
|
|
503
228
|
async function resolveUid(conn, uid) {
|
|
504
229
|
const { object } = await conn.send("DOM.resolveNode", {
|
|
505
230
|
backendNodeId: uid
|
|
@@ -510,409 +235,670 @@ async function resolveUid(conn, uid) {
|
|
|
510
235
|
return { objectId: object.objectId };
|
|
511
236
|
}
|
|
512
237
|
|
|
513
|
-
// src/tools/
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
const hasSelector = typeof t.selector === "string" && t.selector.length > 0;
|
|
517
|
-
if (hasUid === hasSelector) {
|
|
518
|
-
throw new CdpError("provide exactly one of { uid } or { selector }");
|
|
519
|
-
}
|
|
520
|
-
if (hasUid) {
|
|
521
|
-
return resolveUid(conn, t.uid);
|
|
522
|
-
}
|
|
523
|
-
const { result, exceptionDetails } = await conn.send("Runtime.evaluate", {
|
|
524
|
-
expression: `document.querySelector(${JSON.stringify(t.selector)})`,
|
|
525
|
-
returnByValue: false
|
|
526
|
-
});
|
|
527
|
-
if (exceptionDetails) {
|
|
528
|
-
throw new CdpError(`selector '${t.selector}' evaluation failed: ${exceptionDetails.text ?? "error"}`);
|
|
529
|
-
}
|
|
530
|
-
if (!result.objectId || result.subtype === "null") {
|
|
531
|
-
throw new CdpError(`selector '${t.selector}' matched no element`);
|
|
532
|
-
}
|
|
533
|
-
return { objectId: result.objectId };
|
|
238
|
+
// src/tools/network_mock.ts
|
|
239
|
+
function escapeRegExpChar(ch) {
|
|
240
|
+
return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
|
|
534
241
|
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
242
|
+
function globToRegExp(pattern) {
|
|
243
|
+
let re = "";
|
|
244
|
+
for (let i = 0;i < pattern.length; i++) {
|
|
245
|
+
const ch = pattern[i];
|
|
246
|
+
if (ch === "\\") {
|
|
247
|
+
const next = pattern[i + 1];
|
|
248
|
+
if (next !== undefined) {
|
|
249
|
+
re += escapeRegExpChar(next);
|
|
250
|
+
i++;
|
|
251
|
+
} else {
|
|
252
|
+
re += "\\\\";
|
|
253
|
+
}
|
|
254
|
+
} else if (ch === "*") {
|
|
255
|
+
re += ".*";
|
|
256
|
+
} else if (ch === "?") {
|
|
257
|
+
re += ".";
|
|
258
|
+
} else {
|
|
259
|
+
re += escapeRegExpChar(ch);
|
|
260
|
+
}
|
|
546
261
|
}
|
|
547
|
-
return
|
|
548
|
-
}
|
|
549
|
-
async function focusElement(conn, objectId) {
|
|
550
|
-
await conn.send("Runtime.callFunctionOn", {
|
|
551
|
-
objectId,
|
|
552
|
-
functionDeclaration: "function(){this.focus&&this.focus();}",
|
|
553
|
-
returnByValue: true
|
|
554
|
-
});
|
|
555
|
-
}
|
|
556
|
-
async function click(args) {
|
|
557
|
-
const button = args.button ?? "left";
|
|
558
|
-
const clickCount = args.clickCount ?? 1;
|
|
559
|
-
return withPage(args.target, async (conn) => {
|
|
560
|
-
const { objectId } = await resolveElement(conn, args);
|
|
561
|
-
const { x, y } = await centerOf(conn, objectId);
|
|
562
|
-
await conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
|
|
563
|
-
await conn.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount });
|
|
564
|
-
await conn.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, clickCount });
|
|
565
|
-
return { clicked: true, x, y };
|
|
566
|
-
});
|
|
262
|
+
return new RegExp(`^${re}$`);
|
|
567
263
|
}
|
|
568
|
-
|
|
569
|
-
return
|
|
570
|
-
const { objectId } = await resolveElement(conn, args);
|
|
571
|
-
const { x, y } = await centerOf(conn, objectId);
|
|
572
|
-
await conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
|
|
573
|
-
return { hovered: true, x, y };
|
|
574
|
-
});
|
|
264
|
+
function urlMatches(pattern, url) {
|
|
265
|
+
return globToRegExp(pattern).test(url);
|
|
575
266
|
}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
throw new CdpError("drag requires { from } and { to }");
|
|
579
|
-
return withPage(args.target, async (conn) => {
|
|
580
|
-
const { objectId: srcId } = await resolveElement(conn, args.from);
|
|
581
|
-
const from = await centerOf(conn, srcId);
|
|
582
|
-
const { objectId: dstId } = await resolveElement(conn, args.to);
|
|
583
|
-
const to = await centerOf(conn, dstId);
|
|
584
|
-
await conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: from.x, y: from.y });
|
|
585
|
-
await conn.send("Input.dispatchMouseEvent", {
|
|
586
|
-
type: "mousePressed",
|
|
587
|
-
x: from.x,
|
|
588
|
-
y: from.y,
|
|
589
|
-
button: "left",
|
|
590
|
-
clickCount: 1
|
|
591
|
-
});
|
|
592
|
-
await conn.send("Input.dispatchMouseEvent", {
|
|
593
|
-
type: "mouseMoved",
|
|
594
|
-
x: (from.x + to.x) / 2,
|
|
595
|
-
y: (from.y + to.y) / 2,
|
|
596
|
-
button: "left"
|
|
597
|
-
});
|
|
598
|
-
await conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: to.x, y: to.y, button: "left" });
|
|
599
|
-
await conn.send("Input.dispatchMouseEvent", {
|
|
600
|
-
type: "mouseReleased",
|
|
601
|
-
x: to.x,
|
|
602
|
-
y: to.y,
|
|
603
|
-
button: "left",
|
|
604
|
-
clickCount: 1
|
|
605
|
-
});
|
|
606
|
-
return { dragged: true, from, to };
|
|
607
|
-
});
|
|
267
|
+
function selectRule(rules, url, method) {
|
|
268
|
+
return rules.find((r) => urlMatches(r.urlPattern, url) && (!r.method || r.method.toUpperCase() === method.toUpperCase()));
|
|
608
269
|
}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
returnByValue: true
|
|
615
|
-
});
|
|
616
|
-
if (value.length) {
|
|
617
|
-
await conn.send("Input.insertText", { text: value });
|
|
270
|
+
function buildFulfillParams(requestId, rule) {
|
|
271
|
+
const merged = new Map;
|
|
272
|
+
merged.set("content-type", { name: "Content-Type", value: rule.contentType ?? "application/json" });
|
|
273
|
+
for (const [name, value] of Object.entries(rule.headers ?? {})) {
|
|
274
|
+
merged.set(name.toLowerCase(), { name, value });
|
|
618
275
|
}
|
|
276
|
+
return {
|
|
277
|
+
requestId,
|
|
278
|
+
responseCode: rule.status ?? 200,
|
|
279
|
+
responseHeaders: [...merged.values()],
|
|
280
|
+
body: Buffer.from(rule.body ?? "", "utf8").toString("base64")
|
|
281
|
+
};
|
|
619
282
|
}
|
|
620
|
-
|
|
621
|
-
if (
|
|
622
|
-
|
|
623
|
-
return
|
|
624
|
-
const { objectId } = await resolveElement(conn, args);
|
|
625
|
-
await setValue(conn, objectId, args.value);
|
|
626
|
-
return { filled: true, value: args.value };
|
|
627
|
-
});
|
|
283
|
+
function effectiveAction(rule, rnd) {
|
|
284
|
+
if (rule.failRate !== undefined && rule.failRate > 0 && rnd < rule.failRate)
|
|
285
|
+
return "fail";
|
|
286
|
+
return rule.action;
|
|
628
287
|
}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
return withPage(args.target, async (conn) => {
|
|
633
|
-
const { objectId } = await resolveElement(conn, args);
|
|
634
|
-
await focusElement(conn, objectId);
|
|
635
|
-
if (args.text.length)
|
|
636
|
-
await conn.send("Input.insertText", { text: args.text });
|
|
637
|
-
return { typed: true, text: args.text };
|
|
638
|
-
});
|
|
288
|
+
var sessions = new Map;
|
|
289
|
+
function delay(ms) {
|
|
290
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
639
291
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
throw new CdpError("each fill_form field requires a string value");
|
|
292
|
+
function registerHandler(session) {
|
|
293
|
+
session.conn.on("Fetch.requestPaused", (params) => {
|
|
294
|
+
const p = params;
|
|
295
|
+
session.chain = session.chain.then(async () => {
|
|
296
|
+
const rule = selectRule(session.rules, p.request.url, p.request.method);
|
|
297
|
+
if (!rule) {
|
|
298
|
+
await session.conn.send("Fetch.continueRequest", { requestId: p.requestId });
|
|
299
|
+
return;
|
|
649
300
|
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
space: { key: " ", code: "Space", keyCode: 32, text: " " },
|
|
666
|
-
arrowup: { key: "ArrowUp", code: "ArrowUp", keyCode: 38 },
|
|
667
|
-
arrowdown: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 },
|
|
668
|
-
arrowleft: { key: "ArrowLeft", code: "ArrowLeft", keyCode: 37 },
|
|
669
|
-
arrowright: { key: "ArrowRight", code: "ArrowRight", keyCode: 39 },
|
|
670
|
-
up: { key: "ArrowUp", code: "ArrowUp", keyCode: 38 },
|
|
671
|
-
down: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 },
|
|
672
|
-
left: { key: "ArrowLeft", code: "ArrowLeft", keyCode: 37 },
|
|
673
|
-
right: { key: "ArrowRight", code: "ArrowRight", keyCode: 39 },
|
|
674
|
-
home: { key: "Home", code: "Home", keyCode: 36 },
|
|
675
|
-
end: { key: "End", code: "End", keyCode: 35 },
|
|
676
|
-
pageup: { key: "PageUp", code: "PageUp", keyCode: 33 },
|
|
677
|
-
pagedown: { key: "PageDown", code: "PageDown", keyCode: 34 }
|
|
678
|
-
};
|
|
679
|
-
var MODIFIER_BITS = {
|
|
680
|
-
alt: 1,
|
|
681
|
-
control: 2,
|
|
682
|
-
ctrl: 2,
|
|
683
|
-
meta: 4,
|
|
684
|
-
cmd: 4,
|
|
685
|
-
command: 4,
|
|
686
|
-
shift: 8
|
|
687
|
-
};
|
|
688
|
-
function resolveKey(key) {
|
|
689
|
-
const named = NAMED_KEYS[key.toLowerCase()];
|
|
690
|
-
if (named)
|
|
691
|
-
return named;
|
|
692
|
-
if (key.length === 1) {
|
|
693
|
-
const upper = key.toUpperCase();
|
|
694
|
-
const isLetter = upper >= "A" && upper <= "Z";
|
|
695
|
-
const isDigit = key >= "0" && key <= "9";
|
|
696
|
-
const code = isLetter ? `Key${upper}` : isDigit ? `Digit${key}` : `Key${upper}`;
|
|
697
|
-
const keyCode = upper.charCodeAt(0);
|
|
698
|
-
return { key, code, keyCode, text: key };
|
|
699
|
-
}
|
|
700
|
-
throw new CdpError(`unknown key '${key}' (use a named key like Enter/Tab/ArrowDown or a single character)`);
|
|
701
|
-
}
|
|
702
|
-
async function pressKey(args) {
|
|
703
|
-
if (typeof args.key !== "string" || !args.key.length) {
|
|
704
|
-
throw new CdpError("press_key requires a non-empty { key }");
|
|
705
|
-
}
|
|
706
|
-
const spec = resolveKey(args.key);
|
|
707
|
-
let modBits = 0;
|
|
708
|
-
const mods = args.modifiers ?? [];
|
|
709
|
-
for (const m of mods) {
|
|
710
|
-
const bit = MODIFIER_BITS[m.toLowerCase()];
|
|
711
|
-
if (bit === undefined)
|
|
712
|
-
throw new CdpError(`unknown modifier '${m}'`);
|
|
713
|
-
modBits |= bit;
|
|
714
|
-
}
|
|
715
|
-
const suppressText = (modBits & ~MODIFIER_BITS.shift) !== 0;
|
|
716
|
-
return withPage(args.target, async (conn) => {
|
|
717
|
-
const down = {
|
|
718
|
-
type: "keyDown",
|
|
719
|
-
key: spec.key,
|
|
720
|
-
code: spec.code,
|
|
721
|
-
windowsVirtualKeyCode: spec.keyCode,
|
|
722
|
-
nativeVirtualKeyCode: spec.keyCode,
|
|
723
|
-
modifiers: modBits
|
|
724
|
-
};
|
|
725
|
-
if (spec.text && !suppressText) {
|
|
726
|
-
down.text = spec.text;
|
|
727
|
-
}
|
|
728
|
-
await conn.send("Input.dispatchKeyEvent", down);
|
|
729
|
-
await conn.send("Input.dispatchKeyEvent", {
|
|
730
|
-
type: "keyUp",
|
|
731
|
-
key: spec.key,
|
|
732
|
-
code: spec.code,
|
|
733
|
-
windowsVirtualKeyCode: spec.keyCode,
|
|
734
|
-
nativeVirtualKeyCode: spec.keyCode,
|
|
735
|
-
modifiers: modBits
|
|
301
|
+
if (rule.delayMs)
|
|
302
|
+
await delay(rule.delayMs);
|
|
303
|
+
const act = effectiveAction(rule, Math.random());
|
|
304
|
+
if (act === "fulfill") {
|
|
305
|
+
await session.conn.send("Fetch.fulfillRequest", { ...buildFulfillParams(p.requestId, rule) });
|
|
306
|
+
} else if (act === "fail") {
|
|
307
|
+
await session.conn.send("Fetch.failRequest", { requestId: p.requestId, errorReason: rule.errorReason ?? "Failed" });
|
|
308
|
+
} else {
|
|
309
|
+
await session.conn.send("Fetch.continueRequest", { requestId: p.requestId });
|
|
310
|
+
}
|
|
311
|
+
rule.hits++;
|
|
312
|
+
session.intercepted.push({ url: p.request.url, method: p.request.method, action: act });
|
|
313
|
+
}).catch((e) => {
|
|
314
|
+
if (/closed|connection|Target|Inspected/i.test(String(e)))
|
|
315
|
+
session.dead = true;
|
|
736
316
|
});
|
|
737
|
-
return { pressed: spec.key, modifiers: mods };
|
|
738
317
|
});
|
|
739
318
|
}
|
|
740
|
-
async function
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
319
|
+
async function mockRequest(args) {
|
|
320
|
+
if (!args.urlPattern)
|
|
321
|
+
throw new CdpError("mock_request requires a { urlPattern }");
|
|
322
|
+
const rule = {
|
|
323
|
+
urlPattern: args.urlPattern,
|
|
324
|
+
action: args.action ?? "fulfill",
|
|
325
|
+
status: args.status,
|
|
326
|
+
body: args.body,
|
|
327
|
+
contentType: args.contentType,
|
|
328
|
+
headers: args.headers,
|
|
329
|
+
errorReason: args.errorReason,
|
|
330
|
+
method: args.method,
|
|
331
|
+
delayMs: args.delayMs,
|
|
332
|
+
failRate: args.failRate,
|
|
333
|
+
hits: 0
|
|
334
|
+
};
|
|
335
|
+
const resolved = await resolveTarget(args.target);
|
|
336
|
+
let session = sessions.get(resolved.id);
|
|
337
|
+
if (session && !session.dead) {
|
|
338
|
+
const idx = session.rules.findIndex((r) => r.urlPattern === rule.urlPattern && (r.method ?? "") === (rule.method ?? ""));
|
|
339
|
+
if (idx >= 0)
|
|
340
|
+
session.rules[idx] = rule;
|
|
341
|
+
else
|
|
342
|
+
session.rules.push(rule);
|
|
343
|
+
await session.conn.send("Fetch.enable", { patterns: session.rules.map((r) => ({ urlPattern: r.urlPattern })) });
|
|
344
|
+
} else {
|
|
345
|
+
const { conn, target } = await openPage(resolved.id);
|
|
346
|
+
session = { conn, target, rules: [rule], intercepted: [], chain: Promise.resolve(), dead: false };
|
|
347
|
+
registerHandler(session);
|
|
348
|
+
await conn.send("Fetch.enable", { patterns: [{ urlPattern: rule.urlPattern }] });
|
|
349
|
+
sessions.set(target.id, session);
|
|
350
|
+
}
|
|
351
|
+
let reloaded = false;
|
|
352
|
+
if (args.reload) {
|
|
353
|
+
await session.conn.send("Page.enable");
|
|
354
|
+
await session.conn.send("Page.reload", { ignoreCache: true });
|
|
355
|
+
await delay(800);
|
|
356
|
+
reloaded = true;
|
|
357
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
target: { id: session.target.id, url: session.target.url, title: session.target.title },
|
|
360
|
+
pattern: rule.urlPattern,
|
|
361
|
+
ruleCount: session.rules.length,
|
|
362
|
+
reloaded
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
async function listMocks(_args = {}) {
|
|
366
|
+
for (const [id, s] of sessions) {
|
|
367
|
+
if (s.dead) {
|
|
368
|
+
try {
|
|
369
|
+
s.conn.close();
|
|
370
|
+
} catch {}
|
|
371
|
+
sessions.delete(id);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
await s.conn.send("Runtime.evaluate", { expression: "1", returnByValue: true }, { timeoutMs: 3000 });
|
|
376
|
+
} catch {
|
|
377
|
+
try {
|
|
378
|
+
s.conn.close();
|
|
379
|
+
} catch {}
|
|
380
|
+
sessions.delete(id);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const mocks = [...sessions.values()].map((s) => ({
|
|
384
|
+
target: { id: s.target.id, url: s.target.url, title: s.target.title },
|
|
385
|
+
rules: s.rules.map((r) => ({ urlPattern: r.urlPattern, action: r.action, method: r.method, hits: r.hits })),
|
|
386
|
+
hits: s.intercepted.length
|
|
387
|
+
}));
|
|
388
|
+
return { count: mocks.length, mocks };
|
|
389
|
+
}
|
|
390
|
+
async function clearMocks(args = {}) {
|
|
391
|
+
let ids;
|
|
392
|
+
if (args.all) {
|
|
393
|
+
ids = [...sessions.keys()];
|
|
394
|
+
} else {
|
|
395
|
+
const resolved = await resolveTarget(args.target);
|
|
396
|
+
ids = sessions.has(resolved.id) ? [resolved.id] : [];
|
|
397
|
+
}
|
|
398
|
+
let cleared = 0;
|
|
399
|
+
for (const id of ids) {
|
|
400
|
+
const s = sessions.get(id);
|
|
401
|
+
if (!s)
|
|
402
|
+
continue;
|
|
403
|
+
try {
|
|
404
|
+
await s.conn.send("Fetch.disable");
|
|
405
|
+
} catch {}
|
|
406
|
+
try {
|
|
407
|
+
s.conn.close();
|
|
408
|
+
} catch {}
|
|
409
|
+
sessions.delete(id);
|
|
410
|
+
cleared++;
|
|
411
|
+
}
|
|
412
|
+
return { cleared };
|
|
750
413
|
}
|
|
751
414
|
|
|
752
|
-
// src/
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
var
|
|
756
|
-
|
|
757
|
-
|
|
415
|
+
// src/driver.ts
|
|
416
|
+
var UID_STAMP_ATTR = "data-cdp-uid";
|
|
417
|
+
var LEGACY_NUMERIC_UID = /^[0-9]+$/;
|
|
418
|
+
var REQUIRED_CAPABILITIES = {
|
|
419
|
+
performance_start_trace: ["trace.performance"],
|
|
420
|
+
performance_stop_trace: ["trace.performance"],
|
|
421
|
+
performance_analyze_insight: ["trace.performance"],
|
|
422
|
+
performance_trace: ["trace.performance"],
|
|
423
|
+
take_heapsnapshot: ["heap.snapshot"],
|
|
424
|
+
lighthouse_audit: ["audit.lighthouse"],
|
|
425
|
+
mock_request: ["network.intercept"],
|
|
426
|
+
list_mocks: ["network.intercept"],
|
|
427
|
+
clear_mocks: ["network.intercept"]
|
|
428
|
+
};
|
|
429
|
+
function isDriverError(e) {
|
|
430
|
+
return e instanceof Error && typeof e.code === "string";
|
|
758
431
|
}
|
|
759
|
-
|
|
760
|
-
|
|
432
|
+
|
|
433
|
+
// src/cdp/driver.ts
|
|
434
|
+
function driverError(code, message, data) {
|
|
435
|
+
return Object.assign(new Error(message), { code, ...data !== undefined ? { data } : {} });
|
|
436
|
+
}
|
|
437
|
+
function encodeUid(backendNodeId) {
|
|
438
|
+
return `cdp:${backendNodeId}`;
|
|
439
|
+
}
|
|
440
|
+
function decodeUid(uid) {
|
|
441
|
+
if (LEGACY_NUMERIC_UID.test(uid))
|
|
442
|
+
return Number(uid);
|
|
443
|
+
if (!uid.startsWith("cdp:"))
|
|
444
|
+
throw driverError("foreign-uid", `uid is not owned by the cdp driver: ${uid}`);
|
|
445
|
+
const n = Number(uid.slice(4));
|
|
446
|
+
if (!Number.isSafeInteger(n) || n <= 0)
|
|
447
|
+
throw driverError("foreign-uid", `malformed cdp uid payload: ${uid}`);
|
|
448
|
+
return n;
|
|
449
|
+
}
|
|
450
|
+
async function centerOf(conn, objectId) {
|
|
451
|
+
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};}";
|
|
452
|
+
const { result, exceptionDetails } = await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: fn, returnByValue: true });
|
|
453
|
+
if (exceptionDetails)
|
|
454
|
+
throw driverError("page-error", `could not measure element: ${exceptionDetails.text ?? "error"}`);
|
|
455
|
+
if (!result.value)
|
|
456
|
+
throw driverError("page-error", "element has zero size / is not visible; cannot compute a click point");
|
|
457
|
+
return result.value;
|
|
458
|
+
}
|
|
459
|
+
async function focusElement(conn, objectId) {
|
|
460
|
+
await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: "function(){this.focus&&this.focus();}", returnByValue: true });
|
|
461
|
+
}
|
|
462
|
+
async function setValueOnObject(conn, objectId, value) {
|
|
463
|
+
await focusElement(conn, objectId);
|
|
464
|
+
const fn = "function(){if('value'in this){this.value='';this.dispatchEvent(new Event('input',{bubbles:true}));}else if(this.isContentEditable){this.textContent='';}}";
|
|
465
|
+
await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: fn, returnByValue: true });
|
|
466
|
+
if (value.length)
|
|
467
|
+
await conn.send("Input.insertText", { text: value });
|
|
468
|
+
}
|
|
469
|
+
var NAMED_KEYS = {
|
|
470
|
+
enter: { key: "Enter", code: "Enter", keyCode: 13, text: "\r" },
|
|
471
|
+
return: { key: "Enter", code: "Enter", keyCode: 13, text: "\r" },
|
|
472
|
+
tab: { key: "Tab", code: "Tab", keyCode: 9 },
|
|
473
|
+
escape: { key: "Escape", code: "Escape", keyCode: 27 },
|
|
474
|
+
esc: { key: "Escape", code: "Escape", keyCode: 27 },
|
|
475
|
+
backspace: { key: "Backspace", code: "Backspace", keyCode: 8 },
|
|
476
|
+
delete: { key: "Delete", code: "Delete", keyCode: 46 },
|
|
477
|
+
space: { key: " ", code: "Space", keyCode: 32, text: " " },
|
|
478
|
+
arrowup: { key: "ArrowUp", code: "ArrowUp", keyCode: 38 },
|
|
479
|
+
arrowdown: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 },
|
|
480
|
+
arrowleft: { key: "ArrowLeft", code: "ArrowLeft", keyCode: 37 },
|
|
481
|
+
arrowright: { key: "ArrowRight", code: "ArrowRight", keyCode: 39 },
|
|
482
|
+
up: { key: "ArrowUp", code: "ArrowUp", keyCode: 38 },
|
|
483
|
+
down: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 },
|
|
484
|
+
left: { key: "ArrowLeft", code: "ArrowLeft", keyCode: 37 },
|
|
485
|
+
right: { key: "ArrowRight", code: "ArrowRight", keyCode: 39 },
|
|
486
|
+
home: { key: "Home", code: "Home", keyCode: 36 },
|
|
487
|
+
end: { key: "End", code: "End", keyCode: 35 },
|
|
488
|
+
pageup: { key: "PageUp", code: "PageUp", keyCode: 33 },
|
|
489
|
+
pagedown: { key: "PageDown", code: "PageDown", keyCode: 34 }
|
|
490
|
+
};
|
|
491
|
+
var MODIFIER_BITS = { alt: 1, control: 2, ctrl: 2, meta: 4, cmd: 4, command: 4, shift: 8 };
|
|
492
|
+
function resolveKey(key) {
|
|
493
|
+
const named = NAMED_KEYS[key.toLowerCase()];
|
|
494
|
+
if (named)
|
|
495
|
+
return named;
|
|
496
|
+
if (key.length === 1) {
|
|
497
|
+
const upper = key.toUpperCase();
|
|
498
|
+
const isLetter = upper >= "A" && upper <= "Z";
|
|
499
|
+
const isDigit = key >= "0" && key <= "9";
|
|
500
|
+
const code = isLetter ? `Key${upper}` : isDigit ? `Digit${key}` : `Key${upper}`;
|
|
501
|
+
return { key, code, keyCode: upper.charCodeAt(0), text: key };
|
|
502
|
+
}
|
|
503
|
+
throw driverError("page-error", `unknown key '${key}' (use a named key like Enter/Tab/ArrowDown or a single character)`);
|
|
504
|
+
}
|
|
505
|
+
function unwrap(obj) {
|
|
506
|
+
if (Object.prototype.hasOwnProperty.call(obj, "value"))
|
|
507
|
+
return obj.value;
|
|
508
|
+
if (obj.unserializableValue !== undefined)
|
|
509
|
+
return obj.unserializableValue;
|
|
510
|
+
return obj.type === "undefined" ? undefined : obj.description ?? null;
|
|
511
|
+
}
|
|
512
|
+
function exceptionMessage(details) {
|
|
513
|
+
const ex = details.exception;
|
|
514
|
+
const fromObject = ex?.description ?? (typeof ex?.value === "string" ? ex.value : ex?.value !== undefined ? JSON.stringify(ex.value) : undefined);
|
|
515
|
+
return fromObject ?? details.text ?? "evaluation threw";
|
|
516
|
+
}
|
|
517
|
+
var INTERACTIVE_ROLES2 = new Set([
|
|
518
|
+
"button",
|
|
519
|
+
"link",
|
|
520
|
+
"textbox",
|
|
521
|
+
"searchbox",
|
|
522
|
+
"checkbox",
|
|
523
|
+
"radio",
|
|
524
|
+
"combobox",
|
|
525
|
+
"listbox",
|
|
526
|
+
"option",
|
|
527
|
+
"menuitem",
|
|
528
|
+
"menuitemcheckbox",
|
|
529
|
+
"menuitemradio",
|
|
530
|
+
"tab",
|
|
531
|
+
"switch",
|
|
532
|
+
"slider",
|
|
533
|
+
"spinbutton",
|
|
534
|
+
"textfield"
|
|
535
|
+
]);
|
|
536
|
+
function axString(v) {
|
|
537
|
+
if (!v || v.value === undefined || v.value === null)
|
|
538
|
+
return;
|
|
539
|
+
const s = String(v.value).trim();
|
|
540
|
+
return s.length ? s : undefined;
|
|
541
|
+
}
|
|
542
|
+
function propValue(node, name) {
|
|
543
|
+
return axString(node.properties?.find((p) => p.name === name)?.value);
|
|
544
|
+
}
|
|
545
|
+
function isInteractive(role) {
|
|
546
|
+
return !!role && (INTERACTIVE_ROLES2.has(role) || INTERACTIVE_ROLES2.has(role.toLowerCase()));
|
|
547
|
+
}
|
|
548
|
+
function axExtras(node) {
|
|
549
|
+
const extras = {};
|
|
550
|
+
const value = axString(node.value);
|
|
551
|
+
if (value)
|
|
552
|
+
extras.value = value;
|
|
553
|
+
const checked = propValue(node, "checked");
|
|
554
|
+
if (checked && checked !== "false")
|
|
555
|
+
extras.checked = checked;
|
|
556
|
+
const expanded = propValue(node, "expanded");
|
|
557
|
+
if (expanded)
|
|
558
|
+
extras.expanded = expanded;
|
|
559
|
+
if (propValue(node, "disabled") === "true")
|
|
560
|
+
extras.disabled = "true";
|
|
561
|
+
const url = propValue(node, "url");
|
|
562
|
+
if (url)
|
|
563
|
+
extras.url = url;
|
|
564
|
+
if (propValue(node, "focused") === "true")
|
|
565
|
+
extras.focused = "true";
|
|
566
|
+
return extras;
|
|
567
|
+
}
|
|
568
|
+
async function resolveElementLocator(conn, loc) {
|
|
569
|
+
if ("uid" in loc) {
|
|
570
|
+
return resolveUid(conn, decodeUid(loc.uid)).catch(() => {
|
|
571
|
+
throw driverError("stale-uid", `uid does not resolve: ${loc.uid}`);
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
if ("css" in loc) {
|
|
575
|
+
const { result, exceptionDetails } = await conn.send("Runtime.evaluate", { expression: `document.querySelector(${JSON.stringify(loc.css)})`, returnByValue: false });
|
|
576
|
+
if (exceptionDetails)
|
|
577
|
+
throw driverError("page-error", `css '${loc.css}' evaluation failed: ${exceptionDetails.text ?? "error"}`);
|
|
578
|
+
if (!result.objectId || result.subtype === "null")
|
|
579
|
+
throw driverError("no-such-element", `css '${loc.css}' matched no element`);
|
|
580
|
+
return { objectId: result.objectId };
|
|
581
|
+
}
|
|
582
|
+
const backendNodeId = await searchLocate(conn, "xpath" in loc ? loc.xpath : loc.text);
|
|
583
|
+
return resolveUid(conn, backendNodeId).catch(() => {
|
|
584
|
+
throw driverError("stale-uid", "text/xpath match no longer resolves");
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
async function searchLocate(conn, query) {
|
|
588
|
+
await conn.send("DOM.getDocument", { depth: 0 }).catch(() => {
|
|
589
|
+
return;
|
|
590
|
+
});
|
|
591
|
+
const { searchId, resultCount } = await conn.send("DOM.performSearch", { query });
|
|
592
|
+
try {
|
|
593
|
+
if (!resultCount)
|
|
594
|
+
throw driverError("no-such-element", `locator matched nothing: ${query}`);
|
|
595
|
+
const { nodeIds } = await conn.send("DOM.getSearchResults", { searchId, fromIndex: 0, toIndex: 1 });
|
|
596
|
+
const nodeId = nodeIds[0];
|
|
597
|
+
if (nodeId === undefined)
|
|
598
|
+
throw driverError("no-such-element", `locator matched nothing: ${query}`);
|
|
599
|
+
const { node } = await conn.send("DOM.describeNode", { nodeId });
|
|
600
|
+
return node.backendNodeId;
|
|
601
|
+
} finally {
|
|
602
|
+
await conn.send("DOM.discardSearchResults", { searchId }).catch(() => {
|
|
603
|
+
return;
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
async function backendNodeIdOf(conn, loc) {
|
|
608
|
+
if ("uid" in loc)
|
|
609
|
+
return decodeUid(loc.uid);
|
|
610
|
+
if ("css" in loc) {
|
|
611
|
+
const { objectId } = await resolveElementLocator(conn, loc);
|
|
612
|
+
const { node } = await conn.send("DOM.describeNode", { objectId });
|
|
613
|
+
return node.backendNodeId;
|
|
614
|
+
}
|
|
615
|
+
return searchLocate(conn, "xpath" in loc ? loc.xpath : loc.text);
|
|
761
616
|
}
|
|
762
617
|
async function fullPageClip(conn) {
|
|
763
618
|
const m = await conn.send("Page.getLayoutMetrics");
|
|
764
619
|
const size = m.cssContentSize ?? m.contentSize;
|
|
765
620
|
if (!size)
|
|
766
|
-
throw
|
|
767
|
-
return { x: 0, y: 0, width: Math.ceil(size.width), height: Math.ceil(size.height)
|
|
768
|
-
}
|
|
769
|
-
async function elementClip(conn,
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
}
|
|
774
|
-
const { root } = await conn.send("DOM.getDocument", { depth: 0 });
|
|
775
|
-
const { nodeId } = await conn.send("DOM.querySelector", {
|
|
776
|
-
nodeId: root.nodeId,
|
|
777
|
-
selector
|
|
778
|
-
});
|
|
779
|
-
if (!nodeId)
|
|
780
|
-
throw new CdpError(`selector matched no element: ${selector}`);
|
|
781
|
-
const desc = await conn.send("DOM.describeNode", { nodeId });
|
|
782
|
-
backendNodeId = desc.node.backendNodeId;
|
|
783
|
-
} else {
|
|
784
|
-
throw new CdpError("elementClip requires uid or selector");
|
|
785
|
-
}
|
|
786
|
-
try {
|
|
787
|
-
await conn.send("DOM.scrollIntoViewIfNeeded", { backendNodeId });
|
|
788
|
-
} catch {}
|
|
621
|
+
throw driverError("page-error", "Page.getLayoutMetrics returned no content size");
|
|
622
|
+
return { x: 0, y: 0, width: Math.ceil(size.width), height: Math.ceil(size.height) };
|
|
623
|
+
}
|
|
624
|
+
async function elementClip(conn, loc) {
|
|
625
|
+
const backendNodeId = await backendNodeIdOf(conn, loc);
|
|
626
|
+
await conn.send("DOM.scrollIntoViewIfNeeded", { backendNodeId }).catch(() => {
|
|
627
|
+
return;
|
|
628
|
+
});
|
|
789
629
|
const box = await conn.send("DOM.getBoxModel", { backendNodeId });
|
|
790
630
|
const q = box.model.content;
|
|
791
|
-
const q0 = q[0];
|
|
792
|
-
const q1 = q[1];
|
|
793
|
-
if (q0 == null || q1 == null)
|
|
794
|
-
throw new CdpError("DOM.getBoxModel returned an empty content quad");
|
|
795
631
|
const xs = [q[0], q[2], q[4], q[6]].filter((n) => n != null);
|
|
796
632
|
const ys = [q[1], q[3], q[5], q[7]].filter((n) => n != null);
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
const
|
|
800
|
-
const
|
|
801
|
-
const width = Math.ceil(maxX - minX);
|
|
802
|
-
const height = Math.ceil(maxY - minY);
|
|
633
|
+
if (!xs.length || !ys.length)
|
|
634
|
+
throw driverError("page-error", "DOM.getBoxModel returned an empty content quad");
|
|
635
|
+
const minX = Math.min(...xs), minY = Math.min(...ys);
|
|
636
|
+
const width = Math.ceil(Math.max(...xs) - minX), height = Math.ceil(Math.max(...ys) - minY);
|
|
803
637
|
if (width <= 0 || height <= 0)
|
|
804
|
-
throw
|
|
805
|
-
return { x: minX, y: minY, width, height
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
const { data } = await conn.send("Page.captureScreenshot", params);
|
|
824
|
-
const buf = Buffer.from(data, "base64");
|
|
825
|
-
if (buf.byteLength === 0)
|
|
826
|
-
throw new CdpError("Page.captureScreenshot returned empty data");
|
|
827
|
-
await mkdir2(ARTIFACT_DIR, { recursive: true });
|
|
828
|
-
const ext = format === "jpeg" ? "jpg" : "png";
|
|
829
|
-
const path = savePath ?? join(ARTIFACT_DIR, `screenshot-${shortId(t.id)}-${stamp()}.${ext}`);
|
|
830
|
-
await writeFile2(path, buf);
|
|
831
|
-
const result = {
|
|
832
|
-
path,
|
|
833
|
-
bytes: buf.byteLength,
|
|
834
|
-
format,
|
|
835
|
-
target: { id: t.id, url: t.url, title: t.title }
|
|
836
|
-
};
|
|
837
|
-
if (returnBase64)
|
|
838
|
-
result.base64 = data;
|
|
839
|
-
return result;
|
|
840
|
-
});
|
|
841
|
-
}
|
|
638
|
+
throw driverError("page-error", "resolved element has zero area");
|
|
639
|
+
return { x: minX, y: minY, width, height };
|
|
640
|
+
}
|
|
641
|
+
var CDP_CAPABILITIES = new Set([
|
|
642
|
+
"trace.performance",
|
|
643
|
+
"heap.snapshot",
|
|
644
|
+
"audit.lighthouse",
|
|
645
|
+
"emulate.cpuThrottling",
|
|
646
|
+
"emulate.mediaFeatures",
|
|
647
|
+
"emulate.deviceMetrics",
|
|
648
|
+
"emulate.networkConditions",
|
|
649
|
+
"screenshot.fullPage",
|
|
650
|
+
"screenshot.element",
|
|
651
|
+
"network.intercept",
|
|
652
|
+
"snapshot.accessibilityTree",
|
|
653
|
+
"input.insertTextAtomic",
|
|
654
|
+
"locate.text",
|
|
655
|
+
"locate.xpath"
|
|
656
|
+
]);
|
|
842
657
|
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
width: args.width,
|
|
887
|
-
height: args.height,
|
|
888
|
-
deviceScaleFactor: args.deviceScaleFactor ?? 0,
|
|
889
|
-
mobile: args.mobile ?? false
|
|
658
|
+
class CdpPageDriver {
|
|
659
|
+
conn;
|
|
660
|
+
browser;
|
|
661
|
+
info;
|
|
662
|
+
enabledDomains = new Set;
|
|
663
|
+
released = false;
|
|
664
|
+
constructor(conn, target, browser) {
|
|
665
|
+
this.conn = conn;
|
|
666
|
+
this.browser = browser;
|
|
667
|
+
this.info = { id: target.id, url: target.url, title: target.title, type: target.type };
|
|
668
|
+
}
|
|
669
|
+
async ensureDomain(name) {
|
|
670
|
+
if (this.enabledDomains.has(name))
|
|
671
|
+
return;
|
|
672
|
+
this.enabledDomains.add(name);
|
|
673
|
+
await this.conn.send(`${name}.enable`);
|
|
674
|
+
}
|
|
675
|
+
async ensureDomainSoft(name) {
|
|
676
|
+
if (this.enabledDomains.has(name))
|
|
677
|
+
return;
|
|
678
|
+
this.enabledDomains.add(name);
|
|
679
|
+
await this.conn.send(`${name}.enable`).catch(() => {
|
|
680
|
+
return;
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
async navigate(opts) {
|
|
684
|
+
await this.ensureDomain("Page");
|
|
685
|
+
const reload = opts.reload === true;
|
|
686
|
+
if (!reload && !opts.url)
|
|
687
|
+
throw driverError("page-error", "navigate: 'url' is required unless reload:true");
|
|
688
|
+
const waitUntil = opts.waitUntil ?? "load";
|
|
689
|
+
const milestoneMethod = waitUntil === "load" ? "Page.loadEventFired" : "Page.domContentEventFired";
|
|
690
|
+
const milestone = this.conn.waitFor(milestoneMethod, undefined, opts.timeoutMs).then(() => waitUntil).catch(() => {
|
|
691
|
+
return;
|
|
692
|
+
});
|
|
693
|
+
const stopped = this.conn.waitFor("Page.frameStoppedLoading", undefined, opts.timeoutMs).then(() => "committed").catch(() => {
|
|
694
|
+
return;
|
|
695
|
+
});
|
|
696
|
+
let url, contextId, reloaded;
|
|
697
|
+
if (reload) {
|
|
698
|
+
await this.conn.send("Page.reload", { ignoreCache: opts.ignoreCache === true }, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {});
|
|
699
|
+
const tree = await this.conn.send("Page.getFrameTree").catch(() => {
|
|
700
|
+
return;
|
|
890
701
|
});
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
702
|
+
contextId = tree?.frameTree?.frame?.id ?? "";
|
|
703
|
+
url = tree?.frameTree?.frame?.url ?? opts.url ?? "";
|
|
704
|
+
reloaded = true;
|
|
705
|
+
} else {
|
|
706
|
+
const nav = await this.conn.send("Page.navigate", { url: opts.url }, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {});
|
|
707
|
+
if (nav.errorText)
|
|
708
|
+
throw driverError("page-error", `navigate: ${nav.errorText} (${opts.url})`);
|
|
709
|
+
contextId = nav.frameId;
|
|
710
|
+
url = opts.url;
|
|
896
711
|
}
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
712
|
+
const settled = await Promise.race([milestone, stopped]);
|
|
713
|
+
return { url, contextId, ...reloaded ? { reloaded } : {}, waitedFor: settled ?? "timeout" };
|
|
714
|
+
}
|
|
715
|
+
async waitForText(text, timeoutMs = 15000, pollMs = 250) {
|
|
716
|
+
await this.ensureDomain("Runtime");
|
|
717
|
+
const start = Date.now();
|
|
718
|
+
const expr = `(() => { const b = document.body; return !!b && typeof b.innerText === 'string' && b.innerText.includes(${JSON.stringify(text)}); })()`;
|
|
719
|
+
for (;; ) {
|
|
720
|
+
const { result } = await this.conn.send("Runtime.evaluate", { expression: expr, returnByValue: true });
|
|
721
|
+
if (result?.value === true)
|
|
722
|
+
return { found: true, elapsedMs: Date.now() - start };
|
|
723
|
+
if (Date.now() - start >= timeoutMs)
|
|
724
|
+
throw driverError("timeout", `waitForText: '${text}' not found within ${timeoutMs}ms`);
|
|
725
|
+
await new Promise((r) => setTimeout(r, Math.min(pollMs, Math.max(0, timeoutMs - (Date.now() - start)))));
|
|
902
726
|
}
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
727
|
+
}
|
|
728
|
+
async callFn(objectId, functionDeclaration, args, awaitPromise) {
|
|
729
|
+
const res = await this.conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration, arguments: args.map((value) => ({ value })), returnByValue: true, awaitPromise });
|
|
730
|
+
if (res.exceptionDetails)
|
|
731
|
+
throw driverError("page-error", exceptionMessage(res.exceptionDetails));
|
|
732
|
+
return unwrap(res.result);
|
|
733
|
+
}
|
|
734
|
+
async evaluate(expression, opts) {
|
|
735
|
+
await this.ensureDomain("Runtime");
|
|
736
|
+
const awaitPromise = opts?.awaitPromise ?? true;
|
|
737
|
+
if (opts?.args && opts.args.length > 0) {
|
|
738
|
+
const globalObj = await this.conn.send("Runtime.evaluate", { expression: "globalThis", returnByValue: false });
|
|
739
|
+
const objectId = globalObj.result.objectId;
|
|
740
|
+
if (!objectId)
|
|
741
|
+
throw driverError("page-error", "could not resolve global object for function call");
|
|
742
|
+
return this.callFn(objectId, expression, opts.args, awaitPromise);
|
|
743
|
+
}
|
|
744
|
+
const res = await this.conn.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise });
|
|
745
|
+
if (res.exceptionDetails)
|
|
746
|
+
throw driverError("page-error", exceptionMessage(res.exceptionDetails));
|
|
747
|
+
return unwrap(res.result);
|
|
748
|
+
}
|
|
749
|
+
async callOnElement(loc, functionDeclaration, args = []) {
|
|
750
|
+
const { objectId } = await resolveElementLocator(this.conn, loc);
|
|
751
|
+
return this.callFn(objectId, functionDeclaration, args, true);
|
|
752
|
+
}
|
|
753
|
+
async snapshot(opts) {
|
|
754
|
+
const interactiveOnly = opts?.interactiveOnly ?? false;
|
|
755
|
+
await this.ensureDomain("Accessibility");
|
|
756
|
+
const { nodes } = await this.conn.send("Accessibility.getFullAXTree");
|
|
757
|
+
const byId = new Map;
|
|
758
|
+
for (const n of nodes)
|
|
759
|
+
byId.set(n.nodeId, n);
|
|
760
|
+
const roots = nodes.filter((n) => !n.parentId || !byId.has(n.parentId));
|
|
761
|
+
const out = [];
|
|
762
|
+
const walk = (node, depth) => {
|
|
763
|
+
const role = axString(node.role);
|
|
764
|
+
const name = axString(node.name);
|
|
765
|
+
const backendNodeId = node.backendDOMNodeId;
|
|
766
|
+
const emitted = !node.ignored && backendNodeId !== undefined && (interactiveOnly ? isInteractive(role) : !!(role || name));
|
|
767
|
+
if (emitted) {
|
|
768
|
+
const extras = axExtras(node);
|
|
769
|
+
out.push({ uid: encodeUid(backendNodeId), role: role ?? "generic", depth, ...name ? { name } : {}, ...Object.keys(extras).length ? { extras } : {} });
|
|
770
|
+
}
|
|
771
|
+
const childDepth = emitted && !interactiveOnly ? depth + 1 : depth;
|
|
772
|
+
for (const childId of node.childIds ?? []) {
|
|
773
|
+
const child = byId.get(childId);
|
|
774
|
+
if (child)
|
|
775
|
+
walk(child, childDepth);
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
for (const root of roots)
|
|
779
|
+
walk(root, 0);
|
|
780
|
+
return out;
|
|
781
|
+
}
|
|
782
|
+
async locate(loc) {
|
|
783
|
+
return encodeUid(await backendNodeIdOf(this.conn, loc));
|
|
784
|
+
}
|
|
785
|
+
async resolve(uid) {
|
|
786
|
+
await resolveUid(this.conn, decodeUid(uid)).catch(() => {
|
|
787
|
+
throw driverError("stale-uid", `uid does not resolve: ${uid}`);
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
async hover(loc) {
|
|
791
|
+
const { objectId } = await resolveElementLocator(this.conn, loc);
|
|
792
|
+
const { x, y } = await centerOf(this.conn, objectId);
|
|
793
|
+
await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
|
|
794
|
+
return { x, y };
|
|
795
|
+
}
|
|
796
|
+
async click(loc, opts) {
|
|
797
|
+
const button = opts?.button ?? "left";
|
|
798
|
+
const clickCount = opts?.clickCount ?? 1;
|
|
799
|
+
const { x, y } = await this.hover(loc);
|
|
800
|
+
await this.conn.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount });
|
|
801
|
+
await this.conn.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, clickCount });
|
|
802
|
+
return { x, y };
|
|
803
|
+
}
|
|
804
|
+
async drag(from, to) {
|
|
805
|
+
const fromPt = await this.hover(from);
|
|
806
|
+
const { objectId: dstId } = await resolveElementLocator(this.conn, to);
|
|
807
|
+
const toPt = await centerOf(this.conn, dstId);
|
|
808
|
+
await this.conn.send("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
|
|
809
|
+
await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: (fromPt.x + toPt.x) / 2, y: (fromPt.y + toPt.y) / 2, button: "left" });
|
|
810
|
+
await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: toPt.x, y: toPt.y, button: "left" });
|
|
811
|
+
await this.conn.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
|
|
812
|
+
return { from: fromPt, to: toPt };
|
|
813
|
+
}
|
|
814
|
+
async setValue(loc, value) {
|
|
815
|
+
const { objectId } = await resolveElementLocator(this.conn, loc);
|
|
816
|
+
await setValueOnObject(this.conn, objectId, value);
|
|
817
|
+
}
|
|
818
|
+
async typeText(loc, text) {
|
|
819
|
+
const { objectId } = await resolveElementLocator(this.conn, loc);
|
|
820
|
+
await focusElement(this.conn, objectId);
|
|
821
|
+
if (text.length)
|
|
822
|
+
await this.conn.send("Input.insertText", { text });
|
|
823
|
+
}
|
|
824
|
+
async pressKey(press) {
|
|
825
|
+
const spec = resolveKey(press.key);
|
|
826
|
+
let modBits = 0;
|
|
827
|
+
for (const m of press.modifiers ?? []) {
|
|
828
|
+
const bit = MODIFIER_BITS[m.toLowerCase()];
|
|
829
|
+
if (bit === undefined)
|
|
830
|
+
throw driverError("page-error", `unknown modifier '${m}'`);
|
|
831
|
+
modBits |= bit;
|
|
909
832
|
}
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
833
|
+
const suppressText = (modBits & ~MODIFIER_BITS.shift) !== 0;
|
|
834
|
+
const down = { type: "keyDown", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits };
|
|
835
|
+
if (spec.text && !suppressText)
|
|
836
|
+
down.text = spec.text;
|
|
837
|
+
await this.conn.send("Input.dispatchKeyEvent", down);
|
|
838
|
+
await this.conn.send("Input.dispatchKeyEvent", { type: "keyUp", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits });
|
|
839
|
+
}
|
|
840
|
+
async setFiles(loc, files) {
|
|
841
|
+
const { objectId } = await resolveElementLocator(this.conn, loc);
|
|
842
|
+
await this.conn.send("DOM.setFileInputFiles", { files: [...files], objectId });
|
|
843
|
+
}
|
|
844
|
+
async screenshot(opts) {
|
|
845
|
+
await this.ensureDomain("Page");
|
|
846
|
+
const format = opts?.format ?? "png";
|
|
847
|
+
const quality = format === "jpeg" ? opts?.quality ?? 80 : undefined;
|
|
848
|
+
const params = { format, captureBeyondViewport: true };
|
|
849
|
+
if (quality != null)
|
|
850
|
+
params.quality = quality;
|
|
851
|
+
if (opts?.clip)
|
|
852
|
+
params.clip = { ...await elementClip(this.conn, opts.clip), scale: 1 };
|
|
853
|
+
else if (opts?.fullPage)
|
|
854
|
+
params.clip = { ...await fullPageClip(this.conn), scale: 1 };
|
|
855
|
+
const { data } = await this.conn.send("Page.captureScreenshot", params);
|
|
856
|
+
return { data: new Uint8Array(Buffer.from(data, "base64")), format };
|
|
857
|
+
}
|
|
858
|
+
async emulate(opts) {
|
|
859
|
+
if (opts.clearOverrides) {
|
|
860
|
+
const c = () => {
|
|
913
861
|
return;
|
|
862
|
+
};
|
|
863
|
+
await this.conn.send("Emulation.clearDeviceMetricsOverride").catch(c);
|
|
864
|
+
await this.conn.send("Emulation.setUserAgentOverride", { userAgent: "" }).catch(c);
|
|
865
|
+
await this.conn.send("Emulation.setCPUThrottlingRate", { rate: 1 }).catch(c);
|
|
866
|
+
await this.conn.send("Emulation.setEmulatedMedia", { media: "", features: [] }).catch(c);
|
|
867
|
+
await this.ensureDomainSoft("Network");
|
|
868
|
+
await this.conn.send("Network.emulateNetworkConditions", { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 }).catch(c);
|
|
869
|
+
return { applied: [] };
|
|
870
|
+
}
|
|
871
|
+
const applied = [];
|
|
872
|
+
if (opts.width != null || opts.height != null) {
|
|
873
|
+
if (opts.width == null || opts.height == null) {
|
|
874
|
+
throw driverError("page-error", "emulate device metrics require both width and height");
|
|
875
|
+
}
|
|
876
|
+
await this.conn.send("Emulation.setDeviceMetricsOverride", {
|
|
877
|
+
width: opts.width,
|
|
878
|
+
height: opts.height,
|
|
879
|
+
deviceScaleFactor: opts.deviceScaleFactor ?? 0,
|
|
880
|
+
mobile: opts.mobile ?? false
|
|
914
881
|
});
|
|
915
|
-
|
|
882
|
+
applied.push("deviceMetrics");
|
|
883
|
+
}
|
|
884
|
+
if (opts.userAgent != null) {
|
|
885
|
+
await this.conn.send("Emulation.setUserAgentOverride", { userAgent: opts.userAgent });
|
|
886
|
+
applied.push("userAgent");
|
|
887
|
+
}
|
|
888
|
+
if (opts.cpuThrottlingRate != null) {
|
|
889
|
+
if (opts.cpuThrottlingRate < 1)
|
|
890
|
+
throw driverError("page-error", "cpuThrottlingRate must be >= 1");
|
|
891
|
+
await this.conn.send("Emulation.setCPUThrottlingRate", { rate: opts.cpuThrottlingRate });
|
|
892
|
+
applied.push("cpuThrottlingRate");
|
|
893
|
+
}
|
|
894
|
+
if (opts.media != null || opts.mediaFeatures != null) {
|
|
895
|
+
await this.conn.send("Emulation.setEmulatedMedia", { media: opts.media ?? "", features: opts.mediaFeatures ?? [] });
|
|
896
|
+
applied.push("emulatedMedia");
|
|
897
|
+
}
|
|
898
|
+
if (opts.networkConditions != null) {
|
|
899
|
+
const n = opts.networkConditions;
|
|
900
|
+
await this.ensureDomainSoft("Network");
|
|
901
|
+
await this.conn.send("Network.emulateNetworkConditions", {
|
|
916
902
|
offline: n.offline ?? false,
|
|
917
903
|
latency: n.latency ?? 0,
|
|
918
904
|
downloadThroughput: n.downloadThroughput ?? -1,
|
|
@@ -921,98 +907,474 @@ async function emulate(args = {}) {
|
|
|
921
907
|
});
|
|
922
908
|
applied.push("networkConditions");
|
|
923
909
|
}
|
|
924
|
-
|
|
925
|
-
|
|
910
|
+
return { applied };
|
|
911
|
+
}
|
|
912
|
+
static EVENT_MAP = {
|
|
913
|
+
navigated: { method: "Page.frameNavigated" },
|
|
914
|
+
console: { method: "Runtime.consoleAPICalled" },
|
|
915
|
+
"network.request": { method: "Network.requestWillBeSent", domain: "Network" },
|
|
916
|
+
"network.response": { method: "Network.responseReceived", domain: "Network" },
|
|
917
|
+
dialog: { method: "Page.javascriptDialogOpening" },
|
|
918
|
+
"page.closed": { method: "Inspector.detached", domain: "Inspector" }
|
|
919
|
+
};
|
|
920
|
+
on(event, handler) {
|
|
921
|
+
const spec = CdpPageDriver.EVENT_MAP[event];
|
|
922
|
+
if (spec.domain && !this.enabledDomains.has(spec.domain)) {
|
|
923
|
+
this.enabledDomains.add(spec.domain);
|
|
924
|
+
this.conn.send(`${spec.domain}.enable`);
|
|
926
925
|
}
|
|
927
|
-
return
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
926
|
+
return this.conn.on(spec.method, (params) => handler(params));
|
|
927
|
+
}
|
|
928
|
+
async waitForEvent(event, predicate, timeoutMs) {
|
|
929
|
+
const spec = CdpPageDriver.EVENT_MAP[event];
|
|
930
|
+
if (spec.domain)
|
|
931
|
+
await this.ensureDomain(spec.domain);
|
|
932
|
+
return this.conn.waitFor(spec.method, predicate, timeoutMs);
|
|
933
|
+
}
|
|
934
|
+
armDialog(accept, promptText, onHandled) {
|
|
935
|
+
return this.conn.on("Page.javascriptDialogOpening", (params) => {
|
|
936
|
+
const p = params;
|
|
937
|
+
this.conn.send("Page.handleJavaScriptDialog", { accept, ...promptText !== undefined ? { promptText } : {} }).then(() => onHandled({
|
|
938
|
+
type: p.type,
|
|
939
|
+
message: p.message,
|
|
940
|
+
url: p.url,
|
|
941
|
+
...p.defaultPrompt !== undefined ? { defaultPrompt: p.defaultPrompt } : {},
|
|
942
|
+
accept,
|
|
943
|
+
...promptText !== undefined ? { promptText } : {},
|
|
944
|
+
handled: true
|
|
945
|
+
})).catch(() => {
|
|
946
|
+
return;
|
|
947
|
+
});
|
|
948
948
|
});
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
height,
|
|
954
|
-
innerWidth: v?.w ?? width,
|
|
955
|
-
innerHeight: v?.h ?? height
|
|
956
|
-
};
|
|
957
|
-
});
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
// src/tools/dialogs.ts
|
|
961
|
-
function armDialogHandler(conn, accept, promptText, onHandled) {
|
|
962
|
-
return conn.on("Page.javascriptDialogOpening", (params) => {
|
|
963
|
-
const ev = params;
|
|
964
|
-
(async () => {
|
|
965
|
-
try {
|
|
966
|
-
await conn.send("Page.handleJavaScriptDialog", {
|
|
967
|
-
accept,
|
|
968
|
-
...promptText !== undefined ? { promptText } : {}
|
|
969
|
-
});
|
|
970
|
-
onHandled({
|
|
971
|
-
type: ev.type,
|
|
972
|
-
message: ev.message,
|
|
973
|
-
url: ev.url,
|
|
974
|
-
...ev.defaultPrompt !== undefined ? { defaultPrompt: ev.defaultPrompt } : {},
|
|
975
|
-
accept,
|
|
976
|
-
...promptText !== undefined ? { promptText } : {},
|
|
977
|
-
handled: true
|
|
978
|
-
});
|
|
979
|
-
} catch {}
|
|
980
|
-
})();
|
|
981
|
-
});
|
|
982
|
-
}
|
|
983
|
-
async function handleDialog(args) {
|
|
984
|
-
const { target, accept, promptText, timeoutMs = 15000, autoMs } = args;
|
|
985
|
-
const { conn } = await openPage(target);
|
|
986
|
-
try {
|
|
987
|
-
await conn.send("Page.enable");
|
|
988
|
-
if (autoMs !== undefined) {
|
|
949
|
+
}
|
|
950
|
+
async handleDialog(accept, promptText, opts) {
|
|
951
|
+
await this.ensureDomain("Page");
|
|
952
|
+
if (opts?.autoMs !== undefined) {
|
|
989
953
|
const handled = [];
|
|
990
|
-
const
|
|
991
|
-
await new Promise((resolve) => setTimeout(resolve, autoMs));
|
|
992
|
-
|
|
954
|
+
const off = this.armDialog(accept, promptText, (d) => handled.push(d));
|
|
955
|
+
await new Promise((resolve) => setTimeout(resolve, opts.autoMs));
|
|
956
|
+
off();
|
|
993
957
|
return { handled, count: handled.length };
|
|
994
958
|
}
|
|
995
|
-
|
|
959
|
+
const timeoutMs = opts?.timeoutMs ?? 15000;
|
|
960
|
+
return new Promise((resolve, reject) => {
|
|
996
961
|
const timer = setTimeout(() => {
|
|
997
|
-
|
|
998
|
-
reject(
|
|
962
|
+
off();
|
|
963
|
+
reject(driverError("timeout", `handleDialog: no dialog opened within ${timeoutMs}ms`));
|
|
999
964
|
}, timeoutMs);
|
|
1000
|
-
const
|
|
965
|
+
const off = this.armDialog(accept, promptText, (d) => {
|
|
1001
966
|
clearTimeout(timer);
|
|
1002
|
-
|
|
967
|
+
off();
|
|
1003
968
|
resolve(d);
|
|
1004
969
|
});
|
|
1005
970
|
});
|
|
971
|
+
}
|
|
972
|
+
async startBodyCapture() {
|
|
973
|
+
await this.ensureDomain("Network");
|
|
974
|
+
return async () => {
|
|
975
|
+
await this.conn.send("Network.disable").catch(() => {
|
|
976
|
+
return;
|
|
977
|
+
});
|
|
978
|
+
this.enabledDomains.delete("Network");
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
async getResponseBody(requestId) {
|
|
982
|
+
return this.conn.send("Network.getResponseBody", { requestId });
|
|
983
|
+
}
|
|
984
|
+
async intercept(rules) {
|
|
985
|
+
await this.conn.send("Fetch.enable", { patterns: rules.map((r) => ({ urlPattern: r.urlPattern })) });
|
|
986
|
+
let chain = Promise.resolve();
|
|
987
|
+
const off = this.conn.on("Fetch.requestPaused", (params) => {
|
|
988
|
+
const p = params;
|
|
989
|
+
chain = chain.then(async () => {
|
|
990
|
+
const rule = selectRule(rules, p.request.url, p.request.method);
|
|
991
|
+
if (!rule) {
|
|
992
|
+
await this.conn.send("Fetch.continueRequest", { requestId: p.requestId });
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
if (rule.delayMs)
|
|
996
|
+
await new Promise((r) => setTimeout(r, rule.delayMs));
|
|
997
|
+
const act = effectiveAction(rule, Math.random());
|
|
998
|
+
if (act === "fulfill")
|
|
999
|
+
await this.conn.send("Fetch.fulfillRequest", { ...buildFulfillParams(p.requestId, rule) });
|
|
1000
|
+
else if (act === "fail")
|
|
1001
|
+
await this.conn.send("Fetch.failRequest", { requestId: p.requestId, errorReason: rule.errorReason ?? "Failed" });
|
|
1002
|
+
else
|
|
1003
|
+
await this.conn.send("Fetch.continueRequest", { requestId: p.requestId });
|
|
1004
|
+
}).catch(() => {
|
|
1005
|
+
return;
|
|
1006
|
+
});
|
|
1007
|
+
});
|
|
1008
|
+
return async () => {
|
|
1009
|
+
off();
|
|
1010
|
+
await this.conn.send("Fetch.disable").catch(() => {
|
|
1011
|
+
return;
|
|
1012
|
+
});
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
async release() {
|
|
1016
|
+
if (this.released)
|
|
1017
|
+
return;
|
|
1018
|
+
this.released = true;
|
|
1019
|
+
this.conn.close();
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
async function withBrowserConn(fn) {
|
|
1023
|
+
const conn = await openBrowser();
|
|
1024
|
+
try {
|
|
1025
|
+
return await fn(conn);
|
|
1006
1026
|
} finally {
|
|
1007
1027
|
conn.close();
|
|
1008
1028
|
}
|
|
1009
1029
|
}
|
|
1030
|
+
function noSuchTarget(e) {
|
|
1031
|
+
throw driverError("no-such-target", e instanceof CdpError ? e.message : String(e));
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
class CdpBrowserDriver {
|
|
1035
|
+
scheme = "cdp";
|
|
1036
|
+
lifetime = "per-call";
|
|
1037
|
+
capabilities = CDP_CAPABILITIES;
|
|
1038
|
+
uidStability = "browser-node";
|
|
1039
|
+
snapshotFidelity = "accessibility-tree";
|
|
1040
|
+
async listPages(opts) {
|
|
1041
|
+
const targets = await listTargets();
|
|
1042
|
+
const filtered = opts?.all ? targets : targets.filter((t) => t.type === "page");
|
|
1043
|
+
return filtered.map((t) => ({ id: t.id, url: t.url, title: t.title, type: t.type }));
|
|
1044
|
+
}
|
|
1045
|
+
async newPage(url) {
|
|
1046
|
+
return withBrowserConn(async (conn) => {
|
|
1047
|
+
const { targetId } = await conn.send("Target.createTarget", { url: url ?? "about:blank" });
|
|
1048
|
+
if (!targetId)
|
|
1049
|
+
throw driverError("page-error", "Target.createTarget returned no targetId");
|
|
1050
|
+
return { id: targetId, url: url ?? "about:blank", title: "" };
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
async closePage(id) {
|
|
1054
|
+
return withBrowserConn(async (conn) => {
|
|
1055
|
+
const res = await conn.send("Target.closeTarget", { targetId: id });
|
|
1056
|
+
return { success: res.success ?? true };
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
async activatePage(id) {
|
|
1060
|
+
const target = await resolveTarget(id).catch(noSuchTarget);
|
|
1061
|
+
await withBrowserConn((conn) => conn.send("Target.activateTarget", { targetId: target.id }));
|
|
1062
|
+
return { id: target.id, url: target.url, title: target.title };
|
|
1063
|
+
}
|
|
1064
|
+
async page(selector) {
|
|
1065
|
+
const { conn, target } = await openPage(selector).catch(noSuchTarget);
|
|
1066
|
+
return new CdpPageDriver(conn, target, this);
|
|
1067
|
+
}
|
|
1068
|
+
async dispose() {}
|
|
1069
|
+
}
|
|
1070
|
+
function createCdpDriver() {
|
|
1071
|
+
return new CdpBrowserDriver;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// src/shared-tools.ts
|
|
1075
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
1076
|
+
import { join } from "node:path";
|
|
1077
|
+
var ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
1078
|
+
var STATE_DIR = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
|
|
1079
|
+
|
|
1080
|
+
class SharedToolError extends Error {
|
|
1081
|
+
}
|
|
1082
|
+
function target3(p) {
|
|
1083
|
+
return { id: p.id, url: p.url, title: p.title };
|
|
1084
|
+
}
|
|
1085
|
+
async function withPage2(driver, target, fn) {
|
|
1086
|
+
const page = await driver.page(target);
|
|
1087
|
+
try {
|
|
1088
|
+
return await fn(page);
|
|
1089
|
+
} finally {
|
|
1090
|
+
await page.release();
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
async function resolvePage(driver, selector) {
|
|
1094
|
+
const pages = await driver.listPages();
|
|
1095
|
+
if (selector === undefined || selector === "active") {
|
|
1096
|
+
if (!pages.length)
|
|
1097
|
+
throw new SharedToolError("no page targets open");
|
|
1098
|
+
return pages[0];
|
|
1099
|
+
}
|
|
1100
|
+
if (selector.startsWith("index:")) {
|
|
1101
|
+
const p = pages[Number(selector.slice(6))];
|
|
1102
|
+
if (!p)
|
|
1103
|
+
throw new SharedToolError(`no page target at index ${selector.slice(6)} (have ${pages.length})`);
|
|
1104
|
+
return p;
|
|
1105
|
+
}
|
|
1106
|
+
if (selector.startsWith("url:")) {
|
|
1107
|
+
const needle = selector.slice(4);
|
|
1108
|
+
const p = pages.find((x) => x.url.includes(needle));
|
|
1109
|
+
if (!p)
|
|
1110
|
+
throw new SharedToolError(`no page url containing '${needle}'`);
|
|
1111
|
+
return p;
|
|
1112
|
+
}
|
|
1113
|
+
if (selector.startsWith("title:")) {
|
|
1114
|
+
const needle = selector.slice(6);
|
|
1115
|
+
const p = pages.find((x) => x.title.includes(needle));
|
|
1116
|
+
if (!p)
|
|
1117
|
+
throw new SharedToolError(`no page title containing '${needle}'`);
|
|
1118
|
+
return p;
|
|
1119
|
+
}
|
|
1120
|
+
const exactPage = pages.find((x) => x.id === selector);
|
|
1121
|
+
if (exactPage)
|
|
1122
|
+
return exactPage;
|
|
1123
|
+
const all = await driver.listPages({ all: true });
|
|
1124
|
+
const exact = all.find((x) => x.id === selector);
|
|
1125
|
+
if (exact)
|
|
1126
|
+
return exact;
|
|
1127
|
+
throw new SharedToolError(`no target with id '${selector}'`);
|
|
1128
|
+
}
|
|
1129
|
+
function locatorOf(args) {
|
|
1130
|
+
const hasUid = args.uid !== undefined && args.uid !== null && args.uid !== "";
|
|
1131
|
+
const hasSelector = typeof args.selector === "string" && args.selector.length > 0;
|
|
1132
|
+
if (hasUid === hasSelector)
|
|
1133
|
+
throw new SharedToolError("provide exactly one of { uid } or { selector }");
|
|
1134
|
+
return hasUid ? { uid: args.uid } : { css: args.selector };
|
|
1135
|
+
}
|
|
1136
|
+
async function listPages(driver, args = {}) {
|
|
1137
|
+
const pages = await driver.listPages({ all: args.all });
|
|
1138
|
+
return { pages, count: pages.length };
|
|
1139
|
+
}
|
|
1140
|
+
async function newPage(driver, args = {}) {
|
|
1141
|
+
const p = await driver.newPage(args.url);
|
|
1142
|
+
return { targetId: p.id, url: p.url };
|
|
1143
|
+
}
|
|
1144
|
+
async function closePage(driver, args) {
|
|
1145
|
+
if (args.target === undefined || args.target === "")
|
|
1146
|
+
throw new SharedToolError("close_page requires an explicit target; refusing to guess which page to close");
|
|
1147
|
+
const p = await resolvePage(driver, args.target);
|
|
1148
|
+
const res = await driver.closePage(p.id);
|
|
1149
|
+
return { closed: p.id, success: res.success };
|
|
1150
|
+
}
|
|
1151
|
+
async function writeSelectedFile(id) {
|
|
1152
|
+
await mkdir(STATE_DIR, { recursive: true });
|
|
1153
|
+
await writeFile(join(STATE_DIR, "selected"), id, "utf8");
|
|
1154
|
+
}
|
|
1155
|
+
async function selectPage(driver, args) {
|
|
1156
|
+
if (args.target === undefined || args.target === "")
|
|
1157
|
+
throw new SharedToolError("select_page requires an explicit target");
|
|
1158
|
+
const p = await resolvePage(driver, args.target);
|
|
1159
|
+
const activated = await driver.activatePage(p.id);
|
|
1160
|
+
await writeSelectedFile(activated.id);
|
|
1161
|
+
return { selected: activated.id };
|
|
1162
|
+
}
|
|
1163
|
+
function toLegacyWaitedFor(w) {
|
|
1164
|
+
if (w === "committed")
|
|
1165
|
+
return "frameStoppedLoading";
|
|
1166
|
+
if (w === "timeout")
|
|
1167
|
+
return "navigate-only";
|
|
1168
|
+
return w;
|
|
1169
|
+
}
|
|
1170
|
+
async function navigatePage(driver, args) {
|
|
1171
|
+
const reload = args.reload === true;
|
|
1172
|
+
if (!reload && (!args.url || typeof args.url !== "string")) {
|
|
1173
|
+
throw new SharedToolError("navigate_page: 'url' is required (or pass reload:true to reload the current page)");
|
|
1174
|
+
}
|
|
1175
|
+
return withPage2(driver, args.target, async (page) => {
|
|
1176
|
+
const r = await page.navigate(args);
|
|
1177
|
+
return {
|
|
1178
|
+
url: r.url,
|
|
1179
|
+
frameId: r.contextId,
|
|
1180
|
+
...r.reloaded ? { reloaded: r.reloaded } : {},
|
|
1181
|
+
waitedFor: toLegacyWaitedFor(r.waitedFor)
|
|
1182
|
+
};
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
async function waitForText(driver, args) {
|
|
1186
|
+
if (typeof args.text !== "string" || args.text.length === 0)
|
|
1187
|
+
throw new SharedToolError("wait_for: 'text' is required");
|
|
1188
|
+
return withPage2(driver, args.target, async (page) => {
|
|
1189
|
+
const r = await page.waitForText(args.text, args.timeoutMs ?? 15000, args.pollMs ?? 250);
|
|
1190
|
+
return { found: true, waitedMs: r.elapsedMs };
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
async function evaluateScript(driver, args) {
|
|
1194
|
+
if (typeof args.expression !== "string" || args.expression.length === 0) {
|
|
1195
|
+
throw new SharedToolError("evaluateScript: 'expression' must be a non-empty string");
|
|
1196
|
+
}
|
|
1197
|
+
return withPage2(driver, args.target, (page) => page.evaluate(args.expression, { args: args.args, awaitPromise: args.awaitPromise ?? true }));
|
|
1198
|
+
}
|
|
1199
|
+
var BARE_TOKEN_EXTRAS = new Set(["disabled", "focused"]);
|
|
1200
|
+
var UNQUOTED_EXTRAS = new Set(["checked", "expanded", "url"]);
|
|
1201
|
+
function renderSnapshotLine(n, scheme) {
|
|
1202
|
+
const indent = " ".repeat(n.depth);
|
|
1203
|
+
const uidText = scheme === "cdp" && n.uid.startsWith("cdp:") ? n.uid.slice(4) : n.uid;
|
|
1204
|
+
const name = n.name !== undefined ? ` ${JSON.stringify(n.name)}` : "";
|
|
1205
|
+
const extrasToks = [];
|
|
1206
|
+
if (n.extras) {
|
|
1207
|
+
for (const [k, v] of Object.entries(n.extras)) {
|
|
1208
|
+
if (BARE_TOKEN_EXTRAS.has(k))
|
|
1209
|
+
extrasToks.push(k);
|
|
1210
|
+
else if (UNQUOTED_EXTRAS.has(k))
|
|
1211
|
+
extrasToks.push(`${k}=${v}`);
|
|
1212
|
+
else
|
|
1213
|
+
extrasToks.push(`${k}=${JSON.stringify(v)}`);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
const extra = extrasToks.length ? ` [${extrasToks.join(" ")}]` : "";
|
|
1217
|
+
return `${indent}[${uidText}] ${n.role}${name}${extra}`;
|
|
1218
|
+
}
|
|
1219
|
+
async function takeSnapshot(driver, args = {}) {
|
|
1220
|
+
return withPage2(driver, args.target, async (page) => {
|
|
1221
|
+
const nodes = await page.snapshot({ interactiveOnly: args.interactiveOnly ?? false });
|
|
1222
|
+
const scheme = page.browser.scheme;
|
|
1223
|
+
return { snapshot: nodes.map((n) => renderSnapshotLine(n, scheme)).join(`
|
|
1224
|
+
`), target: target3(page.info), nodeCount: nodes.length };
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
async function click(driver, args) {
|
|
1228
|
+
return withPage2(driver, args.target, async (page) => {
|
|
1229
|
+
const { x, y } = await page.click(locatorOf(args), { button: args.button ?? "left", clickCount: args.clickCount ?? 1 });
|
|
1230
|
+
return { clicked: true, x, y };
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
async function hover(driver, args) {
|
|
1234
|
+
return withPage2(driver, args.target, async (page) => {
|
|
1235
|
+
const { x, y } = await page.hover(locatorOf(args));
|
|
1236
|
+
return { hovered: true, x, y };
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
async function drag(driver, args) {
|
|
1240
|
+
if (!args.from || !args.to)
|
|
1241
|
+
throw new SharedToolError("drag requires { from } and { to }");
|
|
1242
|
+
return withPage2(driver, args.target, async (page) => {
|
|
1243
|
+
const { from, to } = await page.drag(locatorOf(args.from), locatorOf(args.to));
|
|
1244
|
+
return { dragged: true, from, to };
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
async function fill(driver, args) {
|
|
1248
|
+
if (typeof args.value !== "string")
|
|
1249
|
+
throw new SharedToolError("fill requires a string { value }");
|
|
1250
|
+
return withPage2(driver, args.target, async (page) => {
|
|
1251
|
+
await page.setValue(locatorOf(args), args.value);
|
|
1252
|
+
return { filled: true, value: args.value };
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
async function fillForm(driver, args) {
|
|
1256
|
+
if (!Array.isArray(args.fields) || args.fields.length === 0)
|
|
1257
|
+
throw new SharedToolError("fill_form requires a non-empty { fields } array");
|
|
1258
|
+
return withPage2(driver, args.target, async (page) => {
|
|
1259
|
+
let filled = 0;
|
|
1260
|
+
for (const f of args.fields) {
|
|
1261
|
+
if (typeof f.value !== "string")
|
|
1262
|
+
throw new SharedToolError("each fill_form field requires a string value");
|
|
1263
|
+
await page.setValue(locatorOf(f), f.value);
|
|
1264
|
+
filled++;
|
|
1265
|
+
}
|
|
1266
|
+
return { filled, fields: args.fields.length };
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
async function typeText(driver, args) {
|
|
1270
|
+
if (typeof args.text !== "string")
|
|
1271
|
+
throw new SharedToolError("type_text requires a string { text }");
|
|
1272
|
+
return withPage2(driver, args.target, async (page) => {
|
|
1273
|
+
await page.typeText(locatorOf(args), args.text);
|
|
1274
|
+
return { typed: true, text: args.text };
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
async function pressKey(driver, args) {
|
|
1278
|
+
if (typeof args.key !== "string" || !args.key.length)
|
|
1279
|
+
throw new SharedToolError("press_key requires a non-empty { key }");
|
|
1280
|
+
const modifiers = args.modifiers ?? [];
|
|
1281
|
+
return withPage2(driver, args.target, async (page) => {
|
|
1282
|
+
await page.pressKey({ key: args.key, modifiers });
|
|
1283
|
+
return { pressed: args.key, modifiers };
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
async function uploadFile(driver, args) {
|
|
1287
|
+
const files = Array.isArray(args.files) ? args.files : [args.files];
|
|
1288
|
+
if (!files.length || files.some((f) => typeof f !== "string" || !f.length)) {
|
|
1289
|
+
throw new SharedToolError("upload_file requires { files } as a non-empty path or array of paths");
|
|
1290
|
+
}
|
|
1291
|
+
return withPage2(driver, args.target, async (page) => {
|
|
1292
|
+
await page.setFiles(locatorOf(args), files);
|
|
1293
|
+
return { uploaded: files };
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
function stamp() {
|
|
1297
|
+
return new Date(Date.now()).toISOString().replace(/[:.]/g, "-");
|
|
1298
|
+
}
|
|
1299
|
+
async function takeScreenshot(driver, args = {}) {
|
|
1300
|
+
if (args.uid !== undefined && args.uid !== "" && args.selector)
|
|
1301
|
+
throw new SharedToolError("provide exactly one of uid or selector, not both");
|
|
1302
|
+
const format = args.format ?? "png";
|
|
1303
|
+
return withPage2(driver, args.target, async (page) => {
|
|
1304
|
+
const clip = args.uid !== undefined || args.selector ? locatorOf(args) : undefined;
|
|
1305
|
+
const { data, format: outFormat } = await page.screenshot({ format, quality: args.quality, fullPage: args.fullPage, ...clip ? { clip } : {} });
|
|
1306
|
+
if (data.byteLength === 0)
|
|
1307
|
+
throw new SharedToolError("captureScreenshot returned empty data");
|
|
1308
|
+
await mkdir(ARTIFACT_DIR, { recursive: true });
|
|
1309
|
+
const ext = outFormat === "jpeg" ? "jpg" : "png";
|
|
1310
|
+
const path = args.savePath ?? join(ARTIFACT_DIR, `screenshot-${page.info.id.slice(0, 8)}-${stamp()}.${ext}`);
|
|
1311
|
+
await writeFile(path, Buffer.from(data));
|
|
1312
|
+
const result = {
|
|
1313
|
+
path,
|
|
1314
|
+
bytes: data.byteLength,
|
|
1315
|
+
format: outFormat,
|
|
1316
|
+
target: target3(page.info)
|
|
1317
|
+
};
|
|
1318
|
+
if (args.returnBase64)
|
|
1319
|
+
result.base64 = Buffer.from(data).toString("base64");
|
|
1320
|
+
return result;
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
async function emulate(driver, args = {}) {
|
|
1324
|
+
return withPage2(driver, args.target, async (page) => {
|
|
1325
|
+
if (args.clearOverrides) {
|
|
1326
|
+
await page.emulate({ clearOverrides: true });
|
|
1327
|
+
return { target: target3(page.info), applied: [], cleared: true };
|
|
1328
|
+
}
|
|
1329
|
+
const { applied } = await page.emulate(args);
|
|
1330
|
+
if (applied.length === 0)
|
|
1331
|
+
throw new SharedToolError("emulate: no overrides specified (pass clearOverrides:true to reset)");
|
|
1332
|
+
return { target: target3(page.info), applied };
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
async function resizePage(driver, args) {
|
|
1336
|
+
if (!Number.isFinite(args.width) || !Number.isFinite(args.height) || args.width <= 0 || args.height <= 0) {
|
|
1337
|
+
throw new SharedToolError("resize_page requires positive numeric width and height");
|
|
1338
|
+
}
|
|
1339
|
+
return withPage2(driver, args.target, async (page) => {
|
|
1340
|
+
await page.emulate({ width: args.width, height: args.height, deviceScaleFactor: args.deviceScaleFactor, mobile: args.mobile });
|
|
1341
|
+
const v = await page.evaluate("({ w: window.innerWidth, h: window.innerHeight })");
|
|
1342
|
+
return { target: target3(page.info), width: args.width, height: args.height, innerWidth: v?.w ?? args.width, innerHeight: v?.h ?? args.height };
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
async function handleDialog(driver, args) {
|
|
1346
|
+
if (args.accept === undefined)
|
|
1347
|
+
throw new SharedToolError("handle_dialog requires { accept }");
|
|
1348
|
+
return withPage2(driver, args.target, (page) => page.handleDialog(args.accept, args.promptText, { timeoutMs: args.timeoutMs, autoMs: args.autoMs }));
|
|
1349
|
+
}
|
|
1350
|
+
var SHARED_TOOLS = {
|
|
1351
|
+
list_pages: listPages,
|
|
1352
|
+
new_page: newPage,
|
|
1353
|
+
close_page: closePage,
|
|
1354
|
+
select_page: selectPage,
|
|
1355
|
+
navigate_page: navigatePage,
|
|
1356
|
+
wait_for: waitForText,
|
|
1357
|
+
evaluate_script: evaluateScript,
|
|
1358
|
+
take_snapshot: takeSnapshot,
|
|
1359
|
+
click,
|
|
1360
|
+
hover,
|
|
1361
|
+
drag,
|
|
1362
|
+
fill,
|
|
1363
|
+
fill_form: fillForm,
|
|
1364
|
+
type_text: typeText,
|
|
1365
|
+
press_key: pressKey,
|
|
1366
|
+
upload_file: uploadFile,
|
|
1367
|
+
take_screenshot: takeScreenshot,
|
|
1368
|
+
emulate,
|
|
1369
|
+
resize_page: resizePage,
|
|
1370
|
+
handle_dialog: handleDialog
|
|
1371
|
+
};
|
|
1010
1372
|
|
|
1011
1373
|
// src/tools/console.ts
|
|
1012
1374
|
import { readFile } from "node:fs/promises";
|
|
1013
1375
|
|
|
1014
1376
|
// src/tools/recorder.ts
|
|
1015
|
-
import { mkdir as
|
|
1377
|
+
import { mkdir as mkdir2, appendFile, writeFile as writeFile2, copyFile } from "node:fs/promises";
|
|
1016
1378
|
var ARTIFACT_DIR2 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
1017
1379
|
function recFile(targetId) {
|
|
1018
1380
|
return `${ARTIFACT_DIR2}/rec-${targetId}.jsonl`;
|
|
@@ -1033,11 +1395,11 @@ var CONSOLE_EVENTS = ["Runtime.consoleAPICalled", "Runtime.exceptionThrown", "Lo
|
|
|
1033
1395
|
async function startRecorder(target, opts = {}) {
|
|
1034
1396
|
const network = opts.network ?? true;
|
|
1035
1397
|
const consoleCap = opts.console ?? true;
|
|
1036
|
-
await
|
|
1398
|
+
await mkdir2(ARTIFACT_DIR2, { recursive: true });
|
|
1037
1399
|
const { conn, target: resolved } = await openPage(target, { timeoutMs: opts.timeoutMs });
|
|
1038
1400
|
const file = opts.file ?? recFile(resolved.id);
|
|
1039
1401
|
if (opts.truncate) {
|
|
1040
|
-
await
|
|
1402
|
+
await writeFile2(file, "");
|
|
1041
1403
|
}
|
|
1042
1404
|
let chain = Promise.resolve();
|
|
1043
1405
|
let dropped = 0;
|
|
@@ -1367,651 +1729,2102 @@ async function getNetworkRequest(args = {}) {
|
|
|
1367
1729
|
} finally {
|
|
1368
1730
|
await cap.stop();
|
|
1369
1731
|
}
|
|
1370
|
-
}
|
|
1371
|
-
const target = await resolveTarget(args.target);
|
|
1372
|
-
const match = pick(await readNetworkRequests(recFile(target.id)));
|
|
1373
|
-
if (!match) {
|
|
1374
|
-
throw new CdpError(`no network request matched ${describeSelector()}; run list_network_requests with reload:true first` + (args.includeBody ? `, then fetch the body with the url selector (reload re-mints requestIds)` : ``));
|
|
1375
|
-
}
|
|
1376
|
-
if (args.includeBody && args.requestId) {
|
|
1377
|
-
return {
|
|
1378
|
-
...match,
|
|
1379
|
-
body: undefined,
|
|
1380
|
-
bodyUnavailableReason: "response bodies are only fetchable via a fresh reload capture, which re-mints requestIds; re-request with the `url` selector to retrieve the body"
|
|
1381
|
-
};
|
|
1382
|
-
}
|
|
1383
|
-
return match;
|
|
1732
|
+
}
|
|
1733
|
+
const target = await resolveTarget(args.target);
|
|
1734
|
+
const match = pick(await readNetworkRequests(recFile(target.id)));
|
|
1735
|
+
if (!match) {
|
|
1736
|
+
throw new CdpError(`no network request matched ${describeSelector()}; run list_network_requests with reload:true first` + (args.includeBody ? `, then fetch the body with the url selector (reload re-mints requestIds)` : ``));
|
|
1737
|
+
}
|
|
1738
|
+
if (args.includeBody && args.requestId) {
|
|
1739
|
+
return {
|
|
1740
|
+
...match,
|
|
1741
|
+
body: undefined,
|
|
1742
|
+
bodyUnavailableReason: "response bodies are only fetchable via a fresh reload capture, which re-mints requestIds; re-request with the `url` selector to retrieve the body"
|
|
1743
|
+
};
|
|
1744
|
+
}
|
|
1745
|
+
return match;
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
// src/tools/performance.ts
|
|
1749
|
+
import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3, stat, unlink } from "node:fs/promises";
|
|
1750
|
+
import { join as join2 } from "node:path";
|
|
1751
|
+
var ARTIFACT_DIR3 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
1752
|
+
var STATE_DIR2 = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
|
|
1753
|
+
var DEFAULT_CATEGORIES = [
|
|
1754
|
+
"devtools.timeline",
|
|
1755
|
+
"blink.user_timing",
|
|
1756
|
+
"loading",
|
|
1757
|
+
"disabled-by-default-devtools.timeline",
|
|
1758
|
+
"disabled-by-default-devtools.timeline.frame"
|
|
1759
|
+
];
|
|
1760
|
+
var liveTraces = new Map;
|
|
1761
|
+
function stamp2() {
|
|
1762
|
+
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
1763
|
+
}
|
|
1764
|
+
function shortId(id) {
|
|
1765
|
+
return id.slice(0, 8);
|
|
1766
|
+
}
|
|
1767
|
+
function stateFilePath() {
|
|
1768
|
+
return join2(STATE_DIR2, "trace-state.json");
|
|
1769
|
+
}
|
|
1770
|
+
function bufferTraceData(conn, sink) {
|
|
1771
|
+
return conn.on("Tracing.dataCollected", (params) => {
|
|
1772
|
+
const value = params.value;
|
|
1773
|
+
if (Array.isArray(value))
|
|
1774
|
+
sink.push(...value);
|
|
1775
|
+
});
|
|
1776
|
+
}
|
|
1777
|
+
async function beginTrace(conn, categories) {
|
|
1778
|
+
await conn.send("Tracing.start", {
|
|
1779
|
+
traceConfig: { includedCategories: categories },
|
|
1780
|
+
transferMode: "ReportEvents"
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
async function endTrace(conn, sink, timeoutMs = 30000) {
|
|
1784
|
+
const complete = conn.waitFor("Tracing.tracingComplete", undefined, timeoutMs);
|
|
1785
|
+
await conn.send("Tracing.end");
|
|
1786
|
+
await complete;
|
|
1787
|
+
return sink;
|
|
1788
|
+
}
|
|
1789
|
+
async function writeTrace(events, targetId) {
|
|
1790
|
+
await mkdir3(ARTIFACT_DIR3, { recursive: true });
|
|
1791
|
+
const path = join2(ARTIFACT_DIR3, `trace-${shortId(targetId)}-${stamp2()}.json`);
|
|
1792
|
+
const body = JSON.stringify({ traceEvents: events, metadata: { source: "cdp-toolkit" } });
|
|
1793
|
+
await writeFile3(path, body, "utf8");
|
|
1794
|
+
const { size } = await stat(path);
|
|
1795
|
+
return { path, bytes: size };
|
|
1796
|
+
}
|
|
1797
|
+
async function performanceStartTrace(args = {}) {
|
|
1798
|
+
const categories = args.categories?.length ? args.categories : DEFAULT_CATEGORIES;
|
|
1799
|
+
const { conn, target } = await openPage(args.target);
|
|
1800
|
+
if (liveTraces.has(target.id)) {
|
|
1801
|
+
conn.close();
|
|
1802
|
+
throw new CdpError(`a trace is already in progress for target ${shortId(target.id)}; call performanceStopTrace first`);
|
|
1803
|
+
}
|
|
1804
|
+
const events = [];
|
|
1805
|
+
bufferTraceData(conn, events);
|
|
1806
|
+
try {
|
|
1807
|
+
await beginTrace(conn, categories);
|
|
1808
|
+
} catch (err) {
|
|
1809
|
+
conn.close();
|
|
1810
|
+
throw err;
|
|
1811
|
+
}
|
|
1812
|
+
liveTraces.set(target.id, { conn, target, events, startedAt: Date.now() });
|
|
1813
|
+
await mkdir3(STATE_DIR2, { recursive: true });
|
|
1814
|
+
const state = {
|
|
1815
|
+
targetId: target.id,
|
|
1816
|
+
url: target.url,
|
|
1817
|
+
startedAt: Date.now(),
|
|
1818
|
+
pid: process.pid,
|
|
1819
|
+
categories
|
|
1820
|
+
};
|
|
1821
|
+
await writeFile3(stateFilePath(), JSON.stringify(state), "utf8");
|
|
1822
|
+
return {
|
|
1823
|
+
target: { id: target.id, url: target.url, title: target.title },
|
|
1824
|
+
categories,
|
|
1825
|
+
note: "Trace recording. Call performanceStopTrace in THIS process to finalize. Cross-process? use performanceTrace instead."
|
|
1826
|
+
};
|
|
1827
|
+
}
|
|
1828
|
+
async function performanceStopTrace(args = {}) {
|
|
1829
|
+
args.target;
|
|
1830
|
+
const entry = liveTraces.size === 1 ? [...liveTraces.values()][0] : undefined;
|
|
1831
|
+
if (!entry) {
|
|
1832
|
+
let hint = "";
|
|
1833
|
+
try {
|
|
1834
|
+
const raw = await readFile3(stateFilePath(), "utf8");
|
|
1835
|
+
const state = JSON.parse(raw);
|
|
1836
|
+
hint = state.pid === process.pid ? ` (state file lists target ${shortId(state.targetId)} but its live connection is gone)` : ` (trace was started by a DIFFERENT process pid=${state.pid}; live recordings cannot cross process boundaries)`;
|
|
1837
|
+
} catch {}
|
|
1838
|
+
throw new CdpError(`no in-process trace to stop${hint}. Use performanceTrace for a robust single-call trace.`);
|
|
1839
|
+
}
|
|
1840
|
+
const { conn, target, events } = entry;
|
|
1841
|
+
try {
|
|
1842
|
+
await endTrace(conn, events);
|
|
1843
|
+
} finally {
|
|
1844
|
+
conn.close();
|
|
1845
|
+
liveTraces.delete(target.id);
|
|
1846
|
+
await unlink(stateFilePath()).catch(() => {});
|
|
1847
|
+
}
|
|
1848
|
+
const written = await writeTrace(events, target.id);
|
|
1849
|
+
const metrics = analyzeEvents(events);
|
|
1850
|
+
return { ...written, events: events.length, metrics };
|
|
1851
|
+
}
|
|
1852
|
+
async function performanceTrace(args = {}) {
|
|
1853
|
+
const durationMs = args.durationMs ?? 3000;
|
|
1854
|
+
const categories = args.categories?.length ? args.categories : DEFAULT_CATEGORIES;
|
|
1855
|
+
const { conn, target } = await openPage(args.target);
|
|
1856
|
+
const events = [];
|
|
1857
|
+
bufferTraceData(conn, events);
|
|
1858
|
+
try {
|
|
1859
|
+
await conn.send("Page.enable");
|
|
1860
|
+
await beginTrace(conn, categories);
|
|
1861
|
+
if (args.navigateTo) {
|
|
1862
|
+
const loaded = conn.waitFor("Page.loadEventFired", undefined, durationMs + 5000);
|
|
1863
|
+
await conn.send("Page.navigate", { url: args.navigateTo });
|
|
1864
|
+
await loaded.catch(() => {});
|
|
1865
|
+
} else if (args.reload) {
|
|
1866
|
+
const loaded = conn.waitFor("Page.loadEventFired", undefined, durationMs + 5000);
|
|
1867
|
+
await conn.send("Page.reload", { ignoreCache: false });
|
|
1868
|
+
await loaded.catch(() => {});
|
|
1869
|
+
}
|
|
1870
|
+
await new Promise((r) => setTimeout(r, durationMs));
|
|
1871
|
+
await endTrace(conn, events);
|
|
1872
|
+
} finally {
|
|
1873
|
+
conn.close();
|
|
1874
|
+
}
|
|
1875
|
+
const written = await writeTrace(events, target.id);
|
|
1876
|
+
const metrics = analyzeEvents(events);
|
|
1877
|
+
return { ...written, events: events.length, metrics, target: { id: target.id, url: target.url } };
|
|
1878
|
+
}
|
|
1879
|
+
function analyzeEvents(events) {
|
|
1880
|
+
let navigationStartUs;
|
|
1881
|
+
let fcpUs;
|
|
1882
|
+
let lcpUs;
|
|
1883
|
+
let dclUs;
|
|
1884
|
+
let loadUs;
|
|
1885
|
+
let cls = 0;
|
|
1886
|
+
let layoutShifts = 0;
|
|
1887
|
+
let longTasks = 0;
|
|
1888
|
+
let longTasksTotalMs = 0;
|
|
1889
|
+
let totalBlockingTimeMs = 0;
|
|
1890
|
+
for (const e of events) {
|
|
1891
|
+
const name = e.name;
|
|
1892
|
+
if (!name)
|
|
1893
|
+
continue;
|
|
1894
|
+
if (name === "navigationStart") {
|
|
1895
|
+
if (typeof e.ts === "number" && (navigationStartUs === undefined || e.ts < navigationStartUs)) {
|
|
1896
|
+
navigationStartUs = e.ts;
|
|
1897
|
+
}
|
|
1898
|
+
continue;
|
|
1899
|
+
}
|
|
1900
|
+
if ((name === "firstContentfulPaint" || name === "FirstContentfulPaint") && typeof e.ts === "number") {
|
|
1901
|
+
if (fcpUs === undefined || e.ts < fcpUs)
|
|
1902
|
+
fcpUs = e.ts;
|
|
1903
|
+
continue;
|
|
1904
|
+
}
|
|
1905
|
+
if (name === "largestContentfulPaint::Candidate" && typeof e.ts === "number") {
|
|
1906
|
+
if (lcpUs === undefined || e.ts > lcpUs)
|
|
1907
|
+
lcpUs = e.ts;
|
|
1908
|
+
continue;
|
|
1909
|
+
}
|
|
1910
|
+
if ((name === "domContentLoadedEventEnd" || name === "DOMContentLoaded") && typeof e.ts === "number") {
|
|
1911
|
+
if (dclUs === undefined || e.ts > dclUs)
|
|
1912
|
+
dclUs = e.ts;
|
|
1913
|
+
continue;
|
|
1914
|
+
}
|
|
1915
|
+
if ((name === "loadEventEnd" || name === "MarkLoad") && typeof e.ts === "number") {
|
|
1916
|
+
if (loadUs === undefined || e.ts > loadUs)
|
|
1917
|
+
loadUs = e.ts;
|
|
1918
|
+
continue;
|
|
1919
|
+
}
|
|
1920
|
+
if (name === "LayoutShift") {
|
|
1921
|
+
const data = e.args?.data;
|
|
1922
|
+
const hadRecentInput = data?.had_recent_input === true;
|
|
1923
|
+
const score = typeof data?.score === "number" ? data.score : undefined;
|
|
1924
|
+
if (!hadRecentInput && typeof score === "number") {
|
|
1925
|
+
cls += score;
|
|
1926
|
+
layoutShifts += 1;
|
|
1927
|
+
}
|
|
1928
|
+
continue;
|
|
1929
|
+
}
|
|
1930
|
+
if (name === "RunTask" && typeof e.dur === "number") {
|
|
1931
|
+
const durMs = e.dur / 1000;
|
|
1932
|
+
if (durMs > 50) {
|
|
1933
|
+
longTasks += 1;
|
|
1934
|
+
longTasksTotalMs += durMs;
|
|
1935
|
+
totalBlockingTimeMs += durMs - 50;
|
|
1936
|
+
}
|
|
1937
|
+
continue;
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
const relMs = (us) => us !== undefined && navigationStartUs !== undefined ? (us - navigationStartUs) / 1000 : undefined;
|
|
1941
|
+
const metrics = {
|
|
1942
|
+
navigationStartUs,
|
|
1943
|
+
fcpMs: relMs(fcpUs),
|
|
1944
|
+
lcpMs: relMs(lcpUs),
|
|
1945
|
+
domContentLoadedMs: relMs(dclUs),
|
|
1946
|
+
loadMs: relMs(loadUs),
|
|
1947
|
+
cls: layoutShifts > 0 ? Number(cls.toFixed(4)) : 0,
|
|
1948
|
+
totalBlockingTimeMs: Number(totalBlockingTimeMs.toFixed(2))
|
|
1949
|
+
};
|
|
1950
|
+
return {
|
|
1951
|
+
metrics,
|
|
1952
|
+
longTasks,
|
|
1953
|
+
longTasksTotalMs: Number(longTasksTotalMs.toFixed(2)),
|
|
1954
|
+
layoutShifts,
|
|
1955
|
+
eventCount: events.length
|
|
1956
|
+
};
|
|
1957
|
+
}
|
|
1958
|
+
async function performanceAnalyzeInsight(args = {}) {
|
|
1959
|
+
if (!args.tracePath) {
|
|
1960
|
+
throw new CdpError("performanceAnalyzeInsight requires { tracePath } pointing at a trace JSON file");
|
|
1961
|
+
}
|
|
1962
|
+
let raw;
|
|
1963
|
+
try {
|
|
1964
|
+
raw = await readFile3(args.tracePath, "utf8");
|
|
1965
|
+
} catch (err) {
|
|
1966
|
+
throw new CdpError(`cannot read trace file '${args.tracePath}': ${err.message}`);
|
|
1967
|
+
}
|
|
1968
|
+
let parsed;
|
|
1969
|
+
try {
|
|
1970
|
+
parsed = JSON.parse(raw);
|
|
1971
|
+
} catch (err) {
|
|
1972
|
+
throw new CdpError(`trace file '${args.tracePath}' is not valid JSON: ${err.message}`);
|
|
1973
|
+
}
|
|
1974
|
+
const events = Array.isArray(parsed) ? parsed : Array.isArray(parsed.traceEvents) ? parsed.traceEvents : (() => {
|
|
1975
|
+
throw new CdpError(`trace file '${args.tracePath}' has no traceEvents array`);
|
|
1976
|
+
})();
|
|
1977
|
+
return { ...analyzeEvents(events), tracePath: args.tracePath };
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
// src/tools/heap.ts
|
|
1981
|
+
import { mkdir as mkdir4, writeFile as writeFile4 } from "node:fs/promises";
|
|
1982
|
+
import { join as join3 } from "node:path";
|
|
1983
|
+
var ARTIFACT_DIR4 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
1984
|
+
function stamp3(targetId) {
|
|
1985
|
+
const short = targetId.slice(0, 8);
|
|
1986
|
+
const iso = new Date().toISOString().replace(/[:.]/g, "-");
|
|
1987
|
+
return `${short}-${iso}`;
|
|
1988
|
+
}
|
|
1989
|
+
async function takeHeapsnapshot(args = {}) {
|
|
1990
|
+
return withPage(args.target, async (conn, target) => {
|
|
1991
|
+
await conn.send("HeapProfiler.enable");
|
|
1992
|
+
const parts = [];
|
|
1993
|
+
const off = conn.on("HeapProfiler.addHeapSnapshotChunk", (params) => {
|
|
1994
|
+
const chunk = params.chunk;
|
|
1995
|
+
if (typeof chunk === "string")
|
|
1996
|
+
parts.push(chunk);
|
|
1997
|
+
});
|
|
1998
|
+
try {
|
|
1999
|
+
await conn.send("HeapProfiler.takeHeapSnapshot", { reportProgress: false, captureNumericValue: false }, { timeoutMs: 120000 });
|
|
2000
|
+
} finally {
|
|
2001
|
+
off();
|
|
2002
|
+
}
|
|
2003
|
+
const snapshot = parts.join("");
|
|
2004
|
+
if (snapshot.length === 0) {
|
|
2005
|
+
throw new Error("HeapProfiler.takeHeapSnapshot produced no chunks");
|
|
2006
|
+
}
|
|
2007
|
+
await mkdir4(ARTIFACT_DIR4, { recursive: true });
|
|
2008
|
+
const path = args.savePath ? args.savePath.startsWith("/") ? args.savePath : join3(ARTIFACT_DIR4, args.savePath) : join3(ARTIFACT_DIR4, `take_heapsnapshot-${stamp3(target.id)}.heapsnapshot`);
|
|
2009
|
+
await writeFile4(path, snapshot, "utf8");
|
|
2010
|
+
const bytes = Buffer.byteLength(snapshot, "utf8");
|
|
2011
|
+
return {
|
|
2012
|
+
path,
|
|
2013
|
+
bytes,
|
|
2014
|
+
chunks: parts.length,
|
|
2015
|
+
target: { id: target.id, url: target.url, title: target.title }
|
|
2016
|
+
};
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
// src/tools/lighthouse.ts
|
|
2021
|
+
import { spawn } from "node:child_process";
|
|
2022
|
+
import { mkdir as mkdir5, readFile as readFile4, stat as stat2 } from "node:fs/promises";
|
|
2023
|
+
var ARTIFACT_DIR5 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
2024
|
+
function cdpPort() {
|
|
2025
|
+
const base = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
|
|
2026
|
+
const m = base.match(/:(\d+)/);
|
|
2027
|
+
return m?.[1] ? Number(m[1]) : 9222;
|
|
2028
|
+
}
|
|
2029
|
+
function stamp4() {
|
|
2030
|
+
return new Date(Date.now()).toISOString().replace(/[:.]/g, "-");
|
|
2031
|
+
}
|
|
2032
|
+
function shortHost(url) {
|
|
2033
|
+
try {
|
|
2034
|
+
const u = new URL(url);
|
|
2035
|
+
const h = u.hostname || u.protocol.replace(/:$/, "");
|
|
2036
|
+
return (h || "page").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 24);
|
|
2037
|
+
} catch {
|
|
2038
|
+
return "page";
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
function runProcess(cmd, args, opts) {
|
|
2042
|
+
return new Promise((resolve) => {
|
|
2043
|
+
let stdout = "";
|
|
2044
|
+
let stderr = "";
|
|
2045
|
+
let settled = false;
|
|
2046
|
+
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
2047
|
+
const timer = setTimeout(() => {
|
|
2048
|
+
if (settled)
|
|
2049
|
+
return;
|
|
2050
|
+
try {
|
|
2051
|
+
child.kill("SIGKILL");
|
|
2052
|
+
} catch {}
|
|
2053
|
+
}, opts.timeoutMs);
|
|
2054
|
+
child.stdout.on("data", (d) => {
|
|
2055
|
+
stdout += d.toString();
|
|
2056
|
+
});
|
|
2057
|
+
child.stderr.on("data", (d) => {
|
|
2058
|
+
stderr += d.toString();
|
|
2059
|
+
});
|
|
2060
|
+
child.on("error", (err) => {
|
|
2061
|
+
if (settled)
|
|
2062
|
+
return;
|
|
2063
|
+
settled = true;
|
|
2064
|
+
clearTimeout(timer);
|
|
2065
|
+
resolve({ code: null, signal: null, stdout, stderr, spawnError: err });
|
|
2066
|
+
});
|
|
2067
|
+
child.on("close", (code, signal) => {
|
|
2068
|
+
if (settled)
|
|
2069
|
+
return;
|
|
2070
|
+
settled = true;
|
|
2071
|
+
clearTimeout(timer);
|
|
2072
|
+
resolve({ code, signal, stdout, stderr });
|
|
2073
|
+
});
|
|
2074
|
+
});
|
|
2075
|
+
}
|
|
2076
|
+
async function lighthouseAudit(args) {
|
|
2077
|
+
if (!args || typeof args.url !== "string" || args.url.trim() === "") {
|
|
2078
|
+
throw new CdpError("lighthouse_audit requires a non-empty 'url'");
|
|
2079
|
+
}
|
|
2080
|
+
const url = args.url.trim();
|
|
2081
|
+
const timeoutMs = args.timeoutMs ?? 120000;
|
|
2082
|
+
const port = cdpPort();
|
|
2083
|
+
await mkdir5(ARTIFACT_DIR5, { recursive: true });
|
|
2084
|
+
const outPath = args.savePath ?? `${ARTIFACT_DIR5}/lighthouse-${shortHost(url)}-${stamp4()}.json`;
|
|
2085
|
+
const formFactor = args.formFactor ?? "desktop";
|
|
2086
|
+
const lhArgs = [
|
|
2087
|
+
"--yes",
|
|
2088
|
+
"lighthouse",
|
|
2089
|
+
url,
|
|
2090
|
+
`--port=${port}`,
|
|
2091
|
+
"--output=json",
|
|
2092
|
+
`--output-path=${outPath}`,
|
|
2093
|
+
"--quiet",
|
|
2094
|
+
"--chrome-flags=--headless=new"
|
|
2095
|
+
];
|
|
2096
|
+
if (formFactor === "desktop") {
|
|
2097
|
+
lhArgs.push("--preset=desktop");
|
|
2098
|
+
}
|
|
2099
|
+
if (Array.isArray(args.categories) && args.categories.length > 0) {
|
|
2100
|
+
lhArgs.push(`--only-categories=${args.categories.join(",")}`);
|
|
2101
|
+
}
|
|
2102
|
+
const res = await runProcess("npx", lhArgs, { timeoutMs });
|
|
2103
|
+
if (res.spawnError) {
|
|
2104
|
+
const code = res.spawnError.code;
|
|
2105
|
+
if (code === "ENOENT") {
|
|
2106
|
+
throw new CdpError("lighthouse_audit requires 'npx' (Node.js) on PATH to fetch and run the Lighthouse CLI; 'npx' was not found.");
|
|
2107
|
+
}
|
|
2108
|
+
throw new CdpError(`lighthouse_audit failed to spawn npx/lighthouse: ${res.spawnError.message}`);
|
|
2109
|
+
}
|
|
2110
|
+
let raw;
|
|
2111
|
+
try {
|
|
2112
|
+
raw = await readFile4(outPath, "utf8");
|
|
2113
|
+
} catch {
|
|
2114
|
+
raw = undefined;
|
|
2115
|
+
}
|
|
2116
|
+
if (raw === undefined) {
|
|
2117
|
+
if (res.signal === "SIGKILL") {
|
|
2118
|
+
throw new CdpError(`lighthouse_audit timed out after ${timeoutMs}ms (no report written). stderr: ${truncate(res.stderr)}`);
|
|
2119
|
+
}
|
|
2120
|
+
const hint = /not found|could not determine executable|npm ERR|E404|ERR_MODULE/i.test(res.stderr) ? " The Lighthouse CLI could not be fetched/installed via npx — ensure network access and that 'lighthouse' is installable." : "";
|
|
2121
|
+
throw new CdpError(`lighthouse_audit produced no report (exit code ${res.code}).${hint} stderr: ${truncate(res.stderr)}`);
|
|
2122
|
+
}
|
|
2123
|
+
let report;
|
|
2124
|
+
try {
|
|
2125
|
+
report = JSON.parse(raw);
|
|
2126
|
+
} catch (e) {
|
|
2127
|
+
throw new CdpError(`lighthouse_audit wrote a report that is not valid JSON (${e.message})`);
|
|
2128
|
+
}
|
|
2129
|
+
if (report.runtimeError?.code && report.runtimeError.code !== "NO_ERROR") {
|
|
2130
|
+
throw new CdpError(`lighthouse_audit runtime error [${report.runtimeError.code}]: ${report.runtimeError.message ?? "unknown"}`);
|
|
2131
|
+
}
|
|
2132
|
+
const categories = {};
|
|
2133
|
+
for (const [key, cat] of Object.entries(report.categories ?? {})) {
|
|
2134
|
+
categories[key] = cat?.score ?? null;
|
|
2135
|
+
}
|
|
2136
|
+
if (Object.keys(categories).length === 0) {
|
|
2137
|
+
throw new CdpError(`lighthouse_audit report contained no category scores (exit code ${res.code}). stderr: ${truncate(res.stderr)}`);
|
|
2138
|
+
}
|
|
2139
|
+
const { size } = await stat2(outPath);
|
|
2140
|
+
return {
|
|
2141
|
+
path: outPath,
|
|
2142
|
+
bytes: size,
|
|
2143
|
+
url,
|
|
2144
|
+
finalUrl: report.finalDisplayedUrl ?? report.finalUrl,
|
|
2145
|
+
lighthouseVersion: report.lighthouseVersion,
|
|
2146
|
+
fetchTime: report.fetchTime,
|
|
2147
|
+
categories,
|
|
2148
|
+
warnings: Array.isArray(report.runWarnings) && report.runWarnings.length > 0 ? report.runWarnings : undefined
|
|
2149
|
+
};
|
|
2150
|
+
}
|
|
2151
|
+
function truncate(s, max = 600) {
|
|
2152
|
+
const t = s.trim();
|
|
2153
|
+
return t.length > max ? `${t.slice(0, max)}…` : t;
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
// src/index.ts
|
|
2157
|
+
var cdpDriver = createCdpDriver();
|
|
2158
|
+
function onCdp(fn) {
|
|
2159
|
+
return (args) => fn(cdpDriver, args);
|
|
2160
|
+
}
|
|
2161
|
+
var TOOLS = {
|
|
2162
|
+
list_pages: onCdp(SHARED_TOOLS.list_pages),
|
|
2163
|
+
new_page: onCdp(SHARED_TOOLS.new_page),
|
|
2164
|
+
close_page: onCdp(SHARED_TOOLS.close_page),
|
|
2165
|
+
select_page: onCdp(SHARED_TOOLS.select_page),
|
|
2166
|
+
navigate_page: onCdp(SHARED_TOOLS.navigate_page),
|
|
2167
|
+
wait_for: onCdp(SHARED_TOOLS.wait_for),
|
|
2168
|
+
evaluate_script: onCdp(SHARED_TOOLS.evaluate_script),
|
|
2169
|
+
take_snapshot: onCdp(SHARED_TOOLS.take_snapshot),
|
|
2170
|
+
click: onCdp(SHARED_TOOLS.click),
|
|
2171
|
+
hover: onCdp(SHARED_TOOLS.hover),
|
|
2172
|
+
drag: onCdp(SHARED_TOOLS.drag),
|
|
2173
|
+
fill: onCdp(SHARED_TOOLS.fill),
|
|
2174
|
+
fill_form: onCdp(SHARED_TOOLS.fill_form),
|
|
2175
|
+
type_text: onCdp(SHARED_TOOLS.type_text),
|
|
2176
|
+
press_key: onCdp(SHARED_TOOLS.press_key),
|
|
2177
|
+
upload_file: onCdp(SHARED_TOOLS.upload_file),
|
|
2178
|
+
take_screenshot: onCdp(SHARED_TOOLS.take_screenshot),
|
|
2179
|
+
emulate: onCdp(SHARED_TOOLS.emulate),
|
|
2180
|
+
resize_page: onCdp(SHARED_TOOLS.resize_page),
|
|
2181
|
+
handle_dialog: onCdp(SHARED_TOOLS.handle_dialog),
|
|
2182
|
+
list_console_messages: listConsoleMessages,
|
|
2183
|
+
get_console_message: getConsoleMessage,
|
|
2184
|
+
list_network_requests: listNetworkRequests,
|
|
2185
|
+
get_network_request: getNetworkRequest,
|
|
2186
|
+
performance_start_trace: performanceStartTrace,
|
|
2187
|
+
performance_stop_trace: performanceStopTrace,
|
|
2188
|
+
performance_analyze_insight: performanceAnalyzeInsight,
|
|
2189
|
+
performance_trace: performanceTrace,
|
|
2190
|
+
take_heapsnapshot: takeHeapsnapshot,
|
|
2191
|
+
lighthouse_audit: lighthouseAudit,
|
|
2192
|
+
mock_request: mockRequest,
|
|
2193
|
+
list_mocks: listMocks,
|
|
2194
|
+
clear_mocks: clearMocks
|
|
2195
|
+
};
|
|
2196
|
+
var TOOL_NAMES = Object.keys(TOOLS);
|
|
2197
|
+
|
|
2198
|
+
// src/bidi/client.ts
|
|
2199
|
+
var DEFAULT_TIMEOUT_MS2 = Number(process.env.BIDI_TIMEOUT_MS ?? 15000);
|
|
2200
|
+
|
|
2201
|
+
class BidiError extends Error {
|
|
2202
|
+
code;
|
|
2203
|
+
data;
|
|
2204
|
+
constructor(message, code, data) {
|
|
2205
|
+
super(message);
|
|
2206
|
+
this.code = code;
|
|
2207
|
+
this.data = data;
|
|
2208
|
+
this.name = "BidiError";
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
class BidiConnection {
|
|
2213
|
+
wsUrl;
|
|
2214
|
+
opts;
|
|
2215
|
+
ws;
|
|
2216
|
+
nextId = 0;
|
|
2217
|
+
sessId;
|
|
2218
|
+
caps;
|
|
2219
|
+
closed = false;
|
|
2220
|
+
refCount = 0;
|
|
2221
|
+
pending = new Map;
|
|
2222
|
+
listeners = new Map;
|
|
2223
|
+
constructor(wsUrl, opts = {}) {
|
|
2224
|
+
this.wsUrl = wsUrl;
|
|
2225
|
+
this.opts = opts;
|
|
2226
|
+
}
|
|
2227
|
+
get sessionId() {
|
|
2228
|
+
return this.sessId;
|
|
2229
|
+
}
|
|
2230
|
+
get capabilities() {
|
|
2231
|
+
return this.caps;
|
|
2232
|
+
}
|
|
2233
|
+
get holders() {
|
|
2234
|
+
return this.refCount;
|
|
2235
|
+
}
|
|
2236
|
+
connect() {
|
|
2237
|
+
return new Promise((resolve, reject) => {
|
|
2238
|
+
const ws = new WebSocket(this.wsUrl);
|
|
2239
|
+
this.ws = ws;
|
|
2240
|
+
const openTimer = setTimeout(() => reject(new BidiError(`connect timeout: ${this.wsUrl}`)), this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2);
|
|
2241
|
+
ws.onopen = () => {
|
|
2242
|
+
clearTimeout(openTimer);
|
|
2243
|
+
resolve(this);
|
|
2244
|
+
};
|
|
2245
|
+
ws.onerror = (e) => {
|
|
2246
|
+
clearTimeout(openTimer);
|
|
2247
|
+
const msg = e?.message ?? "websocket error";
|
|
2248
|
+
if (this.pending.size === 0)
|
|
2249
|
+
reject(new BidiError(msg));
|
|
2250
|
+
this.rejectAll(new BidiError(msg));
|
|
2251
|
+
};
|
|
2252
|
+
ws.onclose = () => {
|
|
2253
|
+
this.closed = true;
|
|
2254
|
+
this.rejectAll(new BidiError("connection closed"));
|
|
2255
|
+
};
|
|
2256
|
+
ws.onmessage = (ev) => this.onMessage(String(ev.data));
|
|
2257
|
+
});
|
|
2258
|
+
}
|
|
2259
|
+
async newSession(capabilities = {}) {
|
|
2260
|
+
const result = await this.send("session.new", { capabilities });
|
|
2261
|
+
this.sessId = result.sessionId;
|
|
2262
|
+
this.caps = result.capabilities;
|
|
2263
|
+
return result.sessionId;
|
|
2264
|
+
}
|
|
2265
|
+
onMessage(raw) {
|
|
2266
|
+
let msg;
|
|
2267
|
+
try {
|
|
2268
|
+
msg = JSON.parse(raw);
|
|
2269
|
+
} catch {
|
|
2270
|
+
return;
|
|
2271
|
+
}
|
|
2272
|
+
if (msg.type === "success" || msg.type === "error") {
|
|
2273
|
+
const id = msg.id;
|
|
2274
|
+
if (typeof id !== "number" || !this.pending.has(id))
|
|
2275
|
+
return;
|
|
2276
|
+
const p = this.pending.get(id);
|
|
2277
|
+
clearTimeout(p.timer);
|
|
2278
|
+
this.pending.delete(id);
|
|
2279
|
+
p.resolve(msg);
|
|
2280
|
+
return;
|
|
2281
|
+
}
|
|
2282
|
+
if (msg.type === "event") {
|
|
2283
|
+
const set = this.listeners.get(msg.method);
|
|
2284
|
+
if (set)
|
|
2285
|
+
for (const h of set)
|
|
2286
|
+
h(msg.params);
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
rejectAll(err) {
|
|
2290
|
+
for (const [, p] of this.pending) {
|
|
2291
|
+
clearTimeout(p.timer);
|
|
2292
|
+
p.reject(err);
|
|
2293
|
+
}
|
|
2294
|
+
this.pending.clear();
|
|
2295
|
+
}
|
|
2296
|
+
send(method, params, opts = {}) {
|
|
2297
|
+
if (this.closed || !this.ws)
|
|
2298
|
+
return Promise.reject(new BidiError("connection not open"));
|
|
2299
|
+
const id = ++this.nextId;
|
|
2300
|
+
const timeoutMs = opts.timeoutMs ?? this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
|
|
2301
|
+
const payload = { id, method, params };
|
|
2302
|
+
return new Promise((resolve, reject) => {
|
|
2303
|
+
const start = Date.now();
|
|
2304
|
+
const timer = setTimeout(() => {
|
|
2305
|
+
this.pending.delete(id);
|
|
2306
|
+
const elapsed = Date.now() - start;
|
|
2307
|
+
reject(new BidiError(`'${method}' timed out after ${elapsed}ms`));
|
|
2308
|
+
}, timeoutMs);
|
|
2309
|
+
this.pending.set(id, {
|
|
2310
|
+
method,
|
|
2311
|
+
start,
|
|
2312
|
+
timer,
|
|
2313
|
+
resolve: (raw) => {
|
|
2314
|
+
if (raw.type === "error")
|
|
2315
|
+
reject(new BidiError(`${method}: ${raw.message}`, raw.error));
|
|
2316
|
+
else
|
|
2317
|
+
resolve(raw.result);
|
|
2318
|
+
},
|
|
2319
|
+
reject
|
|
2320
|
+
});
|
|
2321
|
+
this.ws.send(JSON.stringify(payload));
|
|
2322
|
+
});
|
|
2323
|
+
}
|
|
2324
|
+
on(event, handler) {
|
|
2325
|
+
let set = this.listeners.get(event);
|
|
2326
|
+
if (!set) {
|
|
2327
|
+
set = new Set;
|
|
2328
|
+
this.listeners.set(event, set);
|
|
2329
|
+
}
|
|
2330
|
+
const first = set.size === 0;
|
|
2331
|
+
set.add(handler);
|
|
2332
|
+
if (first) {
|
|
2333
|
+
this.send("session.subscribe", { events: [event] }).catch(() => {});
|
|
2334
|
+
}
|
|
2335
|
+
let removed = false;
|
|
2336
|
+
return () => {
|
|
2337
|
+
if (removed)
|
|
2338
|
+
return;
|
|
2339
|
+
removed = true;
|
|
2340
|
+
const s = this.listeners.get(event);
|
|
2341
|
+
s?.delete(handler);
|
|
2342
|
+
if (s && s.size === 0) {
|
|
2343
|
+
this.send("session.unsubscribe", { events: [event] }).catch(() => {});
|
|
2344
|
+
}
|
|
2345
|
+
};
|
|
2346
|
+
}
|
|
2347
|
+
waitFor(event, predicate, timeoutMs = this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2) {
|
|
2348
|
+
return new Promise((resolve, reject) => {
|
|
2349
|
+
const timer = setTimeout(() => {
|
|
2350
|
+
off();
|
|
2351
|
+
reject(new BidiError(`waitFor('${event}') timed out after ${timeoutMs}ms`));
|
|
2352
|
+
}, timeoutMs);
|
|
2353
|
+
const off = this.on(event, (params) => {
|
|
2354
|
+
if (!predicate || predicate(params)) {
|
|
2355
|
+
clearTimeout(timer);
|
|
2356
|
+
off();
|
|
2357
|
+
resolve(params);
|
|
2358
|
+
}
|
|
2359
|
+
});
|
|
2360
|
+
});
|
|
2361
|
+
}
|
|
2362
|
+
retain() {
|
|
2363
|
+
this.refCount++;
|
|
2364
|
+
}
|
|
2365
|
+
release() {
|
|
2366
|
+
if (this.refCount > 0)
|
|
2367
|
+
this.refCount--;
|
|
2368
|
+
}
|
|
2369
|
+
dispose() {
|
|
2370
|
+
if (this.closed)
|
|
2371
|
+
return;
|
|
2372
|
+
this.closed = true;
|
|
2373
|
+
this.rejectAll(new BidiError("connection disposed"));
|
|
2374
|
+
try {
|
|
2375
|
+
this.ws?.close();
|
|
2376
|
+
} catch {}
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
async function connectBidiSession(port, opts = {}) {
|
|
2380
|
+
const conn = new BidiConnection(`ws://127.0.0.1:${port}/session`, { timeoutMs: opts.timeoutMs });
|
|
2381
|
+
await conn.connect();
|
|
2382
|
+
await conn.newSession(opts.capabilities ?? {});
|
|
2383
|
+
conn.retain();
|
|
2384
|
+
return conn;
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
// src/bidi/snapshot.ts
|
|
2388
|
+
var STAMP_ATTR = "data-cdp-uid";
|
|
2389
|
+
async function takeStampedSnapshot(evaluate) {
|
|
2390
|
+
const raw = await evaluate(buildSnapshotFunctionSource());
|
|
2391
|
+
return coerceSnapshotResult(raw);
|
|
2392
|
+
}
|
|
2393
|
+
function coerceSnapshotResult(raw) {
|
|
2394
|
+
if (typeof raw !== "object" || raw === null || typeof raw.snapshot !== "string" || typeof raw.nodeCount !== "number") {
|
|
2395
|
+
throw new Error("takeStampedSnapshot: evaluate() returned an unexpected shape");
|
|
2396
|
+
}
|
|
2397
|
+
return { snapshot: raw.snapshot, nodeCount: raw.nodeCount };
|
|
2398
|
+
}
|
|
2399
|
+
function buildSnapshotFunctionSource() {
|
|
2400
|
+
return `function() {
|
|
2401
|
+
var STAMP_ATTR = ${JSON.stringify(STAMP_ATTR)};
|
|
2402
|
+
var MAX_NAME_LENGTH = ${MAX_NAME_LENGTH};
|
|
2403
|
+
var lines = [];
|
|
2404
|
+
var nodeCount = 0;
|
|
2405
|
+
|
|
2406
|
+
// THE UID CODEC (driver.ts) documents a bidi payload as exactly 12 lowercase hex chars,
|
|
2407
|
+
// /^[0-9a-f]{12}$/. Build it from two padded random blocks so the length is exact regardless
|
|
2408
|
+
// of the random value drawn, never from string concatenation that could come up short.
|
|
2409
|
+
function nextStamp() {
|
|
2410
|
+
var hi = Math.floor(Math.random() * 0x100000000).toString(16).padStart(8, "0");
|
|
2411
|
+
var lo = Math.floor(Math.random() * 0x10000).toString(16).padStart(4, "0");
|
|
2412
|
+
return hi + lo;
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
function stampOf(el) {
|
|
2416
|
+
var existing = el.getAttribute(STAMP_ATTR);
|
|
2417
|
+
if (existing) return existing;
|
|
2418
|
+
var stamp = nextStamp();
|
|
2419
|
+
el.setAttribute(STAMP_ATTR, stamp);
|
|
2420
|
+
return stamp;
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
// skipSizeCheck exempts <option>/<optgroup> from the zero-size prune: a
|
|
2424
|
+
// closed select's popup is unpainted (0x0) though its options are still
|
|
2425
|
+
// present and pickable. Other visibility rules still apply to them.
|
|
2426
|
+
function isVisible(el, skipSizeCheck) {
|
|
2427
|
+
if (el.hidden) return false;
|
|
2428
|
+
if (el.getAttribute("aria-hidden") === "true") return false;
|
|
2429
|
+
var style;
|
|
2430
|
+
try {
|
|
2431
|
+
style = el.ownerDocument.defaultView.getComputedStyle(el);
|
|
2432
|
+
} catch (e) {
|
|
2433
|
+
style = null;
|
|
2434
|
+
}
|
|
2435
|
+
if (style) {
|
|
2436
|
+
if (style.display === "none" || style.visibility === "hidden") return false;
|
|
2437
|
+
}
|
|
2438
|
+
if (!skipSizeCheck) {
|
|
2439
|
+
var rect = el.getBoundingClientRect();
|
|
2440
|
+
if (rect.width === 0 && rect.height === 0) return false;
|
|
2441
|
+
}
|
|
2442
|
+
return true;
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
function truncate(s) {
|
|
2446
|
+
s = s.trim().replace(/\\s+/g, " ");
|
|
2447
|
+
if (s.length > MAX_NAME_LENGTH) return s.slice(0, MAX_NAME_LENGTH - 1) + "\\u2026";
|
|
2448
|
+
return s;
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
function textOf(el) {
|
|
2452
|
+
return el.textContent || "";
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
function idRefsText(doc, ids) {
|
|
2456
|
+
var parts = [];
|
|
2457
|
+
var list = ids.trim().split(/\\s+/);
|
|
2458
|
+
for (var i = 0; i < list.length; i++) {
|
|
2459
|
+
var ref = doc.getElementById(list[i]);
|
|
2460
|
+
if (ref) parts.push(textOf(ref));
|
|
2461
|
+
}
|
|
2462
|
+
return parts.join(" ").trim();
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
// Text of a <label>, minus any nested form control's own content: a
|
|
2466
|
+
// label that WRAPS its control ("<label>Color <select>...</select></label>")
|
|
2467
|
+
// would otherwise include every option's text in the control's own name.
|
|
2468
|
+
function labelOwnText(label) {
|
|
2469
|
+
var clone = label.cloneNode(true);
|
|
2470
|
+
var controls = clone.querySelectorAll("input, select, textarea, button");
|
|
2471
|
+
for (var i = 0; i < controls.length; i++) {
|
|
2472
|
+
controls[i].parentNode.removeChild(controls[i]);
|
|
2473
|
+
}
|
|
2474
|
+
return textOf(clone);
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
// Guard: without this, an <option> inside a labelled <select> would
|
|
2478
|
+
// inherit the select's own label via closest("label"), not its text.
|
|
2479
|
+
var LABELABLE_TAGS = { INPUT: 1, TEXTAREA: 1, SELECT: 1, BUTTON: 1, METER: 1, OUTPUT: 1, PROGRESS: 1 };
|
|
2480
|
+
|
|
2481
|
+
function nativeLabelText(el) {
|
|
2482
|
+
if (!LABELABLE_TAGS[el.tagName]) return "";
|
|
2483
|
+
var doc = el.ownerDocument;
|
|
2484
|
+
if (el.id) {
|
|
2485
|
+
var forLabel = doc.querySelector('label[for="' + el.id + '"]');
|
|
2486
|
+
if (forLabel) return labelOwnText(forLabel);
|
|
2487
|
+
}
|
|
2488
|
+
var ancestor = el.closest ? el.closest("label") : null;
|
|
2489
|
+
if (ancestor) return labelOwnText(ancestor);
|
|
2490
|
+
return "";
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
// Reduced accname: aria-labelledby, then aria-label, then native label,
|
|
2494
|
+
// then alt/title/placeholder, then trimmed own text for content-named
|
|
2495
|
+
// elements. Not the full W3C accname algorithm (no recursive traversal
|
|
2496
|
+
// rules for nested presentational content) but covers the common cases.
|
|
2497
|
+
function accessibleName(el, contentNamed) {
|
|
2498
|
+
var doc = el.ownerDocument;
|
|
2499
|
+
var labelledby = el.getAttribute("aria-labelledby");
|
|
2500
|
+
if (labelledby) {
|
|
2501
|
+
var viaIds = idRefsText(doc, labelledby);
|
|
2502
|
+
if (viaIds) return truncate(viaIds);
|
|
2503
|
+
}
|
|
2504
|
+
var ariaLabel = el.getAttribute("aria-label");
|
|
2505
|
+
if (ariaLabel && ariaLabel.trim()) return truncate(ariaLabel);
|
|
2506
|
+
var native = nativeLabelText(el);
|
|
2507
|
+
if (native && native.trim()) return truncate(native);
|
|
2508
|
+
var alt = el.getAttribute("alt");
|
|
2509
|
+
if (alt && alt.trim()) return truncate(alt);
|
|
2510
|
+
var title = el.getAttribute("title");
|
|
2511
|
+
if (title && title.trim()) return truncate(title);
|
|
2512
|
+
var placeholder = el.getAttribute("placeholder");
|
|
2513
|
+
if (placeholder && placeholder.trim()) return truncate(placeholder);
|
|
2514
|
+
if (contentNamed) {
|
|
2515
|
+
var text = textOf(el);
|
|
2516
|
+
if (text && text.trim()) return truncate(text);
|
|
2517
|
+
}
|
|
2518
|
+
return "";
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
var IMPLICIT_ROLE_BY_TAG = {
|
|
2522
|
+
A: "link", BUTTON: "button", NAV: "navigation", MAIN: "main", FORM: "form", DIALOG: "dialog",
|
|
2523
|
+
UL: "list", OL: "list", LI: "listitem", TABLE: "table", TR: "row", TD: "cell", TH: "columnheader",
|
|
2524
|
+
SELECT: "combobox", OPTION: "option", TEXTAREA: "textbox", IMG: "img",
|
|
2525
|
+
H1: "heading", H2: "heading", H3: "heading", H4: "heading", H5: "heading", H6: "heading",
|
|
2526
|
+
PROGRESS: "progressbar", METER: "meter"
|
|
2527
|
+
};
|
|
2528
|
+
|
|
2529
|
+
var INPUT_TYPE_ROLE = {
|
|
2530
|
+
button: "button", submit: "button", reset: "button", checkbox: "checkbox", radio: "radio",
|
|
2531
|
+
range: "slider", number: "spinbutton", search: "searchbox", email: "textbox", tel: "textbox",
|
|
2532
|
+
url: "textbox", password: "textbox", text: "textbox", image: "button"
|
|
2533
|
+
};
|
|
2534
|
+
|
|
2535
|
+
var CONTENT_NAMED_ROLES = {
|
|
2536
|
+
button: true, link: true, heading: true, option: true, cell: true,
|
|
2537
|
+
columnheader: true, listitem: true, menuitem: true, tab: true
|
|
2538
|
+
};
|
|
2539
|
+
|
|
2540
|
+
function implicitRole(el) {
|
|
2541
|
+
var explicit = el.getAttribute("role");
|
|
2542
|
+
if (explicit) return explicit;
|
|
2543
|
+
var tag = el.tagName;
|
|
2544
|
+
if (tag === "INPUT") {
|
|
2545
|
+
var type = (el.getAttribute("type") || "text").toLowerCase();
|
|
2546
|
+
return INPUT_TYPE_ROLE[type] || "textbox";
|
|
2547
|
+
}
|
|
2548
|
+
if (tag === "A" && !el.getAttribute("href")) return "";
|
|
2549
|
+
if (Object.prototype.hasOwnProperty.call(IMPLICIT_ROLE_BY_TAG, tag)) {
|
|
2550
|
+
return IMPLICIT_ROLE_BY_TAG[tag];
|
|
2551
|
+
}
|
|
2552
|
+
// Deliberate divergence: Chrome's real AX tree says "generic" here,
|
|
2553
|
+
// but "textbox" is the useful signal an agent needs. Chrome is out of
|
|
2554
|
+
// step, do not "fix" this toward parity.
|
|
2555
|
+
if (el.isContentEditable) return "textbox";
|
|
2556
|
+
return "";
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
function headingLevel(el) {
|
|
2560
|
+
var m = /^H([1-6])$/.exec(el.tagName);
|
|
2561
|
+
return m ? m[1] : null;
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
function currentValue(el) {
|
|
2565
|
+
var tag = el.tagName;
|
|
2566
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") {
|
|
2567
|
+
return el.value;
|
|
2568
|
+
}
|
|
2569
|
+
if (el.isContentEditable) return textOf(el);
|
|
2570
|
+
return undefined;
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
function isChecked(el) {
|
|
2574
|
+
var tag = el.tagName;
|
|
2575
|
+
if (tag === "INPUT") {
|
|
2576
|
+
var type = (el.getAttribute("type") || "").toLowerCase();
|
|
2577
|
+
if (type === "checkbox" || type === "radio") return !!el.checked;
|
|
2578
|
+
}
|
|
2579
|
+
var aria = el.getAttribute("aria-checked");
|
|
2580
|
+
if (aria === "true") return true;
|
|
2581
|
+
return false;
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
function describeNode(el, role) {
|
|
2585
|
+
var contentNamed = !!CONTENT_NAMED_ROLES[role];
|
|
2586
|
+
var name = accessibleName(el, contentNamed);
|
|
2587
|
+
var extras = [];
|
|
2588
|
+
var value = currentValue(el);
|
|
2589
|
+
if (value !== undefined && value !== "") extras.push("value=" + JSON.stringify(String(value)));
|
|
2590
|
+
if (isChecked(el)) extras.push("checked=true");
|
|
2591
|
+
if (el.tagName === "OPTION" && el.selected) extras.push("selected");
|
|
2592
|
+
var href = el.getAttribute("href");
|
|
2593
|
+
if (href) extras.push("url=" + href);
|
|
2594
|
+
if (el.disabled || el.getAttribute("aria-disabled") === "true") extras.push("disabled");
|
|
2595
|
+
var level = headingLevel(el);
|
|
2596
|
+
if (level) extras.push("level=" + level);
|
|
2597
|
+
var label = name ? " " + JSON.stringify(name) : "";
|
|
2598
|
+
var extra = extras.length ? " [" + extras.join(" ") + "]" : "";
|
|
2599
|
+
return { role: role, label: label, extra: extra, name: name };
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
// Traverses light DOM, open shadow roots, and same-origin iframe documents.
|
|
2603
|
+
// A cross-origin iframe throws on contentDocument access; caught and
|
|
2604
|
+
// skipped rather than aborting the whole walk, and noted in a comment
|
|
2605
|
+
// here rather than silently vanishing from the tree without a trace.
|
|
2606
|
+
function walk(root, depth) {
|
|
2607
|
+
var children = root.children ? Array.prototype.slice.call(root.children) : [];
|
|
2608
|
+
for (var i = 0; i < children.length; i++) {
|
|
2609
|
+
var el = children[i];
|
|
2610
|
+
var isSelectChild = el.tagName === "OPTION" || el.tagName === "OPTGROUP";
|
|
2611
|
+
if (!isVisible(el, isSelectChild)) continue;
|
|
2612
|
+
|
|
2613
|
+
var role = implicitRole(el);
|
|
2614
|
+
var isLandmarkOrControl = !!role;
|
|
2615
|
+
var described = null;
|
|
2616
|
+
if (isLandmarkOrControl) {
|
|
2617
|
+
described = describeNode(el, role);
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
var hasContent = described && (described.name || described.extra);
|
|
2621
|
+
var isStructural = isLandmarkOrControl && (hasContent || isInteractiveRole(role) || isLandmarkRole(role));
|
|
2622
|
+
|
|
2623
|
+
// Role-less visible leaf text (measured vs Chrome's take_snapshot,
|
|
2624
|
+
// which keeps a StaticText under a generic wrapper for this case,
|
|
2625
|
+
// e.g. a plain status <div>). Only a leaf (no element children, so
|
|
2626
|
+
// its text is not already captured by a nested control/label).
|
|
2627
|
+
var hasElementChildren = !!(el.children && el.children.length > 0);
|
|
2628
|
+
var leafName = !isStructural && !hasElementChildren ? truncate(textOf(el)) : "";
|
|
2629
|
+
var isLeafText = !!leafName;
|
|
2630
|
+
|
|
2631
|
+
var nextDepth = depth;
|
|
2632
|
+
if (isStructural || isLeafText) {
|
|
2633
|
+
var stamp = stampOf(el);
|
|
2634
|
+
var indent = new Array(depth + 1).join(" ");
|
|
2635
|
+
var lineTail = isStructural
|
|
2636
|
+
? described.role + described.label + described.extra
|
|
2637
|
+
: "text " + JSON.stringify(leafName);
|
|
2638
|
+
lines.push(indent + "[" + stamp + "] " + lineTail);
|
|
2639
|
+
nodeCount += 1;
|
|
2640
|
+
nextDepth = depth + 1;
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
if (el.shadowRoot) {
|
|
2644
|
+
walk(el.shadowRoot, nextDepth);
|
|
2645
|
+
} else if (el.tagName === "IFRAME") {
|
|
2646
|
+
try {
|
|
2647
|
+
var doc = el.contentDocument;
|
|
2648
|
+
if (doc && doc.body) walk(doc.body, nextDepth);
|
|
2649
|
+
} catch (e) {
|
|
2650
|
+
// cross-origin iframe: intentionally skipped, not silently merged
|
|
2651
|
+
// into the parent tree as though it were empty.
|
|
2652
|
+
}
|
|
2653
|
+
} else {
|
|
2654
|
+
walk(el, nextDepth);
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
function isInteractiveRole(role) {
|
|
2660
|
+
return (
|
|
2661
|
+
role === "button" ||
|
|
2662
|
+
role === "link" ||
|
|
2663
|
+
role === "textbox" ||
|
|
2664
|
+
role === "searchbox" ||
|
|
2665
|
+
role === "checkbox" ||
|
|
2666
|
+
role === "radio" ||
|
|
2667
|
+
role === "combobox" ||
|
|
2668
|
+
role === "listbox" ||
|
|
2669
|
+
role === "option" ||
|
|
2670
|
+
role === "slider" ||
|
|
2671
|
+
role === "spinbutton" ||
|
|
2672
|
+
role === "progressbar" ||
|
|
2673
|
+
role === "meter"
|
|
2674
|
+
);
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
function isLandmarkRole(role) {
|
|
2678
|
+
return (
|
|
2679
|
+
role === "navigation" ||
|
|
2680
|
+
role === "main" ||
|
|
2681
|
+
role === "form" ||
|
|
2682
|
+
role === "dialog" ||
|
|
2683
|
+
role === "region" ||
|
|
2684
|
+
role === "heading" ||
|
|
2685
|
+
role === "list" ||
|
|
2686
|
+
role === "listitem" ||
|
|
2687
|
+
role === "table" ||
|
|
2688
|
+
role === "row" ||
|
|
2689
|
+
role === "cell" ||
|
|
2690
|
+
role === "columnheader" ||
|
|
2691
|
+
role === "img"
|
|
2692
|
+
);
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
walk(document.body, 0);
|
|
2696
|
+
return { snapshot: lines.join("\\n"), nodeCount: nodeCount };
|
|
2697
|
+
}`;
|
|
1384
2698
|
}
|
|
2699
|
+
var MAX_NAME_LENGTH = 120;
|
|
1385
2700
|
|
|
1386
|
-
// src/
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
"
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
2701
|
+
// src/bidi/driver.ts
|
|
2702
|
+
function driverError2(code, message, data) {
|
|
2703
|
+
return Object.assign(new Error(message), { code, ...data !== undefined ? { data } : {} });
|
|
2704
|
+
}
|
|
2705
|
+
function encodeUid2(stamp5) {
|
|
2706
|
+
return `bidi:${stamp5}`;
|
|
2707
|
+
}
|
|
2708
|
+
function decodeStamp(uid) {
|
|
2709
|
+
if (!uid.startsWith("bidi:"))
|
|
2710
|
+
throw driverError2("foreign-uid", `uid is not owned by the bidi driver: ${uid}`);
|
|
2711
|
+
const payload = uid.slice(5);
|
|
2712
|
+
if (!payload.length)
|
|
2713
|
+
throw driverError2("foreign-uid", `malformed bidi uid payload: ${uid}`);
|
|
2714
|
+
return payload;
|
|
2715
|
+
}
|
|
2716
|
+
function cssEscape(s) {
|
|
2717
|
+
return s.replace(/["\\]/g, (c) => `\\${c}`);
|
|
2718
|
+
}
|
|
2719
|
+
function mapBidiError(e) {
|
|
2720
|
+
if (e instanceof BidiError) {
|
|
2721
|
+
const code = e.code;
|
|
2722
|
+
if (code === "no such node" || code === "no such element")
|
|
2723
|
+
return driverError2("no-such-element", e.message);
|
|
2724
|
+
if (code === "no such frame")
|
|
2725
|
+
return driverError2("no-such-target", e.message);
|
|
2726
|
+
if (code === "invalid session id")
|
|
2727
|
+
return driverError2("disconnected", e.message);
|
|
2728
|
+
if (code === "unsupported operation")
|
|
2729
|
+
return driverError2("unsupported", e.message);
|
|
2730
|
+
if (!code && /timed out/i.test(e.message))
|
|
2731
|
+
return driverError2("timeout", e.message);
|
|
2732
|
+
if (!code && /(disposed|not open|connection closed)/i.test(e.message))
|
|
2733
|
+
return driverError2("disconnected", e.message);
|
|
2734
|
+
return driverError2("page-error", e.message);
|
|
2735
|
+
}
|
|
2736
|
+
return driverError2("page-error", e instanceof Error ? e.message : String(e));
|
|
2737
|
+
}
|
|
2738
|
+
function serializeArg(v) {
|
|
2739
|
+
if (v === undefined)
|
|
2740
|
+
return { type: "undefined" };
|
|
2741
|
+
if (v === null)
|
|
2742
|
+
return { type: "null" };
|
|
2743
|
+
if (typeof v === "string")
|
|
2744
|
+
return { type: "string", value: v };
|
|
2745
|
+
if (typeof v === "number")
|
|
2746
|
+
return { type: "number", value: Number.isNaN(v) ? "NaN" : v };
|
|
2747
|
+
if (typeof v === "boolean")
|
|
2748
|
+
return { type: "boolean", value: v };
|
|
2749
|
+
if (Array.isArray(v))
|
|
2750
|
+
return { type: "array", value: v.map(serializeArg) };
|
|
2751
|
+
if (typeof v === "object")
|
|
2752
|
+
return { type: "object", value: Object.entries(v).map(([k, val]) => [k, serializeArg(val)]) };
|
|
2753
|
+
return { type: "string", value: String(v) };
|
|
2754
|
+
}
|
|
2755
|
+
function deserializeRemote(rv) {
|
|
2756
|
+
switch (rv.type) {
|
|
2757
|
+
case "undefined":
|
|
2758
|
+
return;
|
|
2759
|
+
case "null":
|
|
2760
|
+
return null;
|
|
2761
|
+
case "string":
|
|
2762
|
+
return rv.value;
|
|
2763
|
+
case "boolean":
|
|
2764
|
+
return rv.value;
|
|
2765
|
+
case "bigint":
|
|
2766
|
+
return rv.value;
|
|
2767
|
+
case "number":
|
|
2768
|
+
return typeof rv.value === "number" ? rv.value : rv.value;
|
|
2769
|
+
case "array":
|
|
2770
|
+
return (rv.value ?? []).map(deserializeRemote);
|
|
2771
|
+
case "object": {
|
|
2772
|
+
const out = {};
|
|
2773
|
+
for (const [k, v] of rv.value ?? [])
|
|
2774
|
+
out[typeof k === "string" ? k : String(deserializeRemote(k))] = deserializeRemote(v);
|
|
2775
|
+
return out;
|
|
2776
|
+
}
|
|
2777
|
+
case "node":
|
|
2778
|
+
return { bidiSharedId: rv.sharedId };
|
|
2779
|
+
default:
|
|
2780
|
+
return;
|
|
2781
|
+
}
|
|
1401
2782
|
}
|
|
1402
|
-
function
|
|
1403
|
-
|
|
2783
|
+
function exceptionMessage2(d) {
|
|
2784
|
+
if (d.text)
|
|
2785
|
+
return d.text;
|
|
2786
|
+
const ex = deserializeRemote(d.exception);
|
|
2787
|
+
return typeof ex === "string" ? ex : "evaluation threw";
|
|
1404
2788
|
}
|
|
1405
|
-
|
|
1406
|
-
|
|
2789
|
+
var LINE_RE = /^((?: {2})*)\[([0-9a-f]+)\] ([a-zA-Z_][\w-]*)(?: ("(?:[^"\\]|\\.)*"))?(?: \[(.*)\])?$/;
|
|
2790
|
+
function splitExtras(s) {
|
|
2791
|
+
const out = [];
|
|
2792
|
+
let cur = "";
|
|
2793
|
+
let inQuotes = false;
|
|
2794
|
+
for (let i = 0;i < s.length; i++) {
|
|
2795
|
+
const ch = s[i];
|
|
2796
|
+
if (ch === '"' && s[i - 1] !== "\\")
|
|
2797
|
+
inQuotes = !inQuotes;
|
|
2798
|
+
if (ch === " " && !inQuotes) {
|
|
2799
|
+
if (cur)
|
|
2800
|
+
out.push(cur);
|
|
2801
|
+
cur = "";
|
|
2802
|
+
} else
|
|
2803
|
+
cur += ch;
|
|
2804
|
+
}
|
|
2805
|
+
if (cur)
|
|
2806
|
+
out.push(cur);
|
|
2807
|
+
return out;
|
|
1407
2808
|
}
|
|
1408
|
-
function
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
2809
|
+
function parseSnapshotLine(line) {
|
|
2810
|
+
const m = LINE_RE.exec(line);
|
|
2811
|
+
if (!m)
|
|
2812
|
+
return;
|
|
2813
|
+
const indent = m[1] ?? "", stamp5 = m[2], role = m[3], nameLit = m[4], extrasRaw = m[5];
|
|
2814
|
+
const depth = indent.length / 2;
|
|
2815
|
+
let name;
|
|
2816
|
+
if (nameLit) {
|
|
2817
|
+
try {
|
|
2818
|
+
name = JSON.parse(nameLit);
|
|
2819
|
+
} catch {
|
|
2820
|
+
name = nameLit;
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
const extras = {};
|
|
2824
|
+
if (extrasRaw) {
|
|
2825
|
+
for (const tok of splitExtras(extrasRaw)) {
|
|
2826
|
+
const eq = tok.indexOf("=");
|
|
2827
|
+
if (eq === -1) {
|
|
2828
|
+
extras[tok] = "true";
|
|
2829
|
+
continue;
|
|
2830
|
+
}
|
|
2831
|
+
const key = tok.slice(0, eq);
|
|
2832
|
+
let val = tok.slice(eq + 1);
|
|
2833
|
+
if (val.startsWith('"')) {
|
|
2834
|
+
try {
|
|
2835
|
+
val = JSON.parse(val);
|
|
2836
|
+
} catch {}
|
|
2837
|
+
}
|
|
2838
|
+
extras[key] = val;
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
return { uid: encodeUid2(stamp5), role, depth, ...name !== undefined ? { name } : {}, ...Object.keys(extras).length ? { extras } : {} };
|
|
1414
2842
|
}
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
2843
|
+
var INTERACTIVE_ROLES3 = new Set([
|
|
2844
|
+
"button",
|
|
2845
|
+
"link",
|
|
2846
|
+
"textbox",
|
|
2847
|
+
"searchbox",
|
|
2848
|
+
"checkbox",
|
|
2849
|
+
"radio",
|
|
2850
|
+
"combobox",
|
|
2851
|
+
"listbox",
|
|
2852
|
+
"option",
|
|
2853
|
+
"slider",
|
|
2854
|
+
"spinbutton",
|
|
2855
|
+
"progressbar",
|
|
2856
|
+
"meter"
|
|
2857
|
+
]);
|
|
2858
|
+
var 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;}`;
|
|
2859
|
+
async function locateNodes(conn, contextId, locator) {
|
|
2860
|
+
try {
|
|
2861
|
+
const { nodes } = await conn.send("browsingContext.locateNodes", { context: contextId, locator, maxNodeCount: 1 });
|
|
2862
|
+
return nodes;
|
|
2863
|
+
} catch (e) {
|
|
2864
|
+
throw mapBidiError(e);
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
function sharedRefOf(node, describe) {
|
|
2868
|
+
if (!node.sharedId)
|
|
2869
|
+
throw driverError2("page-error", `located node has no sharedId (${describe})`);
|
|
2870
|
+
return { sharedId: node.sharedId };
|
|
2871
|
+
}
|
|
2872
|
+
async function resolveElementLocator2(conn, contextId, loc) {
|
|
2873
|
+
if ("uid" in loc) {
|
|
2874
|
+
const stamp5 = decodeStamp(loc.uid);
|
|
2875
|
+
const nodes2 = await locateNodes(conn, contextId, { type: "css", value: `[${UID_STAMP_ATTR}="${cssEscape(stamp5)}"]` });
|
|
2876
|
+
if (!nodes2.length)
|
|
2877
|
+
throw driverError2("stale-uid", `uid does not resolve: ${loc.uid}`);
|
|
2878
|
+
return sharedRefOf(nodes2[0], loc.uid);
|
|
2879
|
+
}
|
|
2880
|
+
if ("css" in loc) {
|
|
2881
|
+
const nodes2 = await locateNodes(conn, contextId, { type: "css", value: loc.css });
|
|
2882
|
+
if (!nodes2.length)
|
|
2883
|
+
throw driverError2("no-such-element", `css '${loc.css}' matched no element`);
|
|
2884
|
+
return sharedRefOf(nodes2[0], `css '${loc.css}'`);
|
|
2885
|
+
}
|
|
2886
|
+
if ("xpath" in loc) {
|
|
2887
|
+
const nodes2 = await locateNodes(conn, contextId, { type: "xpath", value: loc.xpath });
|
|
2888
|
+
if (!nodes2.length)
|
|
2889
|
+
throw driverError2("no-such-element", `xpath '${loc.xpath}' matched no element`);
|
|
2890
|
+
return sharedRefOf(nodes2[0], `xpath '${loc.xpath}'`);
|
|
2891
|
+
}
|
|
2892
|
+
const nodes = await locateNodes(conn, contextId, { type: "innerText", value: loc.text, matchType: "partial" });
|
|
2893
|
+
if (!nodes.length)
|
|
2894
|
+
throw driverError2("no-such-element", `text '${loc.text}' matched no element`);
|
|
2895
|
+
return sharedRefOf(nodes[0], `text '${loc.text}'`);
|
|
2896
|
+
}
|
|
2897
|
+
var KEY_VALUES = {
|
|
2898
|
+
enter: "",
|
|
2899
|
+
return: "",
|
|
2900
|
+
tab: "",
|
|
2901
|
+
escape: "",
|
|
2902
|
+
esc: "",
|
|
2903
|
+
backspace: "",
|
|
2904
|
+
delete: "",
|
|
2905
|
+
space: " ",
|
|
2906
|
+
arrowup: "",
|
|
2907
|
+
arrowdown: "",
|
|
2908
|
+
arrowleft: "",
|
|
2909
|
+
arrowright: "",
|
|
2910
|
+
up: "",
|
|
2911
|
+
down: "",
|
|
2912
|
+
left: "",
|
|
2913
|
+
right: "",
|
|
2914
|
+
home: "",
|
|
2915
|
+
end: "",
|
|
2916
|
+
pageup: "",
|
|
2917
|
+
pagedown: ""
|
|
2918
|
+
};
|
|
2919
|
+
var MODIFIER_VALUES = { shift: "", control: "", ctrl: "", alt: "", meta: "", cmd: "", command: "" };
|
|
2920
|
+
var BUTTON_CODES = { left: 0, middle: 1, right: 2 };
|
|
2921
|
+
var BIDI_CAPABILITIES = new Set([
|
|
2922
|
+
"emulate.deviceMetrics",
|
|
2923
|
+
"screenshot.fullPage",
|
|
2924
|
+
"screenshot.element",
|
|
2925
|
+
"network.intercept",
|
|
2926
|
+
"locate.xpath"
|
|
2927
|
+
]);
|
|
2928
|
+
var connections = new Map;
|
|
2929
|
+
async function getConnection(port, timeoutMs) {
|
|
2930
|
+
const existing = connections.get(port);
|
|
2931
|
+
if (existing) {
|
|
2932
|
+
existing.retain();
|
|
2933
|
+
return existing;
|
|
2934
|
+
}
|
|
2935
|
+
const conn = await connectBidiSession(port, {
|
|
2936
|
+
...timeoutMs !== undefined ? { timeoutMs } : {},
|
|
2937
|
+
capabilities: { alwaysMatch: { unhandledPromptBehavior: { default: "ignore" } } }
|
|
1419
2938
|
});
|
|
2939
|
+
connections.set(port, conn);
|
|
2940
|
+
return conn;
|
|
1420
2941
|
}
|
|
1421
|
-
async function
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
2942
|
+
async function safeTitle(conn, contextId) {
|
|
2943
|
+
try {
|
|
2944
|
+
const res = await conn.send("script.evaluate", { expression: "document.title", target: { context: contextId }, awaitPromise: false });
|
|
2945
|
+
return res.type === "success" && res.result.type === "string" ? res.result.value : "";
|
|
2946
|
+
} catch {
|
|
2947
|
+
return "";
|
|
2948
|
+
}
|
|
1426
2949
|
}
|
|
1427
|
-
async function
|
|
1428
|
-
|
|
1429
|
-
const path = join2(ARTIFACT_DIR3, `trace-${shortId2(targetId)}-${stamp2()}.json`);
|
|
1430
|
-
const body = JSON.stringify({ traceEvents: events, metadata: { source: "cdp-toolkit" } });
|
|
1431
|
-
await writeFile4(path, body, "utf8");
|
|
1432
|
-
const { size } = await stat(path);
|
|
1433
|
-
return { path, bytes: size };
|
|
2950
|
+
async function pageInfoOf(conn, ctx) {
|
|
2951
|
+
return { id: ctx.context, url: ctx.url, title: await safeTitle(conn, ctx.context) };
|
|
1434
2952
|
}
|
|
1435
|
-
async function
|
|
1436
|
-
const
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
throw
|
|
2953
|
+
async function resolveContext(conn, selector) {
|
|
2954
|
+
const { contexts } = await conn.send("browsingContext.getTree", {}).catch((e) => {
|
|
2955
|
+
throw mapBidiError(e);
|
|
2956
|
+
});
|
|
2957
|
+
if (!contexts.length)
|
|
2958
|
+
throw driverError2("no-such-target", "no browsing contexts available");
|
|
2959
|
+
if (selector === undefined || selector === "active")
|
|
2960
|
+
return contexts[0];
|
|
2961
|
+
if (selector.startsWith("index:")) {
|
|
2962
|
+
const t = contexts[Number(selector.slice(6))];
|
|
2963
|
+
if (!t)
|
|
2964
|
+
throw driverError2("no-such-target", `no context at ${selector}`);
|
|
2965
|
+
return t;
|
|
1441
2966
|
}
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
throw err;
|
|
2967
|
+
if (selector.startsWith("url:")) {
|
|
2968
|
+
const needle = selector.slice(4);
|
|
2969
|
+
const t = contexts.find((c) => c.url.includes(needle));
|
|
2970
|
+
if (!t)
|
|
2971
|
+
throw driverError2("no-such-target", `no context with url containing '${needle}'`);
|
|
2972
|
+
return t;
|
|
1449
2973
|
}
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
target: { id: target.id, url: target.url, title: target.title },
|
|
1462
|
-
categories,
|
|
1463
|
-
note: "Trace recording. Call performanceStopTrace in THIS process to finalize. Cross-process? use performanceTrace instead."
|
|
1464
|
-
};
|
|
2974
|
+
if (selector.startsWith("title:")) {
|
|
2975
|
+
const needle = selector.slice(6);
|
|
2976
|
+
for (const c of contexts)
|
|
2977
|
+
if ((await safeTitle(conn, c.context)).includes(needle))
|
|
2978
|
+
return c;
|
|
2979
|
+
throw driverError2("no-such-target", `no context with title containing '${needle}'`);
|
|
2980
|
+
}
|
|
2981
|
+
const exact = contexts.find((c) => c.context === selector);
|
|
2982
|
+
if (exact)
|
|
2983
|
+
return exact;
|
|
2984
|
+
throw driverError2("no-such-target", `no context matching '${selector}'`);
|
|
1465
2985
|
}
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
2986
|
+
|
|
2987
|
+
class BidiPageDriver {
|
|
2988
|
+
conn;
|
|
2989
|
+
contextId;
|
|
2990
|
+
info;
|
|
2991
|
+
browser;
|
|
2992
|
+
released = false;
|
|
2993
|
+
bodyCollector;
|
|
2994
|
+
constructor(conn, contextId, info, browser) {
|
|
2995
|
+
this.conn = conn;
|
|
2996
|
+
this.contextId = contextId;
|
|
2997
|
+
this.info = info;
|
|
2998
|
+
this.browser = browser;
|
|
2999
|
+
}
|
|
3000
|
+
async navigate(opts) {
|
|
3001
|
+
const reload = opts.reload === true;
|
|
3002
|
+
if (!reload && !opts.url)
|
|
3003
|
+
throw driverError2("page-error", "navigate: 'url' is required unless reload:true");
|
|
3004
|
+
const waitUntil = opts.waitUntil ?? "load";
|
|
3005
|
+
const wait = waitUntil === "domcontentloaded" ? "interactive" : "complete";
|
|
3006
|
+
const sendOpts = opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {};
|
|
1471
3007
|
try {
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
3008
|
+
if (reload) {
|
|
3009
|
+
const r2 = await this.conn.send("browsingContext.reload", { context: this.contextId, ignoreCache: opts.ignoreCache === true, wait }, sendOpts);
|
|
3010
|
+
return { url: r2.url, contextId: this.contextId, reloaded: true, waitedFor: waitUntil };
|
|
3011
|
+
}
|
|
3012
|
+
const r = await this.conn.send("browsingContext.navigate", { context: this.contextId, url: opts.url, wait }, sendOpts);
|
|
3013
|
+
return { url: r.url, contextId: this.contextId, waitedFor: waitUntil };
|
|
3014
|
+
} catch (e) {
|
|
3015
|
+
throw mapBidiError(e);
|
|
3016
|
+
}
|
|
1477
3017
|
}
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
3018
|
+
async waitForText(text, timeoutMs = 15000, pollMs = 250) {
|
|
3019
|
+
const start = Date.now();
|
|
3020
|
+
const expr = `(() => { const b = document.body; return !!b && typeof b.innerText === 'string' && b.innerText.includes(${JSON.stringify(text)}); })()`;
|
|
3021
|
+
for (;; ) {
|
|
3022
|
+
const found = await this.conn.send("script.evaluate", { expression: expr, target: { context: this.contextId }, awaitPromise: true }).then((r) => r.type === "success" && deserializeRemote(r.result) === true).catch(() => false);
|
|
3023
|
+
if (found)
|
|
3024
|
+
return { found: true, elapsedMs: Date.now() - start };
|
|
3025
|
+
if (Date.now() - start >= timeoutMs)
|
|
3026
|
+
throw driverError2("timeout", `waitForText: '${text}' not found within ${timeoutMs}ms`);
|
|
3027
|
+
await new Promise((r) => setTimeout(r, Math.min(pollMs, Math.max(0, timeoutMs - (Date.now() - start)))));
|
|
3028
|
+
}
|
|
1485
3029
|
}
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
const loaded = conn.waitFor("Page.loadEventFired", undefined, durationMs + 5000);
|
|
1501
|
-
await conn.send("Page.navigate", { url: args.navigateTo });
|
|
1502
|
-
await loaded.catch(() => {});
|
|
1503
|
-
} else if (args.reload) {
|
|
1504
|
-
const loaded = conn.waitFor("Page.loadEventFired", undefined, durationMs + 5000);
|
|
1505
|
-
await conn.send("Page.reload", { ignoreCache: false });
|
|
1506
|
-
await loaded.catch(() => {});
|
|
3030
|
+
async callFunctionValue(functionDeclaration, args, thisRef) {
|
|
3031
|
+
try {
|
|
3032
|
+
const res = await this.conn.send("script.callFunction", {
|
|
3033
|
+
functionDeclaration,
|
|
3034
|
+
awaitPromise: true,
|
|
3035
|
+
target: { context: this.contextId },
|
|
3036
|
+
arguments: args.map(serializeArg),
|
|
3037
|
+
...thisRef ? { this: thisRef } : {}
|
|
3038
|
+
});
|
|
3039
|
+
if (res.type === "exception")
|
|
3040
|
+
throw driverError2("page-error", exceptionMessage2(res.exceptionDetails));
|
|
3041
|
+
return deserializeRemote(res.result);
|
|
3042
|
+
} catch (e) {
|
|
3043
|
+
throw isDriverError(e) ? e : mapBidiError(e);
|
|
1507
3044
|
}
|
|
1508
|
-
await new Promise((r) => setTimeout(r, durationMs));
|
|
1509
|
-
await endTrace(conn, events);
|
|
1510
|
-
} finally {
|
|
1511
|
-
conn.close();
|
|
1512
3045
|
}
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
let layoutShifts = 0;
|
|
1525
|
-
let longTasks = 0;
|
|
1526
|
-
let longTasksTotalMs = 0;
|
|
1527
|
-
let totalBlockingTimeMs = 0;
|
|
1528
|
-
for (const e of events) {
|
|
1529
|
-
const name = e.name;
|
|
1530
|
-
if (!name)
|
|
1531
|
-
continue;
|
|
1532
|
-
if (name === "navigationStart") {
|
|
1533
|
-
if (typeof e.ts === "number" && (navigationStartUs === undefined || e.ts < navigationStartUs)) {
|
|
1534
|
-
navigationStartUs = e.ts;
|
|
1535
|
-
}
|
|
1536
|
-
continue;
|
|
3046
|
+
async evaluate(expression, opts) {
|
|
3047
|
+
const awaitPromise = opts?.awaitPromise ?? true;
|
|
3048
|
+
if (opts?.args && opts.args.length > 0)
|
|
3049
|
+
return this.callFunctionValue(expression, [...opts.args]);
|
|
3050
|
+
try {
|
|
3051
|
+
const res = await this.conn.send("script.evaluate", { expression, awaitPromise, target: { context: this.contextId } });
|
|
3052
|
+
if (res.type === "exception")
|
|
3053
|
+
throw driverError2("page-error", exceptionMessage2(res.exceptionDetails));
|
|
3054
|
+
return deserializeRemote(res.result);
|
|
3055
|
+
} catch (e) {
|
|
3056
|
+
throw isDriverError(e) ? e : mapBidiError(e);
|
|
1537
3057
|
}
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
3058
|
+
}
|
|
3059
|
+
async callOnElement(loc, functionDeclaration, args = []) {
|
|
3060
|
+
const ref = await resolveElementLocator2(this.conn, this.contextId, loc);
|
|
3061
|
+
return this.callFunctionValue(functionDeclaration, [...args], ref);
|
|
3062
|
+
}
|
|
3063
|
+
async snapshot(opts) {
|
|
3064
|
+
const { snapshot } = await takeStampedSnapshot((src) => this.callFunctionValue(src, []));
|
|
3065
|
+
const nodes = [];
|
|
3066
|
+
for (const line of snapshot.split(`
|
|
3067
|
+
`)) {
|
|
3068
|
+
if (!line)
|
|
3069
|
+
continue;
|
|
3070
|
+
const n = parseSnapshotLine(line);
|
|
3071
|
+
if (n)
|
|
3072
|
+
nodes.push(n);
|
|
1542
3073
|
}
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
3074
|
+
return opts?.interactiveOnly ? nodes.filter((n) => INTERACTIVE_ROLES3.has(n.role)) : nodes;
|
|
3075
|
+
}
|
|
3076
|
+
async locate(loc) {
|
|
3077
|
+
if ("uid" in loc) {
|
|
3078
|
+
await this.resolve(loc.uid);
|
|
3079
|
+
return loc.uid;
|
|
1547
3080
|
}
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
3081
|
+
const ref = await resolveElementLocator2(this.conn, this.contextId, loc);
|
|
3082
|
+
const stamp5 = await this.callFunctionValue(STAMP_ONE_SOURCE, [], ref);
|
|
3083
|
+
if (typeof stamp5 !== "string")
|
|
3084
|
+
throw driverError2("page-error", "stamping the located element did not return a string");
|
|
3085
|
+
return encodeUid2(stamp5);
|
|
3086
|
+
}
|
|
3087
|
+
async resolve(uid) {
|
|
3088
|
+
const stamp5 = decodeStamp(uid);
|
|
3089
|
+
const nodes = await locateNodes(this.conn, this.contextId, { type: "css", value: `[${UID_STAMP_ATTR}="${cssEscape(stamp5)}"]` });
|
|
3090
|
+
if (!nodes.length)
|
|
3091
|
+
throw driverError2("stale-uid", `uid does not resolve: ${uid}`);
|
|
3092
|
+
}
|
|
3093
|
+
async performActions(actions) {
|
|
3094
|
+
try {
|
|
3095
|
+
await this.conn.send("input.performActions", { context: this.contextId, actions });
|
|
3096
|
+
} catch (e) {
|
|
3097
|
+
throw mapBidiError(e);
|
|
1552
3098
|
}
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
3099
|
+
}
|
|
3100
|
+
async centerOf(ref) {
|
|
3101
|
+
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};}";
|
|
3102
|
+
const val = await this.callFunctionValue(fn, [], ref);
|
|
3103
|
+
if (!val || typeof val !== "object")
|
|
3104
|
+
throw driverError2("page-error", "element has zero size / is not visible; cannot compute a click point");
|
|
3105
|
+
return val;
|
|
3106
|
+
}
|
|
3107
|
+
async hover(loc) {
|
|
3108
|
+
const ref = await resolveElementLocator2(this.conn, this.contextId, loc);
|
|
3109
|
+
const { x, y } = await this.centerOf(ref);
|
|
3110
|
+
await this.performActions([{ type: "pointer", id: "cdp-mouse", actions: [{ type: "pointerMove", x, y, duration: 0 }] }]);
|
|
3111
|
+
return { x, y };
|
|
3112
|
+
}
|
|
3113
|
+
async click(loc, opts) {
|
|
3114
|
+
const ref = await resolveElementLocator2(this.conn, this.contextId, loc);
|
|
3115
|
+
const { x, y } = await this.centerOf(ref);
|
|
3116
|
+
const button = BUTTON_CODES[opts?.button ?? "left"];
|
|
3117
|
+
const actions = [{ type: "pointerMove", x, y, duration: 0 }];
|
|
3118
|
+
for (let i = 0;i < (opts?.clickCount ?? 1); i++)
|
|
3119
|
+
actions.push({ type: "pointerDown", button }, { type: "pointerUp", button });
|
|
3120
|
+
await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
|
|
3121
|
+
return { x, y };
|
|
3122
|
+
}
|
|
3123
|
+
async drag(from, to) {
|
|
3124
|
+
const fromPt = await this.centerOf(await resolveElementLocator2(this.conn, this.contextId, from));
|
|
3125
|
+
const toPt = await this.centerOf(await resolveElementLocator2(this.conn, this.contextId, to));
|
|
3126
|
+
const actions = [
|
|
3127
|
+
{ type: "pointerMove", x: fromPt.x, y: fromPt.y, duration: 0 },
|
|
3128
|
+
{ type: "pointerDown", button: 0 },
|
|
3129
|
+
{ type: "pointerMove", x: (fromPt.x + toPt.x) / 2, y: (fromPt.y + toPt.y) / 2, duration: 50 },
|
|
3130
|
+
{ type: "pointerMove", x: toPt.x, y: toPt.y, duration: 50 },
|
|
3131
|
+
{ type: "pointerUp", button: 0 }
|
|
3132
|
+
];
|
|
3133
|
+
await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
|
|
3134
|
+
return { from: fromPt, to: toPt };
|
|
3135
|
+
}
|
|
3136
|
+
async sendKeys(text) {
|
|
3137
|
+
if (!text.length)
|
|
3138
|
+
return;
|
|
3139
|
+
const actions = [];
|
|
3140
|
+
for (const ch of text)
|
|
3141
|
+
actions.push({ type: "keyDown", value: ch }, { type: "keyUp", value: ch });
|
|
3142
|
+
await this.performActions([{ type: "key", id: "cdp-keyboard", actions }]);
|
|
3143
|
+
}
|
|
3144
|
+
static 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}));}";
|
|
3145
|
+
static INSERT_TEXT_SOURCE = "function(v,collapseAtEnd){this.focus();var r=document.createRange();" + "r.selectNodeContents(this);if(collapseAtEnd)r.collapse(false);" + "var s=window.getSelection();s.removeAllRanges();s.addRange(r);" + "return document.execCommand('insertText', false, v);}";
|
|
3146
|
+
static IS_CONTENT_EDITABLE = "function(){return this.isContentEditable===true;}";
|
|
3147
|
+
async setValue(loc, value) {
|
|
3148
|
+
const ref = await resolveElementLocator2(this.conn, this.contextId, loc);
|
|
3149
|
+
const isSelect = await this.callFunctionValue("function(){return this.tagName==='SELECT';}", [], ref);
|
|
3150
|
+
if (isSelect === true) {
|
|
3151
|
+
await this.callFunctionValue(BidiPageDriver.SELECT_SOURCE, [value], ref);
|
|
3152
|
+
return;
|
|
1557
3153
|
}
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
3154
|
+
const isContentEditable = await this.callFunctionValue(BidiPageDriver.IS_CONTENT_EDITABLE, [], ref);
|
|
3155
|
+
if (isContentEditable === true) {
|
|
3156
|
+
await this.callFunctionValue(BidiPageDriver.INSERT_TEXT_SOURCE, [value, false], ref);
|
|
3157
|
+
return;
|
|
3158
|
+
}
|
|
3159
|
+
const clear = "function(){this.focus&&this.focus();if('value' in this){this.value='';this.dispatchEvent(new Event('input',{bubbles:true}));}}";
|
|
3160
|
+
await this.callFunctionValue(clear, [], ref);
|
|
3161
|
+
await this.sendKeys(value);
|
|
3162
|
+
}
|
|
3163
|
+
async typeText(loc, text) {
|
|
3164
|
+
const ref = await resolveElementLocator2(this.conn, this.contextId, loc);
|
|
3165
|
+
const isContentEditable = await this.callFunctionValue(BidiPageDriver.IS_CONTENT_EDITABLE, [], ref);
|
|
3166
|
+
if (isContentEditable === true) {
|
|
3167
|
+
await this.callFunctionValue(BidiPageDriver.INSERT_TEXT_SOURCE, [text, true], ref);
|
|
3168
|
+
return;
|
|
3169
|
+
}
|
|
3170
|
+
await this.callFunctionValue("function(){this.focus&&this.focus();}", [], ref);
|
|
3171
|
+
await this.sendKeys(text);
|
|
3172
|
+
}
|
|
3173
|
+
async pressKey(press) {
|
|
3174
|
+
const value = KEY_VALUES[press.key.toLowerCase()] ?? (press.key.length === 1 ? press.key : undefined);
|
|
3175
|
+
if (value === undefined)
|
|
3176
|
+
throw driverError2("page-error", `unknown key '${press.key}' (use a named key like Enter/Tab/ArrowDown or a single character)`);
|
|
3177
|
+
const mods = [];
|
|
3178
|
+
for (const m of press.modifiers ?? []) {
|
|
3179
|
+
const v = MODIFIER_VALUES[m.toLowerCase()];
|
|
3180
|
+
if (v === undefined)
|
|
3181
|
+
throw driverError2("page-error", `unknown modifier '${m}'`);
|
|
3182
|
+
mods.push(v);
|
|
3183
|
+
}
|
|
3184
|
+
const actions = [...mods.map((v) => ({ type: "keyDown", value: v })), { type: "keyDown", value }, { type: "keyUp", value }, ...[...mods].reverse().map((v) => ({ type: "keyUp", value: v }))];
|
|
3185
|
+
await this.performActions([{ type: "key", id: "cdp-keyboard", actions }]);
|
|
3186
|
+
}
|
|
3187
|
+
async setFiles(loc, files) {
|
|
3188
|
+
const ref = await resolveElementLocator2(this.conn, this.contextId, loc);
|
|
3189
|
+
try {
|
|
3190
|
+
await this.conn.send("input.setFiles", { context: this.contextId, element: ref, files: [...files] });
|
|
3191
|
+
} catch (e) {
|
|
3192
|
+
throw mapBidiError(e);
|
|
3193
|
+
}
|
|
3194
|
+
}
|
|
3195
|
+
async screenshot(opts) {
|
|
3196
|
+
const format = opts?.format ?? "png";
|
|
3197
|
+
const params = {
|
|
3198
|
+
context: this.contextId,
|
|
3199
|
+
format: { type: format === "jpeg" ? "image/jpeg" : "image/png", ...format === "jpeg" ? { quality: (opts?.quality ?? 80) / 100 } : {} }
|
|
3200
|
+
};
|
|
3201
|
+
if (opts?.clip)
|
|
3202
|
+
params.clip = { type: "element", element: await resolveElementLocator2(this.conn, this.contextId, opts.clip) };
|
|
3203
|
+
else if (opts?.fullPage)
|
|
3204
|
+
params.origin = "document";
|
|
3205
|
+
try {
|
|
3206
|
+
const { data } = await this.conn.send("browsingContext.captureScreenshot", params);
|
|
3207
|
+
return { data: new Uint8Array(Buffer.from(data, "base64")), format };
|
|
3208
|
+
} catch (e) {
|
|
3209
|
+
throw mapBidiError(e);
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
async emulate(opts) {
|
|
3213
|
+
if (opts.clearOverrides) {
|
|
3214
|
+
await this.conn.send("emulation.setUserAgentOverride", { userAgent: null, contexts: [this.contextId] }).catch(() => {
|
|
3215
|
+
return;
|
|
3216
|
+
});
|
|
3217
|
+
await this.conn.send("browsingContext.setViewport", { context: this.contextId, viewport: null, devicePixelRatio: null }).catch(() => {
|
|
3218
|
+
return;
|
|
3219
|
+
});
|
|
3220
|
+
return { applied: [] };
|
|
3221
|
+
}
|
|
3222
|
+
const applied = [];
|
|
3223
|
+
try {
|
|
3224
|
+
if (opts.width != null && opts.height != null) {
|
|
3225
|
+
await this.conn.send("browsingContext.setViewport", { context: this.contextId, viewport: { width: opts.width, height: opts.height }, ...opts.deviceScaleFactor != null ? { devicePixelRatio: opts.deviceScaleFactor } : {} });
|
|
3226
|
+
applied.push("deviceMetrics");
|
|
1565
3227
|
}
|
|
1566
|
-
|
|
3228
|
+
if (opts.userAgent != null) {
|
|
3229
|
+
await this.conn.send("emulation.setUserAgentOverride", { userAgent: opts.userAgent, contexts: [this.contextId] });
|
|
3230
|
+
applied.push("userAgent");
|
|
3231
|
+
}
|
|
3232
|
+
} catch (e) {
|
|
3233
|
+
throw mapBidiError(e);
|
|
3234
|
+
}
|
|
3235
|
+
return { applied };
|
|
3236
|
+
}
|
|
3237
|
+
static EVENT_MAP = {
|
|
3238
|
+
navigated: "browsingContext.navigationCommitted",
|
|
3239
|
+
console: "log.entryAdded",
|
|
3240
|
+
"network.request": "network.beforeRequestSent",
|
|
3241
|
+
"network.response": "network.responseCompleted",
|
|
3242
|
+
dialog: "browsingContext.userPromptOpened",
|
|
3243
|
+
"page.closed": "browsingContext.contextDestroyed"
|
|
3244
|
+
};
|
|
3245
|
+
contextMatches(event, payload) {
|
|
3246
|
+
if (event === "console")
|
|
3247
|
+
return true;
|
|
3248
|
+
return payload.context === undefined || payload.context === this.contextId;
|
|
3249
|
+
}
|
|
3250
|
+
on(event, handler) {
|
|
3251
|
+
return this.conn.on(BidiPageDriver.EVENT_MAP[event], (params) => {
|
|
3252
|
+
const p = params;
|
|
3253
|
+
if (this.contextMatches(event, p))
|
|
3254
|
+
handler(p);
|
|
3255
|
+
});
|
|
3256
|
+
}
|
|
3257
|
+
async waitForEvent(event, predicate, timeoutMs) {
|
|
3258
|
+
try {
|
|
3259
|
+
const p = await this.conn.waitFor(BidiPageDriver.EVENT_MAP[event], (params) => {
|
|
3260
|
+
const p2 = params;
|
|
3261
|
+
return this.contextMatches(event, p2) && (!predicate || predicate(p2));
|
|
3262
|
+
}, timeoutMs);
|
|
3263
|
+
return p;
|
|
3264
|
+
} catch (e) {
|
|
3265
|
+
throw mapBidiError(e);
|
|
1567
3266
|
}
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
3267
|
+
}
|
|
3268
|
+
async handleOneDialog(accept, promptText, timeoutMs) {
|
|
3269
|
+
const opened = await this.conn.waitFor("browsingContext.userPromptOpened", (p) => p.context === this.contextId, timeoutMs).catch(() => {
|
|
3270
|
+
throw driverError2("timeout", `handleDialog: no dialog opened within ${timeoutMs}ms`);
|
|
3271
|
+
});
|
|
3272
|
+
try {
|
|
3273
|
+
await this.conn.send("browsingContext.handleUserPrompt", { context: this.contextId, accept, ...promptText !== undefined ? { userText: promptText } : {} });
|
|
3274
|
+
} catch (e) {
|
|
3275
|
+
throw mapBidiError(e);
|
|
3276
|
+
}
|
|
3277
|
+
return {
|
|
3278
|
+
type: opened.type,
|
|
3279
|
+
message: opened.message,
|
|
3280
|
+
url: this.info.url,
|
|
3281
|
+
...opened.defaultValue !== undefined ? { defaultPrompt: opened.defaultValue } : {},
|
|
3282
|
+
accept,
|
|
3283
|
+
...promptText !== undefined ? { promptText } : {},
|
|
3284
|
+
handled: true
|
|
3285
|
+
};
|
|
3286
|
+
}
|
|
3287
|
+
async handleDialog(accept, promptText, opts) {
|
|
3288
|
+
if (opts?.autoMs !== undefined) {
|
|
3289
|
+
const deadline = Date.now() + opts.autoMs;
|
|
3290
|
+
const handled = [];
|
|
3291
|
+
while (Date.now() < deadline) {
|
|
3292
|
+
const remaining = deadline - Date.now();
|
|
3293
|
+
if (remaining <= 0)
|
|
3294
|
+
break;
|
|
3295
|
+
const d = await this.handleOneDialog(accept, promptText, Math.min(250, remaining)).catch(() => {
|
|
3296
|
+
return;
|
|
3297
|
+
});
|
|
3298
|
+
if (d)
|
|
3299
|
+
handled.push(d);
|
|
1574
3300
|
}
|
|
1575
|
-
|
|
3301
|
+
return { handled, count: handled.length };
|
|
1576
3302
|
}
|
|
3303
|
+
return this.handleOneDialog(accept, promptText, opts?.timeoutMs ?? 15000);
|
|
1577
3304
|
}
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
};
|
|
1595
|
-
}
|
|
1596
|
-
async function performanceAnalyzeInsight(args = {}) {
|
|
1597
|
-
if (!args.tracePath) {
|
|
1598
|
-
throw new CdpError("performanceAnalyzeInsight requires { tracePath } pointing at a trace JSON file");
|
|
3305
|
+
async startBodyCapture() {
|
|
3306
|
+
let collector;
|
|
3307
|
+
try {
|
|
3308
|
+
const res = await this.conn.send("network.addDataCollector", { dataTypes: ["response"], maxEncodedDataSize: 1e8, contexts: [this.contextId] });
|
|
3309
|
+
collector = res.collector;
|
|
3310
|
+
} catch (e) {
|
|
3311
|
+
throw mapBidiError(e);
|
|
3312
|
+
}
|
|
3313
|
+
this.bodyCollector = collector;
|
|
3314
|
+
return async () => {
|
|
3315
|
+
await this.conn.send("network.removeDataCollector", { collector }).catch(() => {
|
|
3316
|
+
return;
|
|
3317
|
+
});
|
|
3318
|
+
if (this.bodyCollector === collector)
|
|
3319
|
+
this.bodyCollector = undefined;
|
|
3320
|
+
};
|
|
1599
3321
|
}
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
3322
|
+
async getResponseBody(requestId) {
|
|
3323
|
+
if (!this.bodyCollector)
|
|
3324
|
+
throw driverError2("unsupported", "getResponseBody: call startBodyCapture() first (network.responseBodyPostHoc is not supported)");
|
|
3325
|
+
try {
|
|
3326
|
+
const { bytes } = await this.conn.send("network.getData", { dataType: "response", collector: this.bodyCollector, request: requestId });
|
|
3327
|
+
return bytes.type === "base64" ? { body: bytes.value, base64Encoded: true } : { body: bytes.value, base64Encoded: false };
|
|
3328
|
+
} catch (e) {
|
|
3329
|
+
throw mapBidiError(e);
|
|
3330
|
+
}
|
|
1605
3331
|
}
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
3332
|
+
async intercept(rules) {
|
|
3333
|
+
let intercept;
|
|
3334
|
+
try {
|
|
3335
|
+
const res = await this.conn.send("network.addIntercept", { phases: ["beforeRequestSent"], contexts: [this.contextId] });
|
|
3336
|
+
intercept = res.intercept;
|
|
3337
|
+
} catch (e) {
|
|
3338
|
+
throw mapBidiError(e);
|
|
3339
|
+
}
|
|
3340
|
+
const off = this.conn.on("network.beforeRequestSent", (params) => {
|
|
3341
|
+
(async () => {
|
|
3342
|
+
const req = params.request;
|
|
3343
|
+
const rule = selectRule(rules, req.url, req.method);
|
|
3344
|
+
if (!rule) {
|
|
3345
|
+
await this.conn.send("network.continueRequest", { request: req.request }).catch(() => {
|
|
3346
|
+
return;
|
|
3347
|
+
});
|
|
3348
|
+
return;
|
|
3349
|
+
}
|
|
3350
|
+
if (rule.delayMs)
|
|
3351
|
+
await new Promise((r) => setTimeout(r, rule.delayMs));
|
|
3352
|
+
const act = effectiveAction(rule, Math.random());
|
|
3353
|
+
if (act === "fulfill") {
|
|
3354
|
+
const built = buildFulfillParams(req.request, rule);
|
|
3355
|
+
await this.conn.send("network.provideResponse", {
|
|
3356
|
+
request: req.request,
|
|
3357
|
+
statusCode: built.responseCode,
|
|
3358
|
+
headers: built.responseHeaders.map((h) => ({ name: h.name, value: { type: "string", value: h.value } })),
|
|
3359
|
+
body: { type: "base64", value: built.body }
|
|
3360
|
+
}).catch(() => {
|
|
3361
|
+
return;
|
|
3362
|
+
});
|
|
3363
|
+
} else if (act === "fail") {
|
|
3364
|
+
await this.conn.send("network.failRequest", { request: req.request }).catch(() => {
|
|
3365
|
+
return;
|
|
3366
|
+
});
|
|
3367
|
+
} else {
|
|
3368
|
+
await this.conn.send("network.continueRequest", { request: req.request }).catch(() => {
|
|
3369
|
+
return;
|
|
3370
|
+
});
|
|
3371
|
+
}
|
|
3372
|
+
})();
|
|
3373
|
+
});
|
|
3374
|
+
return async () => {
|
|
3375
|
+
off();
|
|
3376
|
+
await this.conn.send("network.removeIntercept", { intercept }).catch(() => {
|
|
3377
|
+
return;
|
|
3378
|
+
});
|
|
3379
|
+
};
|
|
3380
|
+
}
|
|
3381
|
+
async release() {
|
|
3382
|
+
if (this.released)
|
|
3383
|
+
return;
|
|
3384
|
+
this.released = true;
|
|
3385
|
+
this.conn.release();
|
|
1611
3386
|
}
|
|
1612
|
-
const events = Array.isArray(parsed) ? parsed : Array.isArray(parsed.traceEvents) ? parsed.traceEvents : (() => {
|
|
1613
|
-
throw new CdpError(`trace file '${args.tracePath}' has no traceEvents array`);
|
|
1614
|
-
})();
|
|
1615
|
-
return { ...analyzeEvents(events), tracePath: args.tracePath };
|
|
1616
3387
|
}
|
|
1617
3388
|
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
const
|
|
1632
|
-
const chunk = params.chunk;
|
|
1633
|
-
if (typeof chunk === "string")
|
|
1634
|
-
parts.push(chunk);
|
|
1635
|
-
});
|
|
3389
|
+
class BidiBrowserDriver {
|
|
3390
|
+
port;
|
|
3391
|
+
timeoutMs;
|
|
3392
|
+
scheme = "bidi";
|
|
3393
|
+
lifetime = "session";
|
|
3394
|
+
capabilities = BIDI_CAPABILITIES;
|
|
3395
|
+
uidStability = "document-stamp";
|
|
3396
|
+
snapshotFidelity = "dom-heuristic";
|
|
3397
|
+
constructor(port, timeoutMs) {
|
|
3398
|
+
this.port = port;
|
|
3399
|
+
this.timeoutMs = timeoutMs;
|
|
3400
|
+
}
|
|
3401
|
+
async listPages() {
|
|
3402
|
+
const conn = await getConnection(this.port, this.timeoutMs);
|
|
1636
3403
|
try {
|
|
1637
|
-
await conn.send("
|
|
3404
|
+
const { contexts } = await conn.send("browsingContext.getTree", {}).catch((e) => {
|
|
3405
|
+
throw mapBidiError(e);
|
|
3406
|
+
});
|
|
3407
|
+
return Promise.all(contexts.map((c) => pageInfoOf(conn, c)));
|
|
1638
3408
|
} finally {
|
|
1639
|
-
|
|
3409
|
+
conn.release();
|
|
1640
3410
|
}
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
3411
|
+
}
|
|
3412
|
+
async newPage(url) {
|
|
3413
|
+
const conn = await getConnection(this.port, this.timeoutMs);
|
|
3414
|
+
try {
|
|
3415
|
+
const { context } = await conn.send("browsingContext.create", { type: "tab" }).catch((e) => {
|
|
3416
|
+
throw mapBidiError(e);
|
|
3417
|
+
});
|
|
3418
|
+
if (url)
|
|
3419
|
+
await conn.send("browsingContext.navigate", { context, url, wait: "complete" }).catch((e) => {
|
|
3420
|
+
throw mapBidiError(e);
|
|
3421
|
+
});
|
|
3422
|
+
return { id: context, url: url ?? "about:blank", title: await safeTitle(conn, context) };
|
|
3423
|
+
} finally {
|
|
3424
|
+
conn.release();
|
|
1644
3425
|
}
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
await
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
}
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
3426
|
+
}
|
|
3427
|
+
async closePage(id) {
|
|
3428
|
+
const conn = await getConnection(this.port, this.timeoutMs);
|
|
3429
|
+
try {
|
|
3430
|
+
await conn.send("browsingContext.close", { context: id }).catch((e) => {
|
|
3431
|
+
throw mapBidiError(e);
|
|
3432
|
+
});
|
|
3433
|
+
return { success: true };
|
|
3434
|
+
} finally {
|
|
3435
|
+
conn.release();
|
|
3436
|
+
}
|
|
3437
|
+
}
|
|
3438
|
+
async activatePage(id) {
|
|
3439
|
+
const conn = await getConnection(this.port, this.timeoutMs);
|
|
3440
|
+
try {
|
|
3441
|
+
await conn.send("browsingContext.activate", { context: id }).catch((e) => {
|
|
3442
|
+
throw mapBidiError(e);
|
|
3443
|
+
});
|
|
3444
|
+
const ctx = await resolveContext(conn, id);
|
|
3445
|
+
return await pageInfoOf(conn, ctx);
|
|
3446
|
+
} finally {
|
|
3447
|
+
conn.release();
|
|
3448
|
+
}
|
|
3449
|
+
}
|
|
3450
|
+
async page(selector) {
|
|
3451
|
+
const conn = await getConnection(this.port, this.timeoutMs);
|
|
3452
|
+
let ctx;
|
|
3453
|
+
try {
|
|
3454
|
+
ctx = await resolveContext(conn, selector);
|
|
3455
|
+
} catch (e) {
|
|
3456
|
+
conn.release();
|
|
3457
|
+
throw e;
|
|
3458
|
+
}
|
|
3459
|
+
const info = await pageInfoOf(conn, ctx);
|
|
3460
|
+
return new BidiPageDriver(conn, ctx.context, info, this);
|
|
3461
|
+
}
|
|
3462
|
+
async dispose() {
|
|
3463
|
+
const conn = connections.get(this.port);
|
|
3464
|
+
if (!conn)
|
|
3465
|
+
return;
|
|
3466
|
+
connections.delete(this.port);
|
|
3467
|
+
conn.dispose();
|
|
1677
3468
|
}
|
|
1678
3469
|
}
|
|
1679
|
-
function
|
|
1680
|
-
return new
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
3470
|
+
function createFirefoxDriver(port, opts) {
|
|
3471
|
+
return new BidiBrowserDriver(port, opts?.timeoutMs);
|
|
3472
|
+
}
|
|
3473
|
+
|
|
3474
|
+
// src/bidi/launch.ts
|
|
3475
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
3476
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3477
|
+
import { createConnection, createServer } from "node:net";
|
|
3478
|
+
import { tmpdir } from "node:os";
|
|
3479
|
+
import { join as join4 } from "node:path";
|
|
3480
|
+
function resolveBinary() {
|
|
3481
|
+
const candidates = [
|
|
3482
|
+
process.env.FIREFOX_BIN,
|
|
3483
|
+
"/Applications/Firefox.app/Contents/MacOS/firefox",
|
|
3484
|
+
"firefox"
|
|
3485
|
+
];
|
|
3486
|
+
for (const c of candidates) {
|
|
3487
|
+
if (c)
|
|
3488
|
+
return c;
|
|
3489
|
+
}
|
|
3490
|
+
throw new Error("Firefox binary not found. Tried FIREFOX_BIN env var, " + "/Applications/Firefox.app/Contents/MacOS/firefox, and 'firefox' on PATH.");
|
|
3491
|
+
}
|
|
3492
|
+
function pickPort(requested) {
|
|
3493
|
+
return new Promise((resolve, reject) => {
|
|
3494
|
+
const server = createServer();
|
|
3495
|
+
server.once("error", (err) => {
|
|
3496
|
+
if (requested !== undefined && err.code === "EADDRINUSE") {
|
|
3497
|
+
reject(new Error(`port ${requested} is already in use`));
|
|
1687
3498
|
return;
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
} catch {}
|
|
1691
|
-
}, opts.timeoutMs);
|
|
1692
|
-
child.stdout.on("data", (d) => {
|
|
1693
|
-
stdout += d.toString();
|
|
1694
|
-
});
|
|
1695
|
-
child.stderr.on("data", (d) => {
|
|
1696
|
-
stderr += d.toString();
|
|
3499
|
+
}
|
|
3500
|
+
reject(err);
|
|
1697
3501
|
});
|
|
1698
|
-
|
|
1699
|
-
|
|
3502
|
+
server.listen(requested ?? 0, "127.0.0.1", () => {
|
|
3503
|
+
const addr = server.address();
|
|
3504
|
+
if (addr === null || typeof addr === "string") {
|
|
3505
|
+
server.close();
|
|
3506
|
+
reject(new Error("failed to allocate a port"));
|
|
1700
3507
|
return;
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
3508
|
+
}
|
|
3509
|
+
const { port } = addr;
|
|
3510
|
+
server.close(() => resolve(port));
|
|
1704
3511
|
});
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
3512
|
+
});
|
|
3513
|
+
}
|
|
3514
|
+
function pollPort(port, deadlineMs) {
|
|
3515
|
+
const start = Date.now();
|
|
3516
|
+
return new Promise((resolve, reject) => {
|
|
3517
|
+
const attempt = () => {
|
|
3518
|
+
const sock = createConnection({ port, host: "127.0.0.1" });
|
|
3519
|
+
const onFail = () => {
|
|
3520
|
+
sock.destroy();
|
|
3521
|
+
if (Date.now() - start >= deadlineMs) {
|
|
3522
|
+
reject(new Error(`Firefox debug port ${port} did not accept a connection within ${deadlineMs}ms`));
|
|
3523
|
+
return;
|
|
3524
|
+
}
|
|
3525
|
+
setTimeout(attempt, 100);
|
|
3526
|
+
};
|
|
3527
|
+
sock.once("connect", () => {
|
|
3528
|
+
sock.destroy();
|
|
3529
|
+
resolve();
|
|
3530
|
+
});
|
|
3531
|
+
sock.once("error", onFail);
|
|
3532
|
+
};
|
|
3533
|
+
attempt();
|
|
3534
|
+
});
|
|
3535
|
+
}
|
|
3536
|
+
function waitForExit(proc, deadlineMs) {
|
|
3537
|
+
return new Promise((resolve) => {
|
|
3538
|
+
if (proc.exitCode !== null || proc.signalCode !== null) {
|
|
3539
|
+
resolve();
|
|
3540
|
+
return;
|
|
3541
|
+
}
|
|
3542
|
+
const timer = setTimeout(() => resolve(), deadlineMs);
|
|
3543
|
+
proc.once("exit", () => {
|
|
1709
3544
|
clearTimeout(timer);
|
|
1710
|
-
resolve(
|
|
3545
|
+
resolve();
|
|
1711
3546
|
});
|
|
1712
3547
|
});
|
|
1713
3548
|
}
|
|
1714
|
-
async function
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
const
|
|
1719
|
-
const
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
const
|
|
1723
|
-
|
|
1724
|
-
const
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
lhArgs.push("--preset=desktop");
|
|
1736
|
-
}
|
|
1737
|
-
if (Array.isArray(args.categories) && args.categories.length > 0) {
|
|
1738
|
-
lhArgs.push(`--only-categories=${args.categories.join(",")}`);
|
|
1739
|
-
}
|
|
1740
|
-
const res = await runProcess("npx", lhArgs, { timeoutMs });
|
|
1741
|
-
if (res.spawnError) {
|
|
1742
|
-
const code = res.spawnError.code;
|
|
1743
|
-
if (code === "ENOENT") {
|
|
1744
|
-
throw new CdpError("lighthouse_audit requires 'npx' (Node.js) on PATH to fetch and run the Lighthouse CLI; 'npx' was not found.");
|
|
3549
|
+
async function launchFirefox(opts = {}) {
|
|
3550
|
+
const binary = resolveBinary();
|
|
3551
|
+
const port = await pickPort(opts.port);
|
|
3552
|
+
const isThrowaway = opts.profilePath === undefined;
|
|
3553
|
+
const profilePath = opts.profilePath ?? mkdtempSync(join4(tmpdir(), "cdp-toolkit-ff-profile-"));
|
|
3554
|
+
const args = ["--profile", profilePath, "--remote-debugging-port", String(port), "--no-remote"];
|
|
3555
|
+
if (opts.headless)
|
|
3556
|
+
args.push("--headless");
|
|
3557
|
+
const proc = spawn2(binary, args, { stdio: "ignore", detached: false });
|
|
3558
|
+
let closed = false;
|
|
3559
|
+
const close = async () => {
|
|
3560
|
+
if (closed)
|
|
3561
|
+
return;
|
|
3562
|
+
closed = true;
|
|
3563
|
+
if (proc.exitCode === null && proc.signalCode === null) {
|
|
3564
|
+
proc.kill("SIGTERM");
|
|
3565
|
+
await waitForExit(proc, 5000);
|
|
3566
|
+
if (proc.exitCode === null && proc.signalCode === null) {
|
|
3567
|
+
proc.kill("SIGKILL");
|
|
3568
|
+
await waitForExit(proc, 5000);
|
|
3569
|
+
}
|
|
1745
3570
|
}
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
let raw;
|
|
1749
|
-
try {
|
|
1750
|
-
raw = await readFile4(outPath, "utf8");
|
|
1751
|
-
} catch {
|
|
1752
|
-
raw = undefined;
|
|
1753
|
-
}
|
|
1754
|
-
if (raw === undefined) {
|
|
1755
|
-
if (res.signal === "SIGKILL") {
|
|
1756
|
-
throw new CdpError(`lighthouse_audit timed out after ${timeoutMs}ms (no report written). stderr: ${truncate(res.stderr)}`);
|
|
3571
|
+
if (isThrowaway) {
|
|
3572
|
+
rmSync(profilePath, { recursive: true, force: true });
|
|
1757
3573
|
}
|
|
1758
|
-
|
|
1759
|
-
|
|
3574
|
+
};
|
|
3575
|
+
const pid = proc.pid;
|
|
3576
|
+
if (pid === undefined) {
|
|
3577
|
+
await close();
|
|
3578
|
+
throw new Error(`failed to spawn Firefox binary at ${binary}`);
|
|
1760
3579
|
}
|
|
1761
|
-
|
|
3580
|
+
const timeoutMs = opts.timeoutMs ?? 15000;
|
|
1762
3581
|
try {
|
|
1763
|
-
|
|
1764
|
-
} catch (
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
if (report.runtimeError?.code && report.runtimeError.code !== "NO_ERROR") {
|
|
1768
|
-
throw new CdpError(`lighthouse_audit runtime error [${report.runtimeError.code}]: ${report.runtimeError.message ?? "unknown"}`);
|
|
3582
|
+
await pollPort(port, timeoutMs);
|
|
3583
|
+
} catch (err) {
|
|
3584
|
+
await close();
|
|
3585
|
+
throw err;
|
|
1769
3586
|
}
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
3587
|
+
return { port, pid, profilePath, close };
|
|
3588
|
+
}
|
|
3589
|
+
|
|
3590
|
+
// src/backend.ts
|
|
3591
|
+
function resolveBrowserKind(argv) {
|
|
3592
|
+
const flagIdx = argv.indexOf("--browser");
|
|
3593
|
+
const flagVal = flagIdx !== -1 ? argv[flagIdx + 1] : undefined;
|
|
3594
|
+
const raw = flagVal ?? process.env.CDP_BROWSER ?? "chrome";
|
|
3595
|
+
if (raw !== "chrome" && raw !== "firefox") {
|
|
3596
|
+
throw new Error(`unknown --browser '${raw}': expected 'chrome' or 'firefox' (or set CDP_BROWSER)`);
|
|
1773
3597
|
}
|
|
1774
|
-
|
|
1775
|
-
|
|
3598
|
+
return raw;
|
|
3599
|
+
}
|
|
3600
|
+
function stripBrowserFlag(argv) {
|
|
3601
|
+
const out = [];
|
|
3602
|
+
for (let i = 0;i < argv.length; i++) {
|
|
3603
|
+
if (argv[i] === "--browser") {
|
|
3604
|
+
i++;
|
|
3605
|
+
continue;
|
|
3606
|
+
}
|
|
3607
|
+
out.push(argv[i]);
|
|
1776
3608
|
}
|
|
1777
|
-
|
|
3609
|
+
return out;
|
|
3610
|
+
}
|
|
3611
|
+
async function startFirefoxSession(opts) {
|
|
3612
|
+
const launched = await launchFirefox({ headless: opts?.headless });
|
|
3613
|
+
const driver = createFirefoxDriver(launched.port);
|
|
3614
|
+
let disposed = false;
|
|
1778
3615
|
return {
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
3616
|
+
driver,
|
|
3617
|
+
async dispose() {
|
|
3618
|
+
if (disposed)
|
|
3619
|
+
return;
|
|
3620
|
+
disposed = true;
|
|
3621
|
+
await driver.dispose().catch(() => {
|
|
3622
|
+
return;
|
|
3623
|
+
});
|
|
3624
|
+
await launched.close();
|
|
3625
|
+
}
|
|
1787
3626
|
};
|
|
1788
3627
|
}
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
3628
|
+
var sessionPromise;
|
|
3629
|
+
function getOrCreateFirefoxSession() {
|
|
3630
|
+
if (!sessionPromise)
|
|
3631
|
+
sessionPromise = startFirefoxSession();
|
|
3632
|
+
return sessionPromise;
|
|
1792
3633
|
}
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
3634
|
+
async function disposeFirefoxSession() {
|
|
3635
|
+
if (!sessionPromise)
|
|
3636
|
+
return;
|
|
3637
|
+
const p = sessionPromise;
|
|
3638
|
+
sessionPromise = undefined;
|
|
3639
|
+
await p.then((s) => s.dispose()).catch(() => {
|
|
3640
|
+
return;
|
|
3641
|
+
});
|
|
1797
3642
|
}
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
re += ".*";
|
|
1812
|
-
} else if (ch === "?") {
|
|
1813
|
-
re += ".";
|
|
3643
|
+
|
|
3644
|
+
// src/capabilities.ts
|
|
3645
|
+
function capabilitiesFor(kind) {
|
|
3646
|
+
return kind === "chrome" ? createCdpDriver().capabilities : createFirefoxDriver(0).capabilities;
|
|
3647
|
+
}
|
|
3648
|
+
function toolAvailability(kind) {
|
|
3649
|
+
const caps = capabilitiesFor(kind);
|
|
3650
|
+
const available = [];
|
|
3651
|
+
const unavailable = [];
|
|
3652
|
+
for (const name of TOOL_NAMES) {
|
|
3653
|
+
const required = REQUIRED_CAPABILITIES[name];
|
|
3654
|
+
if (!required || required.every((c) => caps.has(c))) {
|
|
3655
|
+
available.push(name);
|
|
1814
3656
|
} else {
|
|
1815
|
-
|
|
3657
|
+
unavailable.push({ name, missing: required.filter((c) => !caps.has(c)) });
|
|
1816
3658
|
}
|
|
1817
3659
|
}
|
|
1818
|
-
return
|
|
1819
|
-
}
|
|
1820
|
-
function urlMatches(pattern, url) {
|
|
1821
|
-
return globToRegExp(pattern).test(url);
|
|
3660
|
+
return { kind, available, unavailable };
|
|
1822
3661
|
}
|
|
1823
|
-
|
|
1824
|
-
|
|
3662
|
+
|
|
3663
|
+
// src/bidi-tools.ts
|
|
3664
|
+
class BidiToolError extends Error {
|
|
1825
3665
|
}
|
|
1826
|
-
function
|
|
1827
|
-
const
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
3666
|
+
async function withPage3(driver, target, fn) {
|
|
3667
|
+
const page = await driver.page(target);
|
|
3668
|
+
try {
|
|
3669
|
+
return await fn(page);
|
|
3670
|
+
} finally {
|
|
3671
|
+
await page.release();
|
|
3672
|
+
}
|
|
3673
|
+
}
|
|
3674
|
+
var consoleBuffers = new Map;
|
|
3675
|
+
var networkBuffers = new Map;
|
|
3676
|
+
function bufferFor(map, id) {
|
|
3677
|
+
let b = map.get(id);
|
|
3678
|
+
if (!b) {
|
|
3679
|
+
b = [];
|
|
3680
|
+
map.set(id, b);
|
|
3681
|
+
}
|
|
3682
|
+
return b;
|
|
3683
|
+
}
|
|
3684
|
+
async function captureFresh(page, durationMs) {
|
|
3685
|
+
const cBuf = bufferFor(consoleBuffers, page.info.id);
|
|
3686
|
+
const nBuf = bufferFor(networkBuffers, page.info.id);
|
|
3687
|
+
cBuf.length = 0;
|
|
3688
|
+
nBuf.length = 0;
|
|
3689
|
+
const offC = page.on("console", (p) => cBuf.push(p));
|
|
3690
|
+
const offReq = page.on("network.request", (p) => nBuf.push({ ...p, phase: "request" }));
|
|
3691
|
+
const offRes = page.on("network.response", (p) => nBuf.push({ ...p, phase: "response" }));
|
|
3692
|
+
try {
|
|
3693
|
+
await page.navigate({ reload: true }).catch(() => {
|
|
3694
|
+
return;
|
|
3695
|
+
});
|
|
3696
|
+
await new Promise((r) => setTimeout(r, durationMs));
|
|
3697
|
+
} finally {
|
|
3698
|
+
offC();
|
|
3699
|
+
offReq();
|
|
3700
|
+
offRes();
|
|
1831
3701
|
}
|
|
1832
|
-
return {
|
|
1833
|
-
requestId,
|
|
1834
|
-
responseCode: rule.status ?? 200,
|
|
1835
|
-
responseHeaders: [...merged.values()],
|
|
1836
|
-
body: Buffer.from(rule.body ?? "", "utf8").toString("base64")
|
|
1837
|
-
};
|
|
1838
3702
|
}
|
|
1839
|
-
function
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
3703
|
+
async function listConsoleMessages2(driver, args = {}) {
|
|
3704
|
+
return withPage3(driver, args.target, async (page) => {
|
|
3705
|
+
if (args.reload)
|
|
3706
|
+
await captureFresh(page, args.durationMs ?? 2500);
|
|
3707
|
+
const buf = bufferFor(consoleBuffers, page.info.id);
|
|
3708
|
+
return { messages: [...buf], count: buf.length };
|
|
3709
|
+
});
|
|
1843
3710
|
}
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
3711
|
+
async function getConsoleMessage2(driver, args = {}) {
|
|
3712
|
+
return withPage3(driver, args.target, async (page) => {
|
|
3713
|
+
const buf = bufferFor(consoleBuffers, page.info.id);
|
|
3714
|
+
const idx = args.index ?? 0;
|
|
3715
|
+
const entry = buf[idx];
|
|
3716
|
+
if (!entry)
|
|
3717
|
+
throw new BidiToolError(`no console message at index ${idx}; run list_console_messages first`);
|
|
3718
|
+
return entry;
|
|
3719
|
+
});
|
|
1847
3720
|
}
|
|
1848
|
-
function
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
}
|
|
1857
|
-
if (rule.delayMs)
|
|
1858
|
-
await delay3(rule.delayMs);
|
|
1859
|
-
const act = effectiveAction(rule, Math.random());
|
|
1860
|
-
if (act === "fulfill") {
|
|
1861
|
-
await session.conn.send("Fetch.fulfillRequest", { ...buildFulfillParams(p.requestId, rule) });
|
|
1862
|
-
} else if (act === "fail") {
|
|
1863
|
-
await session.conn.send("Fetch.failRequest", { requestId: p.requestId, errorReason: rule.errorReason ?? "Failed" });
|
|
1864
|
-
} else {
|
|
1865
|
-
await session.conn.send("Fetch.continueRequest", { requestId: p.requestId });
|
|
1866
|
-
}
|
|
1867
|
-
rule.hits++;
|
|
1868
|
-
session.intercepted.push({ url: p.request.url, method: p.request.method, action: act });
|
|
1869
|
-
}).catch((e) => {
|
|
1870
|
-
if (/closed|connection|Target|Inspected/i.test(String(e)))
|
|
1871
|
-
session.dead = true;
|
|
1872
|
-
});
|
|
3721
|
+
async function listNetworkRequests2(driver, args = {}) {
|
|
3722
|
+
return withPage3(driver, args.target, async (page) => {
|
|
3723
|
+
if (args.reload)
|
|
3724
|
+
await captureFresh(page, args.durationMs ?? 2500);
|
|
3725
|
+
let buf = bufferFor(networkBuffers, page.info.id);
|
|
3726
|
+
if (args.filterUrl)
|
|
3727
|
+
buf = buf.filter((r) => typeof r.url === "string" && r.url.includes(args.filterUrl));
|
|
3728
|
+
return { requests: buf, count: buf.length };
|
|
1873
3729
|
});
|
|
1874
3730
|
}
|
|
1875
|
-
async function
|
|
3731
|
+
async function getNetworkRequest2(driver, args = {}) {
|
|
3732
|
+
if (!args.requestId && !args.url)
|
|
3733
|
+
throw new BidiToolError("get_network_request requires requestId or url");
|
|
3734
|
+
return withPage3(driver, args.target, async (page) => {
|
|
3735
|
+
const buf = bufferFor(networkBuffers, page.info.id);
|
|
3736
|
+
const found = args.requestId ? buf.find((r) => r.requestId === args.requestId || r.request === args.requestId) : buf.find((r) => typeof r.url === "string" && r.url.includes(args.url));
|
|
3737
|
+
if (!found)
|
|
3738
|
+
throw new BidiToolError("no matching network request in the current buffer; run list_network_requests first");
|
|
3739
|
+
return found;
|
|
3740
|
+
});
|
|
3741
|
+
}
|
|
3742
|
+
var mockSessions = new Map;
|
|
3743
|
+
async function mockRequest2(driver, args) {
|
|
1876
3744
|
if (!args.urlPattern)
|
|
1877
|
-
throw new
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
}
|
|
1907
|
-
let reloaded = false;
|
|
1908
|
-
if (args.reload) {
|
|
1909
|
-
await session.conn.send("Page.enable");
|
|
1910
|
-
await session.conn.send("Page.reload", { ignoreCache: true });
|
|
1911
|
-
await delay3(800);
|
|
1912
|
-
reloaded = true;
|
|
1913
|
-
}
|
|
1914
|
-
return {
|
|
1915
|
-
target: { id: session.target.id, url: session.target.url, title: session.target.title },
|
|
1916
|
-
pattern: rule.urlPattern,
|
|
1917
|
-
ruleCount: session.rules.length,
|
|
1918
|
-
reloaded
|
|
1919
|
-
};
|
|
3745
|
+
throw new BidiToolError("mock_request requires a { urlPattern }");
|
|
3746
|
+
return withPage3(driver, args.target, async (page) => {
|
|
3747
|
+
const existing = mockSessions.get(page.info.id);
|
|
3748
|
+
const rule = {
|
|
3749
|
+
urlPattern: args.urlPattern,
|
|
3750
|
+
method: args.method,
|
|
3751
|
+
action: args.action ?? "fulfill",
|
|
3752
|
+
status: args.status,
|
|
3753
|
+
body: args.body,
|
|
3754
|
+
contentType: args.contentType,
|
|
3755
|
+
headers: args.headers,
|
|
3756
|
+
errorReason: args.errorReason,
|
|
3757
|
+
delayMs: args.delayMs,
|
|
3758
|
+
failRate: args.failRate,
|
|
3759
|
+
hits: 0
|
|
3760
|
+
};
|
|
3761
|
+
const rules = existing ? [...existing.rules, rule] : [rule];
|
|
3762
|
+
if (existing)
|
|
3763
|
+
await existing.dispose().catch(() => {
|
|
3764
|
+
return;
|
|
3765
|
+
});
|
|
3766
|
+
const dispose = await page.intercept(rules);
|
|
3767
|
+
mockSessions.set(page.info.id, { rules, dispose });
|
|
3768
|
+
if (args.reload)
|
|
3769
|
+
await page.navigate({ reload: true }).catch(() => {
|
|
3770
|
+
return;
|
|
3771
|
+
});
|
|
3772
|
+
return { target: page.info, rule: { urlPattern: rule.urlPattern, action: rule.action }, ruleCount: rules.length };
|
|
3773
|
+
});
|
|
1920
3774
|
}
|
|
1921
|
-
async function
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
s.conn.close();
|
|
1926
|
-
} catch {}
|
|
1927
|
-
sessions.delete(id);
|
|
1928
|
-
continue;
|
|
1929
|
-
}
|
|
1930
|
-
try {
|
|
1931
|
-
await s.conn.send("Runtime.evaluate", { expression: "1", returnByValue: true }, { timeoutMs: 3000 });
|
|
1932
|
-
} catch {
|
|
1933
|
-
try {
|
|
1934
|
-
s.conn.close();
|
|
1935
|
-
} catch {}
|
|
1936
|
-
sessions.delete(id);
|
|
1937
|
-
}
|
|
1938
|
-
}
|
|
1939
|
-
const mocks = [...sessions.values()].map((s) => ({
|
|
1940
|
-
target: { id: s.target.id, url: s.target.url, title: s.target.title },
|
|
1941
|
-
rules: s.rules.map((r) => ({ urlPattern: r.urlPattern, action: r.action, method: r.method, hits: r.hits })),
|
|
1942
|
-
hits: s.intercepted.length
|
|
3775
|
+
async function listMocks2(_driver, _args = {}) {
|
|
3776
|
+
const sessions2 = [...mockSessions.entries()].map(([id, s]) => ({
|
|
3777
|
+
target: id,
|
|
3778
|
+
rules: s.rules.map((r) => ({ urlPattern: r.urlPattern, action: r.action, method: r.method }))
|
|
1943
3779
|
}));
|
|
1944
|
-
return {
|
|
3780
|
+
return { sessions: sessions2 };
|
|
1945
3781
|
}
|
|
1946
|
-
async function
|
|
1947
|
-
let ids;
|
|
3782
|
+
async function clearMocks2(driver, args = {}) {
|
|
1948
3783
|
if (args.all) {
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
3784
|
+
let n = 0;
|
|
3785
|
+
for (const [, s] of mockSessions) {
|
|
3786
|
+
await s.dispose().catch(() => {
|
|
3787
|
+
return;
|
|
3788
|
+
});
|
|
3789
|
+
n++;
|
|
3790
|
+
}
|
|
3791
|
+
mockSessions.clear();
|
|
3792
|
+
return { cleared: n };
|
|
3793
|
+
}
|
|
3794
|
+
return withPage3(driver, args.target, async (page) => {
|
|
3795
|
+
const existing = mockSessions.get(page.info.id);
|
|
3796
|
+
if (!existing)
|
|
3797
|
+
return { cleared: 0 };
|
|
3798
|
+
await existing.dispose().catch(() => {
|
|
3799
|
+
return;
|
|
3800
|
+
});
|
|
3801
|
+
mockSessions.delete(page.info.id);
|
|
3802
|
+
return { cleared: 1 };
|
|
3803
|
+
});
|
|
1969
3804
|
}
|
|
3805
|
+
var BIDI_ONLY_TOOLS = {
|
|
3806
|
+
list_console_messages: listConsoleMessages2,
|
|
3807
|
+
get_console_message: getConsoleMessage2,
|
|
3808
|
+
list_network_requests: listNetworkRequests2,
|
|
3809
|
+
get_network_request: getNetworkRequest2,
|
|
3810
|
+
mock_request: mockRequest2,
|
|
3811
|
+
list_mocks: listMocks2,
|
|
3812
|
+
clear_mocks: clearMocks2
|
|
3813
|
+
};
|
|
1970
3814
|
|
|
1971
|
-
// src/
|
|
1972
|
-
var
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
close_page: closePage,
|
|
1976
|
-
select_page: selectPage,
|
|
1977
|
-
navigate_page: navigatePage,
|
|
1978
|
-
wait_for: waitForText,
|
|
1979
|
-
evaluate_script: evaluateScript,
|
|
1980
|
-
take_snapshot: takeSnapshot,
|
|
1981
|
-
click,
|
|
1982
|
-
hover,
|
|
1983
|
-
drag,
|
|
1984
|
-
fill,
|
|
1985
|
-
fill_form: fillForm,
|
|
1986
|
-
type_text: typeText,
|
|
1987
|
-
press_key: pressKey,
|
|
1988
|
-
upload_file: uploadFile,
|
|
1989
|
-
take_screenshot: takeScreenshot,
|
|
1990
|
-
emulate,
|
|
1991
|
-
resize_page: resizePage,
|
|
1992
|
-
handle_dialog: handleDialog,
|
|
1993
|
-
list_console_messages: listConsoleMessages,
|
|
1994
|
-
get_console_message: getConsoleMessage,
|
|
1995
|
-
list_network_requests: listNetworkRequests,
|
|
1996
|
-
get_network_request: getNetworkRequest,
|
|
1997
|
-
performance_start_trace: performanceStartTrace,
|
|
1998
|
-
performance_stop_trace: performanceStopTrace,
|
|
1999
|
-
performance_analyze_insight: performanceAnalyzeInsight,
|
|
2000
|
-
performance_trace: performanceTrace,
|
|
2001
|
-
take_heapsnapshot: takeHeapsnapshot,
|
|
2002
|
-
lighthouse_audit: lighthouseAudit,
|
|
2003
|
-
mock_request: mockRequest,
|
|
2004
|
-
list_mocks: listMocks,
|
|
2005
|
-
clear_mocks: clearMocks
|
|
3815
|
+
// src/firefox-tools.ts
|
|
3816
|
+
var FIREFOX_TOOLS = {
|
|
3817
|
+
...SHARED_TOOLS,
|
|
3818
|
+
...BIDI_ONLY_TOOLS
|
|
2006
3819
|
};
|
|
2007
|
-
var TOOL_NAMES = Object.keys(TOOLS);
|
|
2008
3820
|
|
|
2009
3821
|
// src/cli.ts
|
|
2010
|
-
var USAGE = `cdp-toolkit
|
|
3822
|
+
var USAGE = `cdp-toolkit: raw single-target CDP, 29-tool chrome-devtools-mcp parity, plus a Firefox backend over WebDriver BiDi.
|
|
2011
3823
|
|
|
2012
3824
|
Usage:
|
|
2013
|
-
bun run src/cli.ts <tool> [--target <sel>] [--json '<obj>'] [--<key> <value> ...]
|
|
2014
|
-
bun run src/cli.ts --list
|
|
3825
|
+
bun run src/cli.ts <tool> [--browser chrome|firefox] [--target <sel>] [--json '<obj>'] [--<key> <value> ...]
|
|
3826
|
+
bun run src/cli.ts --list [--browser chrome|firefox]
|
|
3827
|
+
bun run src/cli.ts --capabilities [--browser chrome|firefox]
|
|
2015
3828
|
|
|
2016
3829
|
Examples:
|
|
2017
3830
|
bun run src/cli.ts list_pages
|
|
@@ -2019,9 +3832,17 @@ Examples:
|
|
|
2019
3832
|
bun run src/cli.ts click --target index:0 --uid 42
|
|
2020
3833
|
bun run src/cli.ts evaluate_script --json '{"expression":"1+2"}'
|
|
2021
3834
|
bun run src/cli.ts take_screenshot --target url:example --fullPage true
|
|
3835
|
+
bun run src/cli.ts --browser firefox take_snapshot
|
|
3836
|
+
bun run src/cli.ts --capabilities --browser firefox
|
|
3837
|
+
|
|
3838
|
+
Backend selection: --browser chrome|firefox, else the CDP_BROWSER env var, else "chrome" (default,
|
|
3839
|
+
zero behavior change for existing users). Firefox is LAUNCHED per invocation (it cannot be attached
|
|
3840
|
+
to), used for exactly one tool call, then disposed before the process exits.
|
|
2022
3841
|
|
|
2023
3842
|
Target selector grammar: active | index:N | url:<substr> | title:<substr> | <targetId>
|
|
2024
|
-
Run with --list to print every available
|
|
3843
|
+
Run with --list to print every tool name available under the selected backend.
|
|
3844
|
+
Run with --capabilities to see, per tool, whether it is available and (if not) which capability
|
|
3845
|
+
the selected backend is missing.`;
|
|
2025
3846
|
function coerce(raw) {
|
|
2026
3847
|
if (raw === "true")
|
|
2027
3848
|
return true;
|
|
@@ -2033,7 +3854,7 @@ function coerce(raw) {
|
|
|
2033
3854
|
return raw;
|
|
2034
3855
|
}
|
|
2035
3856
|
function parseArgv(argv) {
|
|
2036
|
-
const out = { list: false, args: {} };
|
|
3857
|
+
const out = { list: false, capabilities: false, args: {} };
|
|
2037
3858
|
let jsonObj;
|
|
2038
3859
|
const flagPairs = [];
|
|
2039
3860
|
for (let i = 0;i < argv.length; i++) {
|
|
@@ -2042,6 +3863,10 @@ function parseArgv(argv) {
|
|
|
2042
3863
|
out.list = true;
|
|
2043
3864
|
continue;
|
|
2044
3865
|
}
|
|
3866
|
+
if (token === "--capabilities") {
|
|
3867
|
+
out.capabilities = true;
|
|
3868
|
+
continue;
|
|
3869
|
+
}
|
|
2045
3870
|
if (token.startsWith("--")) {
|
|
2046
3871
|
const key = token.slice(2);
|
|
2047
3872
|
const next = argv[i + 1];
|
|
@@ -2077,9 +3902,25 @@ function isToolName(name) {
|
|
|
2077
3902
|
return Object.prototype.hasOwnProperty.call(TOOLS, name);
|
|
2078
3903
|
}
|
|
2079
3904
|
async function main() {
|
|
2080
|
-
const
|
|
3905
|
+
const rawArgv = process.argv.slice(2);
|
|
3906
|
+
const browser = resolveBrowserKind(rawArgv);
|
|
3907
|
+
const parsed = parseArgv(stripBrowserFlag(rawArgv));
|
|
3908
|
+
const availability = toolAvailability(browser);
|
|
3909
|
+
const availableSet = new Set(availability.available);
|
|
3910
|
+
if (parsed.capabilities) {
|
|
3911
|
+
const lines = [`backend: ${browser}`, "", "available:"];
|
|
3912
|
+
for (const name of availability.available)
|
|
3913
|
+
lines.push(` ${name}`);
|
|
3914
|
+
lines.push("", "unavailable:");
|
|
3915
|
+
for (const u of availability.unavailable)
|
|
3916
|
+
lines.push(` ${u.name} (needs: ${u.missing.join(", ")})`);
|
|
3917
|
+
process.stdout.write(`${lines.join(`
|
|
3918
|
+
`)}
|
|
3919
|
+
`);
|
|
3920
|
+
return 0;
|
|
3921
|
+
}
|
|
2081
3922
|
if (parsed.list) {
|
|
2082
|
-
process.stdout.write(`${
|
|
3923
|
+
process.stdout.write(`${availability.available.join(`
|
|
2083
3924
|
`)}
|
|
2084
3925
|
`);
|
|
2085
3926
|
return 0;
|
|
@@ -2094,11 +3935,36 @@ async function main() {
|
|
|
2094
3935
|
`);
|
|
2095
3936
|
return 1;
|
|
2096
3937
|
}
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
3938
|
+
if (!availableSet.has(parsed.tool)) {
|
|
3939
|
+
const gap = availability.unavailable.find((u) => u.name === parsed.tool);
|
|
3940
|
+
process.stderr.write(`${JSON.stringify({
|
|
3941
|
+
error: `tool '${parsed.tool}' is not available under --browser ${browser}${gap ? ` (needs: ${gap.missing.join(", ")})` : ""}. Run --capabilities --browser ${browser} to see the full list.`
|
|
3942
|
+
})}
|
|
3943
|
+
`);
|
|
3944
|
+
return 1;
|
|
3945
|
+
}
|
|
3946
|
+
if (browser === "chrome") {
|
|
3947
|
+
const fn = TOOLS[parsed.tool];
|
|
3948
|
+
const result = await fn(parsed.args);
|
|
3949
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
3950
|
+
`);
|
|
3951
|
+
return 0;
|
|
3952
|
+
}
|
|
3953
|
+
const neutralFn = FIREFOX_TOOLS[parsed.tool];
|
|
3954
|
+
if (!neutralFn) {
|
|
3955
|
+
process.stderr.write(`${JSON.stringify({ error: `tool '${parsed.tool}' has no Firefox implementation wired` })}
|
|
3956
|
+
`);
|
|
3957
|
+
return 1;
|
|
3958
|
+
}
|
|
3959
|
+
const session = await startFirefoxSession();
|
|
3960
|
+
try {
|
|
3961
|
+
const result = await neutralFn(session.driver, parsed.args);
|
|
3962
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
2100
3963
|
`);
|
|
2101
|
-
|
|
3964
|
+
return 0;
|
|
3965
|
+
} finally {
|
|
3966
|
+
await session.dispose();
|
|
3967
|
+
}
|
|
2102
3968
|
}
|
|
2103
3969
|
main().then((code) => process.exit(code)).catch((err) => {
|
|
2104
3970
|
const message = err instanceof Error ? err.message : String(err);
|