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/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/input.ts
514
- async function resolveElement(conn, t) {
515
- const hasUid = t.uid !== undefined && t.uid !== null;
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
- async function centerOf(conn, objectId) {
536
- const { result, exceptionDetails } = await conn.send("Runtime.callFunctionOn", {
537
- objectId,
538
- functionDeclaration: "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};}",
539
- returnByValue: true
540
- });
541
- if (exceptionDetails) {
542
- throw new CdpError(`could not measure element: ${exceptionDetails.text ?? "error"}`);
543
- }
544
- if (!result.value) {
545
- throw new CdpError("element has zero size / is not visible; cannot compute a click point");
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 result.value;
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
- async function hover(args) {
569
- return withPage(args.target, async (conn) => {
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
- async function drag(args) {
577
- if (!args.from || !args.to)
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
- async function setValue(conn, objectId, value) {
610
- await focusElement(conn, objectId);
611
- await conn.send("Runtime.callFunctionOn", {
612
- objectId,
613
- functionDeclaration: "function(){if('value'in this){this.value='';this.dispatchEvent(new Event('input',{bubbles:true}));}else if(this.isContentEditable){this.textContent='';}}",
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
- async function fill(args) {
621
- if (typeof args.value !== "string")
622
- throw new CdpError("fill requires a string { value }");
623
- return withPage(args.target, async (conn) => {
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
- async function typeText(args) {
630
- if (typeof args.text !== "string")
631
- throw new CdpError("type_text requires a string { text }");
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
- async function fillForm(args) {
641
- if (!Array.isArray(args.fields) || args.fields.length === 0) {
642
- throw new CdpError("fill_form requires a non-empty { fields } array");
643
- }
644
- return withPage(args.target, async (conn) => {
645
- let filled = 0;
646
- for (const field of args.fields) {
647
- if (typeof field.value !== "string") {
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
- const { objectId } = await resolveElement(conn, field);
651
- await setValue(conn, objectId, field.value);
652
- filled++;
653
- }
654
- return { filled, fields: args.fields.length };
655
- });
656
- }
657
- var NAMED_KEYS = {
658
- enter: { key: "Enter", code: "Enter", keyCode: 13, text: "\r" },
659
- return: { key: "Enter", code: "Enter", keyCode: 13, text: "\r" },
660
- tab: { key: "Tab", code: "Tab", keyCode: 9 },
661
- escape: { key: "Escape", code: "Escape", keyCode: 27 },
662
- esc: { key: "Escape", code: "Escape", keyCode: 27 },
663
- backspace: { key: "Backspace", code: "Backspace", keyCode: 8 },
664
- delete: { key: "Delete", code: "Delete", keyCode: 46 },
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 uploadFile(args) {
741
- const files = Array.isArray(args.files) ? args.files : [args.files];
742
- if (!files.length || files.some((f) => typeof f !== "string" || !f.length)) {
743
- throw new CdpError("upload_file requires { files } as a non-empty path or array of paths");
744
- }
745
- return withPage(args.target, async (conn) => {
746
- const { objectId } = await resolveElement(conn, args);
747
- await conn.send("DOM.setFileInputFiles", { files, objectId });
748
- return { uploaded: files };
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/tools/screenshot.ts
753
- import { writeFile as writeFile2, mkdir as mkdir2 } from "node:fs/promises";
754
- import { join } from "node:path";
755
- var ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
756
- function shortId(id) {
757
- return id.slice(0, 8);
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
- function stamp() {
760
- return new Date(Date.now()).toISOString().replace(/[:.]/g, "-");
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 new CdpError("Page.getLayoutMetrics returned no content size");
767
- return { x: 0, y: 0, width: Math.ceil(size.width), height: Math.ceil(size.height), scale: 1 };
768
- }
769
- async function elementClip(conn, uid, selector) {
770
- let backendNodeId;
771
- if (uid != null) {
772
- backendNodeId = uid;
773
- } else if (selector) {
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
- const minX = Math.min(...xs);
798
- const minY = Math.min(...ys);
799
- const maxX = Math.max(...xs);
800
- const maxY = Math.max(...ys);
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 new CdpError("resolved element has zero area");
805
- return { x: minX, y: minY, width, height, scale: 1 };
806
- }
807
- async function takeScreenshot(args = {}) {
808
- const { target, fullPage, uid, selector, savePath, returnBase64 } = args;
809
- if (uid != null && selector)
810
- throw new CdpError("provide exactly one of uid or selector, not both");
811
- const format = args.format ?? "png";
812
- const quality = format === "jpeg" ? args.quality ?? 80 : undefined;
813
- return withPage(target, async (conn, t) => {
814
- await conn.send("Page.enable");
815
- const params = { format, captureBeyondViewport: true };
816
- if (quality != null)
817
- params.quality = quality;
818
- if (uid != null || selector) {
819
- params.clip = await elementClip(conn, uid, selector);
820
- } else if (fullPage) {
821
- params.clip = await fullPageClip(conn);
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
- // src/tools/emulation.ts
844
- async function clearAll(conn) {
845
- await conn.send("Emulation.clearDeviceMetricsOverride").catch(() => {
846
- return;
847
- });
848
- await conn.send("Emulation.setUserAgentOverride", { userAgent: "" }).catch(() => {
849
- return;
850
- });
851
- await conn.send("Emulation.setCPUThrottlingRate", { rate: 1 }).catch(() => {
852
- return;
853
- });
854
- await conn.send("Emulation.setEmulatedMedia", { media: "", features: [] }).catch(() => {
855
- return;
856
- });
857
- await conn.send("Network.enable").catch(() => {
858
- return;
859
- });
860
- await conn.send("Network.emulateNetworkConditions", {
861
- offline: false,
862
- latency: 0,
863
- downloadThroughput: -1,
864
- uploadThroughput: -1
865
- }).catch(() => {
866
- return;
867
- });
868
- }
869
- async function emulate(args = {}) {
870
- const { target } = args;
871
- return withPage(target, async (conn, t) => {
872
- if (args.clearOverrides) {
873
- await clearAll(conn);
874
- return {
875
- target: { id: t.id, url: t.url, title: t.title },
876
- applied: [],
877
- cleared: true
878
- };
879
- }
880
- const applied = [];
881
- if (args.width != null || args.height != null) {
882
- if (args.width == null || args.height == null) {
883
- throw new CdpError("emulate device metrics require both width and height");
884
- }
885
- await conn.send("Emulation.setDeviceMetricsOverride", {
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
- applied.push("deviceMetrics");
892
- }
893
- if (args.userAgent != null) {
894
- await conn.send("Emulation.setUserAgentOverride", { userAgent: args.userAgent });
895
- applied.push("userAgent");
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
- if (args.cpuThrottlingRate != null) {
898
- if (args.cpuThrottlingRate < 1)
899
- throw new CdpError("cpuThrottlingRate must be >= 1");
900
- await conn.send("Emulation.setCPUThrottlingRate", { rate: args.cpuThrottlingRate });
901
- applied.push("cpuThrottlingRate");
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
- if (args.media != null || args.mediaFeatures != null) {
904
- await conn.send("Emulation.setEmulatedMedia", {
905
- media: args.media ?? "",
906
- features: args.mediaFeatures ?? []
907
- });
908
- applied.push("emulatedMedia");
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
- if (args.networkConditions != null) {
911
- const n = args.networkConditions;
912
- await conn.send("Network.enable").catch(() => {
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
- await conn.send("Network.emulateNetworkConditions", {
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
- if (applied.length === 0) {
925
- throw new CdpError("emulate: no overrides specified (pass clearOverrides:true to reset)");
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 { target: { id: t.id, url: t.url, title: t.title }, applied };
928
- });
929
- }
930
- async function resizePage(args) {
931
- const { target, width, height } = args;
932
- if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
933
- throw new CdpError("resize_page requires positive numeric width and height");
934
- }
935
- return withPage(target, async (conn, t) => {
936
- await conn.send("Emulation.setDeviceMetricsOverride", {
937
- width,
938
- height,
939
- deviceScaleFactor: args.deviceScaleFactor ?? 0,
940
- mobile: args.mobile ?? false
941
- });
942
- await conn.send("Runtime.enable").catch(() => {
943
- return;
944
- });
945
- const { result } = await conn.send("Runtime.evaluate", {
946
- expression: "({ w: window.innerWidth, h: window.innerHeight })",
947
- returnByValue: true
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
- const v = result.value;
950
- return {
951
- target: { id: t.id, url: t.url, title: t.title },
952
- width,
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 disarm = armDialogHandler(conn, accept, promptText, (d) => handled.push(d));
991
- await new Promise((resolve) => setTimeout(resolve, autoMs));
992
- disarm();
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
- return await new Promise((resolve, reject) => {
959
+ const timeoutMs = opts?.timeoutMs ?? 15000;
960
+ return new Promise((resolve, reject) => {
996
961
  const timer = setTimeout(() => {
997
- disarm();
998
- reject(new CdpError(`handle_dialog: no dialog opened within ${timeoutMs}ms`));
962
+ off();
963
+ reject(driverError("timeout", `handleDialog: no dialog opened within ${timeoutMs}ms`));
999
964
  }, timeoutMs);
1000
- const disarm = armDialogHandler(conn, accept, promptText, (d) => {
965
+ const off = this.armDialog(accept, promptText, (d) => {
1001
966
  clearTimeout(timer);
1002
- disarm();
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 mkdir3, appendFile, writeFile as writeFile3, copyFile } from "node:fs/promises";
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 mkdir3(ARTIFACT_DIR2, { recursive: true });
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 writeFile3(file, "");
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/tools/performance.ts
1387
- import { mkdir as mkdir4, readFile as readFile3, writeFile as writeFile4, stat, unlink } from "node:fs/promises";
1388
- import { join as join2 } from "node:path";
1389
- var ARTIFACT_DIR3 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
1390
- var STATE_DIR2 = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
1391
- var DEFAULT_CATEGORIES = [
1392
- "devtools.timeline",
1393
- "blink.user_timing",
1394
- "loading",
1395
- "disabled-by-default-devtools.timeline",
1396
- "disabled-by-default-devtools.timeline.frame"
1397
- ];
1398
- var liveTraces = new Map;
1399
- function stamp2() {
1400
- return new Date().toISOString().replace(/[:.]/g, "-");
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 shortId2(id) {
1403
- return id.slice(0, 8);
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
- function stateFilePath() {
1406
- return join2(STATE_DIR2, "trace-state.json");
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 bufferTraceData(conn, sink) {
1409
- return conn.on("Tracing.dataCollected", (params) => {
1410
- const value = params.value;
1411
- if (Array.isArray(value))
1412
- sink.push(...value);
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
- async function beginTrace(conn, categories) {
1416
- await conn.send("Tracing.start", {
1417
- traceConfig: { includedCategories: categories },
1418
- transferMode: "ReportEvents"
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 endTrace(conn, sink, timeoutMs = 30000) {
1422
- const complete = conn.waitFor("Tracing.tracingComplete", undefined, timeoutMs);
1423
- await conn.send("Tracing.end");
1424
- await complete;
1425
- return sink;
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 writeTrace(events, targetId) {
1428
- await mkdir4(ARTIFACT_DIR3, { recursive: true });
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 performanceStartTrace(args = {}) {
1436
- const categories = args.categories?.length ? args.categories : DEFAULT_CATEGORIES;
1437
- const { conn, target } = await openPage(args.target);
1438
- if (liveTraces.has(target.id)) {
1439
- conn.close();
1440
- throw new CdpError(`a trace is already in progress for target ${shortId2(target.id)}; call performanceStopTrace first`);
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
- const events = [];
1443
- bufferTraceData(conn, events);
1444
- try {
1445
- await beginTrace(conn, categories);
1446
- } catch (err) {
1447
- conn.close();
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
- liveTraces.set(target.id, { conn, target, events, startedAt: Date.now() });
1451
- await mkdir4(STATE_DIR2, { recursive: true });
1452
- const state = {
1453
- targetId: target.id,
1454
- url: target.url,
1455
- startedAt: Date.now(),
1456
- pid: process.pid,
1457
- categories
1458
- };
1459
- await writeFile4(stateFilePath(), JSON.stringify(state), "utf8");
1460
- return {
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
- async function performanceStopTrace(args = {}) {
1467
- args.target;
1468
- const entry = liveTraces.size === 1 ? [...liveTraces.values()][0] : undefined;
1469
- if (!entry) {
1470
- let hint = "";
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
- const raw = await readFile3(stateFilePath(), "utf8");
1473
- const state = JSON.parse(raw);
1474
- hint = state.pid === process.pid ? ` (state file lists target ${shortId2(state.targetId)} but its live connection is gone)` : ` (trace was started by a DIFFERENT process pid=${state.pid}; live recordings cannot cross process boundaries)`;
1475
- } catch {}
1476
- throw new CdpError(`no in-process trace to stop${hint}. Use performanceTrace for a robust single-call trace.`);
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
- const { conn, target, events } = entry;
1479
- try {
1480
- await endTrace(conn, events);
1481
- } finally {
1482
- conn.close();
1483
- liveTraces.delete(target.id);
1484
- await unlink(stateFilePath()).catch(() => {});
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
- const written = await writeTrace(events, target.id);
1487
- const metrics = analyzeEvents(events);
1488
- return { ...written, events: events.length, metrics };
1489
- }
1490
- async function performanceTrace(args = {}) {
1491
- const durationMs = args.durationMs ?? 3000;
1492
- const categories = args.categories?.length ? args.categories : DEFAULT_CATEGORIES;
1493
- const { conn, target } = await openPage(args.target);
1494
- const events = [];
1495
- bufferTraceData(conn, events);
1496
- try {
1497
- await conn.send("Page.enable");
1498
- await beginTrace(conn, categories);
1499
- if (args.navigateTo) {
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
- const written = await writeTrace(events, target.id);
1514
- const metrics = analyzeEvents(events);
1515
- return { ...written, events: events.length, metrics, target: { id: target.id, url: target.url } };
1516
- }
1517
- function analyzeEvents(events) {
1518
- let navigationStartUs;
1519
- let fcpUs;
1520
- let lcpUs;
1521
- let dclUs;
1522
- let loadUs;
1523
- let cls = 0;
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
- if ((name === "firstContentfulPaint" || name === "FirstContentfulPaint") && typeof e.ts === "number") {
1539
- if (fcpUs === undefined || e.ts < fcpUs)
1540
- fcpUs = e.ts;
1541
- continue;
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
- if (name === "largestContentfulPaint::Candidate" && typeof e.ts === "number") {
1544
- if (lcpUs === undefined || e.ts > lcpUs)
1545
- lcpUs = e.ts;
1546
- continue;
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
- if ((name === "domContentLoadedEventEnd" || name === "DOMContentLoaded") && typeof e.ts === "number") {
1549
- if (dclUs === undefined || e.ts > dclUs)
1550
- dclUs = e.ts;
1551
- continue;
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
- if ((name === "loadEventEnd" || name === "MarkLoad") && typeof e.ts === "number") {
1554
- if (loadUs === undefined || e.ts > loadUs)
1555
- loadUs = e.ts;
1556
- continue;
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
- if (name === "LayoutShift") {
1559
- const data = e.args?.data;
1560
- const hadRecentInput = data?.had_recent_input === true;
1561
- const score = typeof data?.score === "number" ? data.score : undefined;
1562
- if (!hadRecentInput && typeof score === "number") {
1563
- cls += score;
1564
- layoutShifts += 1;
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
- continue;
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
- if (name === "RunTask" && typeof e.dur === "number") {
1569
- const durMs = e.dur / 1000;
1570
- if (durMs > 50) {
1571
- longTasks += 1;
1572
- longTasksTotalMs += durMs;
1573
- totalBlockingTimeMs += durMs - 50;
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
- continue;
3301
+ return { handled, count: handled.length };
1576
3302
  }
3303
+ return this.handleOneDialog(accept, promptText, opts?.timeoutMs ?? 15000);
1577
3304
  }
1578
- const relMs = (us) => us !== undefined && navigationStartUs !== undefined ? (us - navigationStartUs) / 1000 : undefined;
1579
- const metrics = {
1580
- navigationStartUs,
1581
- fcpMs: relMs(fcpUs),
1582
- lcpMs: relMs(lcpUs),
1583
- domContentLoadedMs: relMs(dclUs),
1584
- loadMs: relMs(loadUs),
1585
- cls: layoutShifts > 0 ? Number(cls.toFixed(4)) : 0,
1586
- totalBlockingTimeMs: Number(totalBlockingTimeMs.toFixed(2))
1587
- };
1588
- return {
1589
- metrics,
1590
- longTasks,
1591
- longTasksTotalMs: Number(longTasksTotalMs.toFixed(2)),
1592
- layoutShifts,
1593
- eventCount: events.length
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
- let raw;
1601
- try {
1602
- raw = await readFile3(args.tracePath, "utf8");
1603
- } catch (err) {
1604
- throw new CdpError(`cannot read trace file '${args.tracePath}': ${err.message}`);
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
- let parsed;
1607
- try {
1608
- parsed = JSON.parse(raw);
1609
- } catch (err) {
1610
- throw new CdpError(`trace file '${args.tracePath}' is not valid JSON: ${err.message}`);
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
- // src/tools/heap.ts
1619
- import { mkdir as mkdir5, writeFile as writeFile5 } from "node:fs/promises";
1620
- import { join as join3 } from "node:path";
1621
- var ARTIFACT_DIR4 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
1622
- function stamp3(targetId) {
1623
- const short = targetId.slice(0, 8);
1624
- const iso = new Date().toISOString().replace(/[:.]/g, "-");
1625
- return `${short}-${iso}`;
1626
- }
1627
- async function takeHeapsnapshot(args = {}) {
1628
- return withPage(args.target, async (conn, target) => {
1629
- await conn.send("HeapProfiler.enable");
1630
- const parts = [];
1631
- const off = conn.on("HeapProfiler.addHeapSnapshotChunk", (params) => {
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("HeapProfiler.takeHeapSnapshot", { reportProgress: false, captureNumericValue: false }, { timeoutMs: 120000 });
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
- off();
3409
+ conn.release();
1640
3410
  }
1641
- const snapshot = parts.join("");
1642
- if (snapshot.length === 0) {
1643
- throw new Error("HeapProfiler.takeHeapSnapshot produced no chunks");
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
- await mkdir5(ARTIFACT_DIR4, { recursive: true });
1646
- const path = args.savePath ? args.savePath.startsWith("/") ? args.savePath : join3(ARTIFACT_DIR4, args.savePath) : join3(ARTIFACT_DIR4, `take_heapsnapshot-${stamp3(target.id)}.heapsnapshot`);
1647
- await writeFile5(path, snapshot, "utf8");
1648
- const bytes = Buffer.byteLength(snapshot, "utf8");
1649
- return {
1650
- path,
1651
- bytes,
1652
- chunks: parts.length,
1653
- target: { id: target.id, url: target.url, title: target.title }
1654
- };
1655
- });
1656
- }
1657
-
1658
- // src/tools/lighthouse.ts
1659
- import { spawn } from "node:child_process";
1660
- import { mkdir as mkdir6, readFile as readFile4, stat as stat2 } from "node:fs/promises";
1661
- var ARTIFACT_DIR5 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
1662
- function cdpPort() {
1663
- const base = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
1664
- const m = base.match(/:(\d+)/);
1665
- return m?.[1] ? Number(m[1]) : 9222;
1666
- }
1667
- function stamp4() {
1668
- return new Date(Date.now()).toISOString().replace(/[:.]/g, "-");
1669
- }
1670
- function shortHost(url) {
1671
- try {
1672
- const u = new URL(url);
1673
- const h = u.hostname || u.protocol.replace(/:$/, "");
1674
- return (h || "page").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 24);
1675
- } catch {
1676
- return "page";
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 runProcess(cmd, args, opts) {
1680
- return new Promise((resolve) => {
1681
- let stdout = "";
1682
- let stderr = "";
1683
- let settled = false;
1684
- const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
1685
- const timer = setTimeout(() => {
1686
- if (settled)
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
- try {
1689
- child.kill("SIGKILL");
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
- child.on("error", (err) => {
1699
- if (settled)
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
- settled = true;
1702
- clearTimeout(timer);
1703
- resolve({ code: null, signal: null, stdout, stderr, spawnError: err });
3508
+ }
3509
+ const { port } = addr;
3510
+ server.close(() => resolve(port));
1704
3511
  });
1705
- child.on("close", (code, signal) => {
1706
- if (settled)
1707
- return;
1708
- settled = true;
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({ code, signal, stdout, stderr });
3545
+ resolve();
1711
3546
  });
1712
3547
  });
1713
3548
  }
1714
- async function lighthouseAudit(args) {
1715
- if (!args || typeof args.url !== "string" || args.url.trim() === "") {
1716
- throw new CdpError("lighthouse_audit requires a non-empty 'url'");
1717
- }
1718
- const url = args.url.trim();
1719
- const timeoutMs = args.timeoutMs ?? 120000;
1720
- const port = cdpPort();
1721
- await mkdir6(ARTIFACT_DIR5, { recursive: true });
1722
- const outPath = args.savePath ?? `${ARTIFACT_DIR5}/lighthouse-${shortHost(url)}-${stamp4()}.json`;
1723
- const formFactor = args.formFactor ?? "desktop";
1724
- const lhArgs = [
1725
- "--yes",
1726
- "lighthouse",
1727
- url,
1728
- `--port=${port}`,
1729
- "--output=json",
1730
- `--output-path=${outPath}`,
1731
- "--quiet",
1732
- "--chrome-flags=--headless=new"
1733
- ];
1734
- if (formFactor === "desktop") {
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
- throw new CdpError(`lighthouse_audit failed to spawn npx/lighthouse: ${res.spawnError.message}`);
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
- 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." : "";
1759
- throw new CdpError(`lighthouse_audit produced no report (exit code ${res.code}).${hint} stderr: ${truncate(res.stderr)}`);
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
- let report;
3580
+ const timeoutMs = opts.timeoutMs ?? 15000;
1762
3581
  try {
1763
- report = JSON.parse(raw);
1764
- } catch (e) {
1765
- throw new CdpError(`lighthouse_audit wrote a report that is not valid JSON (${e.message})`);
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
- const categories = {};
1771
- for (const [key, cat] of Object.entries(report.categories ?? {})) {
1772
- categories[key] = cat?.score ?? null;
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
- if (Object.keys(categories).length === 0) {
1775
- throw new CdpError(`lighthouse_audit report contained no category scores (exit code ${res.code}). stderr: ${truncate(res.stderr)}`);
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
- const { size } = await stat2(outPath);
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
- path: outPath,
1780
- bytes: size,
1781
- url,
1782
- finalUrl: report.finalDisplayedUrl ?? report.finalUrl,
1783
- lighthouseVersion: report.lighthouseVersion,
1784
- fetchTime: report.fetchTime,
1785
- categories,
1786
- warnings: Array.isArray(report.runWarnings) && report.runWarnings.length > 0 ? report.runWarnings : undefined
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
- function truncate(s, max = 600) {
1790
- const t = s.trim();
1791
- return t.length > max ? `${t.slice(0, max)}…` : t;
3628
+ var sessionPromise;
3629
+ function getOrCreateFirefoxSession() {
3630
+ if (!sessionPromise)
3631
+ sessionPromise = startFirefoxSession();
3632
+ return sessionPromise;
1792
3633
  }
1793
-
1794
- // src/tools/network_mock.ts
1795
- function escapeRegExpChar(ch) {
1796
- return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
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
- function globToRegExp(pattern) {
1799
- let re = "";
1800
- for (let i = 0;i < pattern.length; i++) {
1801
- const ch = pattern[i];
1802
- if (ch === "\\") {
1803
- const next = pattern[i + 1];
1804
- if (next !== undefined) {
1805
- re += escapeRegExpChar(next);
1806
- i++;
1807
- } else {
1808
- re += "\\\\";
1809
- }
1810
- } else if (ch === "*") {
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
- re += escapeRegExpChar(ch);
3657
+ unavailable.push({ name, missing: required.filter((c) => !caps.has(c)) });
1816
3658
  }
1817
3659
  }
1818
- return new RegExp(`^${re}$`);
1819
- }
1820
- function urlMatches(pattern, url) {
1821
- return globToRegExp(pattern).test(url);
3660
+ return { kind, available, unavailable };
1822
3661
  }
1823
- function selectRule(rules, url, method) {
1824
- return rules.find((r) => urlMatches(r.urlPattern, url) && (!r.method || r.method.toUpperCase() === method.toUpperCase()));
3662
+
3663
+ // src/bidi-tools.ts
3664
+ class BidiToolError extends Error {
1825
3665
  }
1826
- function buildFulfillParams(requestId, rule) {
1827
- const merged = new Map;
1828
- merged.set("content-type", { name: "Content-Type", value: rule.contentType ?? "application/json" });
1829
- for (const [name, value] of Object.entries(rule.headers ?? {})) {
1830
- merged.set(name.toLowerCase(), { name, value });
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 effectiveAction(rule, rnd) {
1840
- if (rule.failRate !== undefined && rule.failRate > 0 && rnd < rule.failRate)
1841
- return "fail";
1842
- return rule.action;
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
- var sessions = new Map;
1845
- function delay3(ms) {
1846
- return new Promise((r) => setTimeout(r, ms));
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 registerHandler(session) {
1849
- session.conn.on("Fetch.requestPaused", (params) => {
1850
- const p = params;
1851
- session.chain = session.chain.then(async () => {
1852
- const rule = selectRule(session.rules, p.request.url, p.request.method);
1853
- if (!rule) {
1854
- await session.conn.send("Fetch.continueRequest", { requestId: p.requestId });
1855
- return;
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 mockRequest(args) {
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 CdpError("mock_request requires a { urlPattern }");
1878
- const rule = {
1879
- urlPattern: args.urlPattern,
1880
- action: args.action ?? "fulfill",
1881
- status: args.status,
1882
- body: args.body,
1883
- contentType: args.contentType,
1884
- headers: args.headers,
1885
- errorReason: args.errorReason,
1886
- method: args.method,
1887
- delayMs: args.delayMs,
1888
- failRate: args.failRate,
1889
- hits: 0
1890
- };
1891
- const resolved = await resolveTarget(args.target);
1892
- let session = sessions.get(resolved.id);
1893
- if (session && !session.dead) {
1894
- const idx = session.rules.findIndex((r) => r.urlPattern === rule.urlPattern && (r.method ?? "") === (rule.method ?? ""));
1895
- if (idx >= 0)
1896
- session.rules[idx] = rule;
1897
- else
1898
- session.rules.push(rule);
1899
- await session.conn.send("Fetch.enable", { patterns: session.rules.map((r) => ({ urlPattern: r.urlPattern })) });
1900
- } else {
1901
- const { conn, target } = await openPage(resolved.id);
1902
- session = { conn, target, rules: [rule], intercepted: [], chain: Promise.resolve(), dead: false };
1903
- registerHandler(session);
1904
- await conn.send("Fetch.enable", { patterns: [{ urlPattern: rule.urlPattern }] });
1905
- sessions.set(target.id, session);
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 listMocks(_args = {}) {
1922
- for (const [id, s] of sessions) {
1923
- if (s.dead) {
1924
- try {
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 { count: mocks.length, mocks };
3780
+ return { sessions: sessions2 };
1945
3781
  }
1946
- async function clearMocks(args = {}) {
1947
- let ids;
3782
+ async function clearMocks2(driver, args = {}) {
1948
3783
  if (args.all) {
1949
- ids = [...sessions.keys()];
1950
- } else {
1951
- const resolved = await resolveTarget(args.target);
1952
- ids = sessions.has(resolved.id) ? [resolved.id] : [];
1953
- }
1954
- let cleared = 0;
1955
- for (const id of ids) {
1956
- const s = sessions.get(id);
1957
- if (!s)
1958
- continue;
1959
- try {
1960
- await s.conn.send("Fetch.disable");
1961
- } catch {}
1962
- try {
1963
- s.conn.close();
1964
- } catch {}
1965
- sessions.delete(id);
1966
- cleared++;
1967
- }
1968
- return { cleared };
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/index.ts
1972
- var TOOLS = {
1973
- list_pages: listPages,
1974
- new_page: newPage,
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 — raw single-target CDP, 29-tool chrome-devtools-mcp parity.
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 tool name.`;
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 parsed = parseArgv(process.argv.slice(2));
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(`${TOOL_NAMES.join(`
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
- const fn = TOOLS[parsed.tool];
2098
- const result = await fn(parsed.args);
2099
- process.stdout.write(`${JSON.stringify(result, null, 2)}
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
- return 0;
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);