cdp-toolkit 1.7.0 → 1.8.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/index.js CHANGED
@@ -484,6 +484,141 @@ async function withPage(selector, fn, opts = {}) {
484
484
  }
485
485
  }
486
486
 
487
+ // src/activity.ts
488
+ var BEACON_DATA_GLOBAL = "__cdpToolkitLastInput";
489
+ var BEACON_INSTALLED_GLOBAL = "__cdpToolkitBeacon";
490
+ var BEACON_EVENTS = ["pointerdown", "mousedown", "keydown", "wheel", "touchstart"];
491
+ var BEACON_BODY = `
492
+ if (!window.${BEACON_INSTALLED_GLOBAL}) {
493
+ window.${BEACON_INSTALLED_GLOBAL} = true;
494
+ var mark = function () { window.${BEACON_DATA_GLOBAL} = Date.now(); };
495
+ var events = ${JSON.stringify([...BEACON_EVENTS])};
496
+ for (var i = 0; i < events.length; i++) {
497
+ window.addEventListener(events[i], mark, { capture: true, passive: true });
498
+ }
499
+ }`;
500
+ var BEACON_SOURCE = `(function () {${BEACON_BODY}
501
+ })();`;
502
+ var BEACON_FUNCTION_DECLARATION = `function () {${BEACON_BODY}
503
+ }`;
504
+ var BEACON_READ_EXPRESSION = `(typeof window.${BEACON_DATA_GLOBAL} === "number" ? window.${BEACON_DATA_GLOBAL} : null)`;
505
+ var DISPATCH_ATTRIBUTION_WINDOW_MS = 1500;
506
+ function dispatchKey(backend, targetId) {
507
+ return `${backend}:${targetId}`;
508
+ }
509
+ var dispatchLog = new Map;
510
+ var DISPATCH_LOG_LIMIT = 512;
511
+ function recordDispatch(backend, targetId, now = Date.now()) {
512
+ const key = dispatchKey(backend, targetId);
513
+ dispatchLog.delete(key);
514
+ dispatchLog.set(key, now);
515
+ while (dispatchLog.size > DISPATCH_LOG_LIMIT) {
516
+ const oldest = dispatchLog.keys().next();
517
+ if (oldest.done)
518
+ break;
519
+ dispatchLog.delete(oldest.value);
520
+ }
521
+ }
522
+ var NON_SYNTHESIZING_INPUT_METHODS = new Set(["Input.setInterceptDrags", "Input.setIgnoreInputEvents"]);
523
+ async function sendInput(conn, backend, targetId, method, params = {}) {
524
+ if (!NON_SYNTHESIZING_INPUT_METHODS.has(method))
525
+ recordDispatch(backend, targetId);
526
+ return conn.send(method, params);
527
+ }
528
+ function lastDispatchAt(backend, targetId) {
529
+ return dispatchLog.get(dispatchKey(backend, targetId));
530
+ }
531
+ function isHumanAttributed(beaconTs, dispatchAt, windowMs = DISPATCH_ATTRIBUTION_WINDOW_MS) {
532
+ if (typeof beaconTs !== "number" || !Number.isFinite(beaconTs))
533
+ return false;
534
+ return beaconTs > (dispatchAt ?? 0) + windowMs;
535
+ }
536
+ function humanActiveMs(beaconTs, dispatchAt, now = Date.now(), windowMs = DISPATCH_ATTRIBUTION_WINDOW_MS) {
537
+ if (!isHumanAttributed(beaconTs, dispatchAt, windowMs))
538
+ return null;
539
+ return Math.max(0, now - beaconTs);
540
+ }
541
+ var CONTENTION_WINDOW_MS = 30000;
542
+ function contentionWarning(ms, opts) {
543
+ if (!opts.takeover)
544
+ return;
545
+ if (ms === null || ms >= CONTENTION_WINDOW_MS)
546
+ return;
547
+ return `a person appears to be using this tab right now: input not dispatched by this server landed ${ms}ms ago. ` + `The claim SUCCEEDED and you hold the lease — this is a warning, not a refusal. ` + `Driving the tab now will fight them for the keyboard and mouse. ` + `Consider claim_page with no target to work in your own tab instead, or ask before continuing.`;
548
+ }
549
+ function beaconSupported(driver) {
550
+ return typeof driver.installActivityBeacon === "function" && typeof driver.readActivityBeacon === "function";
551
+ }
552
+ async function installBeacon(driver, targetId) {
553
+ if (typeof driver.installActivityBeacon !== "function")
554
+ return false;
555
+ return driver.installActivityBeacon(targetId).catch(() => false);
556
+ }
557
+ async function readHumanActiveMs(driver, backend, targetId, now = Date.now()) {
558
+ if (typeof driver.readActivityBeacon !== "function")
559
+ return null;
560
+ const beaconTs = await driver.readActivityBeacon(targetId).catch(() => null);
561
+ return humanActiveMs(beaconTs, lastDispatchAt(backend, targetId), now);
562
+ }
563
+ var RENDERER_PROBE_TIMEOUT_MS = 500;
564
+ var RENDERER_PROBE_EXPRESSION = `[1, ${BEACON_READ_EXPRESSION}]`;
565
+ function rendererProbeSupported(driver) {
566
+ return typeof driver.probeRenderer === "function";
567
+ }
568
+ async function probeRendererActivity(driver, backend, targetId, now = Date.now()) {
569
+ if (typeof driver.probeRenderer !== "function")
570
+ return { responsive: false };
571
+ const { responsive, beaconTs } = await driver.probeRenderer(targetId, RENDERER_PROBE_TIMEOUT_MS).catch(() => ({ responsive: false, beaconTs: null }));
572
+ const ms = humanActiveMs(beaconTs, lastDispatchAt(backend, targetId), now);
573
+ return ms === null ? { responsive } : { responsive, humanActiveMs: ms };
574
+ }
575
+
576
+ class BeaconSessions {
577
+ close;
578
+ limit;
579
+ live = new Map;
580
+ constructor(close, limit = 32) {
581
+ this.close = close;
582
+ this.limit = limit;
583
+ }
584
+ get(key) {
585
+ return this.live.get(key);
586
+ }
587
+ set(key, value) {
588
+ const previous = this.live.get(key);
589
+ if (previous !== undefined) {
590
+ this.live.delete(key);
591
+ this.safeClose(previous);
592
+ }
593
+ this.live.set(key, value);
594
+ while (this.live.size > this.limit) {
595
+ const oldest = this.live.keys().next();
596
+ if (oldest.done)
597
+ break;
598
+ this.drop(oldest.value);
599
+ }
600
+ }
601
+ drop(key) {
602
+ const value = this.live.get(key);
603
+ if (value === undefined)
604
+ return;
605
+ this.live.delete(key);
606
+ this.safeClose(value);
607
+ }
608
+ clear() {
609
+ for (const key of [...this.live.keys()])
610
+ this.drop(key);
611
+ }
612
+ get size() {
613
+ return this.live.size;
614
+ }
615
+ safeClose(value) {
616
+ try {
617
+ this.close(value);
618
+ } catch {}
619
+ }
620
+ }
621
+
487
622
  // src/tools/snapshot.ts
488
623
  var INTERACTIVE_ROLES = new Set([
489
624
  "button",
@@ -707,11 +842,22 @@ var REQUIRED_CAPABILITIES = {
707
842
  stop_screen_recording: ["capture.screencast"],
708
843
  mock_request: ["network.intercept"],
709
844
  list_mocks: ["network.intercept"],
710
- clear_mocks: ["network.intercept"]
845
+ clear_mocks: ["network.intercept"],
846
+ dispatch_mouse: ["input.raw"],
847
+ wait_for_download: ["browser.downloads"],
848
+ grant_permissions: ["browser.permissions"]
711
849
  };
712
850
  function isDriverError(e) {
713
851
  return e instanceof Error && typeof e.code === "string";
714
852
  }
853
+ function interpolatePoints(from, to, steps) {
854
+ const out = [];
855
+ for (let i = 1;i < steps; i++) {
856
+ out.push({ x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps });
857
+ }
858
+ out.push({ x: to.x, y: to.y });
859
+ return out;
860
+ }
715
861
 
716
862
  // src/cdp/driver.ts
717
863
  function driverError(code, message, data) {
@@ -767,12 +913,21 @@ async function centerOf(conn, objectId) {
767
913
  async function focusElement(conn, objectId) {
768
914
  await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: "function(){this.focus&&this.focus();}", returnByValue: true });
769
915
  }
770
- async function setValueOnObject(conn, objectId, value) {
916
+ var ARM_SCROLL_SETTLE_WATCH = "(function(){window.__cdpScrollSettle=new Promise((resolve)=>{let t;const done=()=>{window.removeEventListener('scroll',on,true);resolve(true);};" + "const on=()=>{clearTimeout(t);t=setTimeout(done,60);};window.addEventListener('scroll',on,true);t=setTimeout(done,60);setTimeout(done,500);});})()";
917
+ async function armScrollSettleWatch(conn) {
918
+ await conn.send("Runtime.evaluate", { expression: ARM_SCROLL_SETTLE_WATCH, returnByValue: true });
919
+ }
920
+ async function awaitScrollSettle(conn) {
921
+ await conn.send("Runtime.evaluate", { expression: "window.__cdpScrollSettle", awaitPromise: true, returnByValue: true }).catch(() => {
922
+ return;
923
+ });
924
+ }
925
+ async function setValueOnObject(conn, objectId, value, sendInput2) {
771
926
  await focusElement(conn, objectId);
772
927
  const fn = "function(){if('value'in this){this.value='';this.dispatchEvent(new Event('input',{bubbles:true}));}else if(this.isContentEditable){this.textContent='';}}";
773
928
  await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: fn, returnByValue: true });
774
929
  if (value.length)
775
- await conn.send("Input.insertText", { text: value });
930
+ await sendInput2("Input.insertText", { text: value });
776
931
  }
777
932
  var NAMED_KEYS = {
778
933
  enter: { key: "Enter", code: "Enter", keyCode: 13, text: "\r" },
@@ -797,6 +952,18 @@ var NAMED_KEYS = {
797
952
  pagedown: { key: "PageDown", code: "PageDown", keyCode: 34 }
798
953
  };
799
954
  var MODIFIER_BITS = { alt: 1, control: 2, ctrl: 2, meta: 4, cmd: 4, command: 4, shift: 8 };
955
+ var CLICK_MODIFIER_BITS = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
956
+ function inputModifierBits(mods) {
957
+ let bits = 0;
958
+ for (const m of mods ?? []) {
959
+ const bit = CLICK_MODIFIER_BITS[m];
960
+ if (bit === undefined)
961
+ throw driverError("page-error", `unknown modifier '${m}' (use Alt, Control, Meta, or Shift)`);
962
+ bits |= bit;
963
+ }
964
+ return bits;
965
+ }
966
+ var HTML5_DRAG_INTERCEPT_TIMEOUT_MS = 5000;
800
967
  function resolveKey(key) {
801
968
  const named = NAMED_KEYS[key.toLowerCase()];
802
969
  if (named)
@@ -961,7 +1128,11 @@ var CDP_CAPABILITIES = new Set([
961
1128
  "input.insertTextAtomic",
962
1129
  "locate.text",
963
1130
  "locate.xpath",
964
- "capture.screencast"
1131
+ "capture.screencast",
1132
+ "input.raw",
1133
+ "input.html5Drag",
1134
+ "browser.downloads",
1135
+ "browser.permissions"
965
1136
  ]);
966
1137
 
967
1138
  class CdpPageDriver {
@@ -981,6 +1152,9 @@ class CdpPageDriver {
981
1152
  this.enabledDomains.add(name);
982
1153
  await this.conn.send(`${name}.enable`);
983
1154
  }
1155
+ async dispatchInput(method, params = {}) {
1156
+ return sendInput(this.conn, "chrome", this.info.id, method, params);
1157
+ }
984
1158
  async ensureDomainSoft(name) {
985
1159
  if (this.enabledDomains.has(name))
986
1160
  return;
@@ -989,11 +1163,22 @@ class CdpPageDriver {
989
1163
  return;
990
1164
  });
991
1165
  }
1166
+ async historyEntry(direction) {
1167
+ const h = await this.conn.send("Page.getNavigationHistory");
1168
+ const entries = h.entries ?? [];
1169
+ const entry = entries[h.currentIndex + (direction === "back" ? -1 : 1)];
1170
+ if (!entry) {
1171
+ throw driverError("page-error", `navigate: no history entry to go ${direction} to (at entry ${h.currentIndex + 1} of ${entries.length})`);
1172
+ }
1173
+ return entry;
1174
+ }
992
1175
  async navigate(opts) {
993
1176
  await this.ensureDomain("Page");
994
1177
  const reload = opts.reload === true;
995
- if (!reload && !opts.url)
1178
+ const history = opts.history;
1179
+ if (!reload && !history && !opts.url)
996
1180
  throw driverError("page-error", "navigate: 'url' is required unless reload:true");
1181
+ const entry = history ? await this.historyEntry(history) : undefined;
997
1182
  const waitUntil = opts.waitUntil ?? "load";
998
1183
  const milestoneMethod = waitUntil === "load" ? "Page.loadEventFired" : "Page.domContentEventFired";
999
1184
  const milestone = this.conn.waitFor(milestoneMethod, undefined, opts.timeoutMs).then(() => waitUntil).catch(() => {
@@ -1011,6 +1196,10 @@ class CdpPageDriver {
1011
1196
  contextId = tree?.frameTree?.frame?.id ?? "";
1012
1197
  url = tree?.frameTree?.frame?.url ?? opts.url ?? "";
1013
1198
  reloaded = true;
1199
+ } else if (entry) {
1200
+ await this.conn.send("Page.navigateToHistoryEntry", { entryId: entry.id }, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {});
1201
+ contextId = "";
1202
+ url = entry.url;
1014
1203
  } else {
1015
1204
  const nav = await this.conn.send("Page.navigate", { url: opts.url }, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {});
1016
1205
  if (nav.errorText)
@@ -1019,7 +1208,20 @@ class CdpPageDriver {
1019
1208
  url = opts.url;
1020
1209
  }
1021
1210
  const settled = await Promise.race([milestone, stopped]);
1022
- return { url, contextId, ...reloaded ? { reloaded } : {}, waitedFor: settled ?? "timeout" };
1211
+ if (entry) {
1212
+ const tree = await this.conn.send("Page.getFrameTree").catch(() => {
1213
+ return;
1214
+ });
1215
+ contextId = tree?.frameTree?.frame?.id ?? contextId;
1216
+ url = tree?.frameTree?.frame?.url ?? url;
1217
+ }
1218
+ return {
1219
+ url,
1220
+ contextId,
1221
+ ...reloaded ? { reloaded } : {},
1222
+ ...history ? { traversed: history } : {},
1223
+ waitedFor: settled ?? "timeout"
1224
+ };
1023
1225
  }
1024
1226
  async waitForText(text, timeoutMs = 15000, pollMs = 250) {
1025
1227
  await this.ensureDomain("Runtime");
@@ -1099,36 +1301,96 @@ class CdpPageDriver {
1099
1301
  async hover(loc) {
1100
1302
  const { objectId } = await resolveElementLocator(this.conn, loc);
1101
1303
  const { x, y } = await centerOf(this.conn, objectId);
1102
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
1304
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
1103
1305
  return { x, y };
1104
1306
  }
1105
1307
  async click(loc, opts) {
1106
1308
  const button = opts?.button ?? "left";
1107
1309
  const clickCount = opts?.clickCount ?? 1;
1310
+ const modifiers = inputModifierBits(opts?.modifiers);
1108
1311
  const { x, y } = await this.hover(loc);
1109
- await this.conn.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount });
1110
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, clickCount });
1312
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount, modifiers });
1313
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, clickCount, modifiers });
1314
+ return { x, y };
1315
+ }
1316
+ async scroll(anchor, opts) {
1317
+ await this.ensureDomain("Runtime");
1318
+ const { x, y } = await this.resolveScrollPoint(anchor);
1319
+ await armScrollSettleWatch(this.conn);
1320
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseWheel", x, y, deltaX: opts.deltaX ?? 0, deltaY: opts.deltaY ?? 0 });
1321
+ await awaitScrollSettle(this.conn);
1111
1322
  return { x, y };
1112
1323
  }
1113
- async drag(from, to) {
1324
+ async resolveScrollPoint(anchor) {
1325
+ if (anchor === undefined) {
1326
+ await this.ensureDomain("Runtime");
1327
+ const { result, exceptionDetails } = await this.conn.send("Runtime.evaluate", { expression: "({x: window.innerWidth / 2, y: window.innerHeight / 2})", returnByValue: true });
1328
+ if (exceptionDetails || !result.value)
1329
+ throw driverError("page-error", `could not measure viewport for scroll center: ${exceptionDetails?.text ?? "no value"}`);
1330
+ return result.value;
1331
+ }
1332
+ if ("x" in anchor)
1333
+ return anchor;
1334
+ const { objectId } = await resolveElementLocator(this.conn, anchor);
1335
+ return centerOf(this.conn, objectId);
1336
+ }
1337
+ async drag(from, to, opts) {
1114
1338
  const fromPt = await this.hover(from);
1115
- const { objectId: dstId } = await resolveElementLocator(this.conn, to);
1116
- const toPt = await centerOf(this.conn, dstId);
1117
- await this.conn.send("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
1118
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: (fromPt.x + toPt.x) / 2, y: (fromPt.y + toPt.y) / 2, button: "left" });
1119
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: toPt.x, y: toPt.y, button: "left" });
1120
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
1339
+ const toPt = await this.resolveDragDestination(to, fromPt);
1340
+ const steps = Math.max(1, Math.trunc(opts?.steps ?? 2));
1341
+ if ((opts?.mode ?? "mouse") === "html5") {
1342
+ await this.html5Drag(fromPt, toPt, steps);
1343
+ return { from: fromPt, to: toPt };
1344
+ }
1345
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
1346
+ for (const pt of interpolatePoints(fromPt, toPt, steps)) {
1347
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x: pt.x, y: pt.y, button: "left" });
1348
+ }
1349
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
1121
1350
  return { from: fromPt, to: toPt };
1122
1351
  }
1352
+ async resolveDragDestination(to, fromPt) {
1353
+ if ("dx" in to)
1354
+ return { x: fromPt.x + to.dx, y: fromPt.y + to.dy };
1355
+ if ("x" in to)
1356
+ return to;
1357
+ const { objectId } = await resolveElementLocator(this.conn, to);
1358
+ return centerOf(this.conn, objectId);
1359
+ }
1360
+ async html5Drag(fromPt, toPt, steps) {
1361
+ await this.dispatchInput("Input.setInterceptDrags", { enabled: true });
1362
+ try {
1363
+ const intercepted = this.conn.waitFor("Input.dragIntercepted", undefined, HTML5_DRAG_INTERCEPT_TIMEOUT_MS).catch(() => {
1364
+ return;
1365
+ });
1366
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
1367
+ for (const pt of interpolatePoints(fromPt, toPt, steps)) {
1368
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x: pt.x, y: pt.y, button: "left" });
1369
+ }
1370
+ const event = await intercepted;
1371
+ if (!event?.data) {
1372
+ throw driverError("page-error", `drag mode:"html5": the page never started a drag (no Input.dragIntercepted within ${HTML5_DRAG_INTERCEPT_TIMEOUT_MS}ms). ` + 'The source element is probably not draggable — HTML5 drag-and-drop needs draggable="true" (or a native draggable element) ' + 'plus a dragstart handler. Use the default mode:"mouse" for widgets built on raw pointer events.');
1373
+ }
1374
+ const data = event.data;
1375
+ for (const type of ["dragEnter", "dragOver", "drop"]) {
1376
+ await this.dispatchInput("Input.dispatchDragEvent", { type, x: toPt.x, y: toPt.y, data });
1377
+ }
1378
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
1379
+ } finally {
1380
+ await this.dispatchInput("Input.setInterceptDrags", { enabled: false }).catch(() => {
1381
+ return;
1382
+ });
1383
+ }
1384
+ }
1123
1385
  async setValue(loc, value) {
1124
1386
  const { objectId } = await resolveElementLocator(this.conn, loc);
1125
- await setValueOnObject(this.conn, objectId, value);
1387
+ await setValueOnObject(this.conn, objectId, value, (method, params) => this.dispatchInput(method, params));
1126
1388
  }
1127
1389
  async typeText(loc, text) {
1128
1390
  const { objectId } = await resolveElementLocator(this.conn, loc);
1129
1391
  await focusElement(this.conn, objectId);
1130
1392
  if (text.length)
1131
- await this.conn.send("Input.insertText", { text });
1393
+ await this.dispatchInput("Input.insertText", { text });
1132
1394
  }
1133
1395
  async pressKey(press) {
1134
1396
  const spec = resolveKey(press.key);
@@ -1143,8 +1405,8 @@ class CdpPageDriver {
1143
1405
  const down = { type: "keyDown", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits };
1144
1406
  if (spec.text && !suppressText)
1145
1407
  down.text = spec.text;
1146
- await this.conn.send("Input.dispatchKeyEvent", down);
1147
- await this.conn.send("Input.dispatchKeyEvent", { type: "keyUp", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits });
1408
+ await this.dispatchInput("Input.dispatchKeyEvent", down);
1409
+ await this.dispatchInput("Input.dispatchKeyEvent", { type: "keyUp", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits });
1148
1410
  }
1149
1411
  async setFiles(loc, files) {
1150
1412
  const { objectId } = await resolveElementLocator(this.conn, loc);
@@ -1360,6 +1622,36 @@ class CdpPageDriver {
1360
1622
  this.conn.close();
1361
1623
  }
1362
1624
  }
1625
+ var beaconSessions = new BeaconSessions((conn) => conn.close());
1626
+ async function targetWsUrl(targetId) {
1627
+ const hit = (await listTargets()).find((t) => t.id === targetId);
1628
+ return hit?.webSocketDebuggerUrl;
1629
+ }
1630
+ async function readBeaconOver(conn) {
1631
+ try {
1632
+ const res = await conn.send("Runtime.evaluate", {
1633
+ expression: BEACON_READ_EXPRESSION,
1634
+ returnByValue: true
1635
+ });
1636
+ const value = res.result?.value;
1637
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
1638
+ } catch {
1639
+ return;
1640
+ }
1641
+ }
1642
+ async function probeOver(conn, timeoutMs) {
1643
+ try {
1644
+ const res = await conn.send("Runtime.evaluate", { expression: RENDERER_PROBE_EXPRESSION, returnByValue: true }, { timeoutMs });
1645
+ const value = res.result?.value;
1646
+ if (Array.isArray(value) && value[0] === 1) {
1647
+ const ts = value[1];
1648
+ return { responsive: true, beaconTs: typeof ts === "number" && Number.isFinite(ts) ? ts : null };
1649
+ }
1650
+ return { responsive: false, beaconTs: null };
1651
+ } catch {
1652
+ return { responsive: false, beaconTs: null };
1653
+ }
1654
+ }
1363
1655
  async function withBrowserConn(fn) {
1364
1656
  const conn = await openBrowser();
1365
1657
  try {
@@ -1398,6 +1690,8 @@ class CdpBrowserDriver {
1398
1690
  async closePage(id) {
1399
1691
  return withBrowserConn(async (conn) => {
1400
1692
  const res = await conn.send("Target.closeTarget", { targetId: id });
1693
+ if (res.success !== false)
1694
+ beaconSessions.drop(id);
1401
1695
  return { success: res.success ?? true };
1402
1696
  });
1403
1697
  }
@@ -1410,7 +1704,83 @@ class CdpBrowserDriver {
1410
1704
  const { conn, target } = await openPage(selector).catch(noSuchTarget);
1411
1705
  return new CdpPageDriver(conn, target, this);
1412
1706
  }
1413
- async dispose() {}
1707
+ async installActivityBeacon(targetId) {
1708
+ const held = beaconSessions.get(targetId);
1709
+ if (held) {
1710
+ const rearmed = await held.send("Runtime.evaluate", { expression: BEACON_SOURCE, returnByValue: true }).then(() => true).catch(() => false);
1711
+ if (rearmed)
1712
+ return true;
1713
+ beaconSessions.drop(targetId);
1714
+ }
1715
+ const wsUrl = await targetWsUrl(targetId).catch(() => {
1716
+ return;
1717
+ });
1718
+ if (!wsUrl)
1719
+ return false;
1720
+ const conn = await new CdpConnection(wsUrl).connect().catch(() => {
1721
+ return;
1722
+ });
1723
+ if (!conn)
1724
+ return false;
1725
+ try {
1726
+ await conn.send("Page.enable");
1727
+ await conn.send("Page.addScriptToEvaluateOnNewDocument", { source: BEACON_SOURCE });
1728
+ await conn.send("Runtime.evaluate", { expression: BEACON_SOURCE, returnByValue: true });
1729
+ } catch {
1730
+ conn.close();
1731
+ return false;
1732
+ }
1733
+ beaconSessions.set(targetId, conn);
1734
+ return true;
1735
+ }
1736
+ async readActivityBeacon(targetId) {
1737
+ const held = beaconSessions.get(targetId);
1738
+ if (held) {
1739
+ const value = await readBeaconOver(held);
1740
+ if (value !== undefined)
1741
+ return value;
1742
+ beaconSessions.drop(targetId);
1743
+ }
1744
+ const wsUrl = await targetWsUrl(targetId).catch(() => {
1745
+ return;
1746
+ });
1747
+ if (!wsUrl)
1748
+ return null;
1749
+ const conn = await new CdpConnection(wsUrl).connect().catch(() => {
1750
+ return;
1751
+ });
1752
+ if (!conn)
1753
+ return null;
1754
+ try {
1755
+ return await readBeaconOver(conn) ?? null;
1756
+ } finally {
1757
+ conn.close();
1758
+ }
1759
+ }
1760
+ async probeRenderer(targetId, timeoutMs = RENDERER_PROBE_TIMEOUT_MS) {
1761
+ const held = beaconSessions.get(targetId);
1762
+ if (held)
1763
+ return probeOver(held, timeoutMs);
1764
+ const wsUrl = await targetWsUrl(targetId).catch(() => {
1765
+ return;
1766
+ });
1767
+ if (!wsUrl)
1768
+ return { responsive: false, beaconTs: null };
1769
+ const conn = new CdpConnection(wsUrl, { timeoutMs });
1770
+ try {
1771
+ await conn.connect();
1772
+ } catch {
1773
+ return { responsive: false, beaconTs: null };
1774
+ }
1775
+ try {
1776
+ return await probeOver(conn, timeoutMs);
1777
+ } finally {
1778
+ conn.close();
1779
+ }
1780
+ }
1781
+ async dispose() {
1782
+ beaconSessions.clear();
1783
+ }
1414
1784
  }
1415
1785
  function createCdpDriver() {
1416
1786
  return new CdpBrowserDriver;
@@ -1532,6 +1902,8 @@ function staleAgentTabs(input) {
1532
1902
  continue;
1533
1903
  if (row.stale !== "dead-pid" && row.stale !== "expired")
1534
1904
  continue;
1905
+ if (row.stale === "expired" && input.now - row.lastUsedAt <= row.ttlMs + input.reapGraceMs)
1906
+ continue;
1535
1907
  if (!live.has(row.targetId))
1536
1908
  continue;
1537
1909
  const origin = input.origins.get(row.targetId);
@@ -1541,13 +1913,19 @@ function staleAgentTabs(input) {
1541
1913
  }
1542
1914
  return out;
1543
1915
  }
1916
+ var DEFAULT_REAP_GRACE_MS = 2700000;
1917
+ function reapGraceMs() {
1918
+ const raw = Number(process.env.CDP_REAP_GRACE_MS);
1919
+ return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_REAP_GRACE_MS;
1920
+ }
1544
1921
  async function reapStaleAgentTabs(driver, backend) {
1922
+ const now = Date.now();
1545
1923
  const livePageIds = (await driver.listPages()).map((p) => p.id);
1546
1924
  const [origins, leases] = await Promise.all([
1547
1925
  originIndex(backend, undefined),
1548
- listLeases({ liveIds: livePageIds, liveBackend: backend }).catch(() => [])
1926
+ listLeases({ now, liveIds: livePageIds, liveBackend: backend }).catch(() => [])
1549
1927
  ]);
1550
- const candidates = staleAgentTabs({ backend, livePageIds, origins, leases });
1928
+ const candidates = staleAgentTabs({ backend, livePageIds, origins, leases, now, reapGraceMs: reapGraceMs() });
1551
1929
  const closed = [];
1552
1930
  for (const c of candidates) {
1553
1931
  const res = await driver.closePage(c.targetId).catch(() => ({ success: false }));
@@ -1639,18 +2017,29 @@ async function reapSet(driver, pages, all) {
1639
2017
  }
1640
2018
  }
1641
2019
  async function listPages(driver, args = {}) {
1642
- const reaped = requireLease() ? await reapStaleAgentTabs(driver, backendOf(driver)) : [];
2020
+ const backend = backendOf(driver);
2021
+ const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
1643
2022
  const reapedIds = new Set(reaped.map((r) => r.targetId));
1644
2023
  const pages = (await driver.listPages({ all: args.all })).filter((p) => !reapedIds.has(p.id));
1645
- const ledger = await originIndex(backendOf(driver), await reapSet(driver, pages, args.all));
1646
- const annotated = pages.map((p) => {
2024
+ const ledger = await originIndex(backend, await reapSet(driver, pages, args.all));
2025
+ const now = Date.now();
2026
+ const leaseByTarget = new Map((await listLeases({ now, liveIds: pages.map((p) => p.id), liveBackend: backend })).filter((l) => l.backend === backend && l.unreadable === undefined).map((l) => [l.targetId, l]));
2027
+ const doProbe = args.probe === true && rendererProbeSupported(driver);
2028
+ const annotated = await Promise.all(pages.map(async (p) => {
1647
2029
  const rec = ledger.get(p.id);
1648
- if (!rec)
1649
- return { ...p, origin: "unknown" };
1650
- if (rec.unreadable !== undefined)
1651
- return { ...p, origin: "unknown", originUnreadable: rec.unreadable };
1652
- return { ...p, origin: "agent", label: rec.label, createdAt: rec.createdAt };
1653
- });
2030
+ const base = !rec ? { ...p, origin: "unknown" } : rec.unreadable !== undefined ? { ...p, origin: "unknown", originUnreadable: rec.unreadable } : { ...p, origin: "agent", label: rec.label, createdAt: rec.createdAt };
2031
+ const leaseRow = leaseByTarget.get(p.id);
2032
+ if (leaseRow) {
2033
+ base.lease = { label: leaseRow.label, pid: leaseRow.pid, idleMs: now - leaseRow.lastUsedAt, expiresAt: leaseRow.lastUsedAt + leaseRow.ttlMs, stale: leaseRow.stale };
2034
+ }
2035
+ if (doProbe && (p.type === undefined || p.type === "page")) {
2036
+ const probe = await probeRendererActivity(driver, backend, p.id, now);
2037
+ base.responsive = probe.responsive;
2038
+ if (probe.humanActiveMs !== undefined)
2039
+ base.humanActiveMs = probe.humanActiveMs;
2040
+ }
2041
+ return base;
2042
+ }));
1654
2043
  return { pages: annotated, count: annotated.length, ...reaped.length ? { reaped } : {} };
1655
2044
  }
1656
2045
  async function newPage(driver, args = {}) {
@@ -1664,6 +2053,7 @@ async function newPage(driver, args = {}) {
1664
2053
  ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
1665
2054
  auto: !explicit
1666
2055
  });
2056
+ await installBeacon(driver, p.id);
1667
2057
  return { targetId: p.id, url: p.url, lease: token, label: record.label, expiresAt: record.lastUsedAt + record.ttlMs };
1668
2058
  }
1669
2059
  async function closePage(driver, args) {
@@ -1695,17 +2085,36 @@ function toLegacyWaitedFor(w) {
1695
2085
  return "navigate-only";
1696
2086
  return w;
1697
2087
  }
1698
- async function navigatePage(driver, args) {
2088
+ function resolveNavigateMode(args) {
1699
2089
  const reload = args.reload === true;
1700
- if (!reload && (!args.url || typeof args.url !== "string")) {
1701
- throw new SharedToolError("navigate_page: 'url' is required (or pass reload:true to reload the current page)");
2090
+ const hasUrl = typeof args.url === "string" && args.url.length > 0;
2091
+ const hasHistory = args.history !== undefined && args.history !== null;
2092
+ const given = [hasUrl ? "url" : "", reload ? "reload" : "", hasHistory ? "history" : ""].filter(Boolean);
2093
+ if (given.length > 1) {
2094
+ throw new SharedToolError(`navigate_page: 'url', 'reload' and 'history' are mutually exclusive (got ${given.join(" + ")})`);
2095
+ }
2096
+ if (hasHistory) {
2097
+ if (args.history !== "back" && args.history !== "forward") {
2098
+ throw new SharedToolError(`navigate_page: 'history' must be 'back' or 'forward' (got ${JSON.stringify(args.history)})`);
2099
+ }
2100
+ return { mode: "history", history: args.history };
2101
+ }
2102
+ if (reload)
2103
+ return { mode: "reload" };
2104
+ if (!hasUrl) {
2105
+ throw new SharedToolError("navigate_page: 'url' is required (or pass reload:true to reload the current page, or history:'back'|'forward' to go back/forward in this tab's session history)");
1702
2106
  }
2107
+ return { mode: "url", url: args.url };
2108
+ }
2109
+ async function navigatePage(driver, args) {
2110
+ const mode = resolveNavigateMode(args);
1703
2111
  return withPage2(driver, args.target, async (page) => {
1704
- const r = await page.navigate(args);
2112
+ const r = await page.navigate(mode.mode === "history" ? { ...args, history: mode.history } : args);
1705
2113
  return {
1706
2114
  url: r.url,
1707
2115
  frameId: r.contextId,
1708
2116
  ...r.reloaded ? { reloaded: r.reloaded } : {},
2117
+ ...r.traversed ? { traversed: r.traversed } : {},
1709
2118
  waitedFor: toLegacyWaitedFor(r.waitedFor)
1710
2119
  };
1711
2120
  });
@@ -1851,22 +2260,113 @@ async function takeSnapshot(driver, args = {}) {
1851
2260
  }
1852
2261
  async function click(driver, args) {
1853
2262
  return withPage2(driver, args.target, async (page) => {
1854
- const { x, y } = await page.click(locatorOf(args), { button: args.button ?? "left", clickCount: args.clickCount ?? 1 });
2263
+ const { x, y } = await page.click(locatorOf(args), { button: args.button ?? "left", clickCount: args.clickCount ?? 1, modifiers: args.modifiers });
1855
2264
  return { clicked: true, x, y };
1856
2265
  });
1857
2266
  }
2267
+ function resolveScrollAnchor(args) {
2268
+ const hasUid = args.uid !== undefined && args.uid !== null && args.uid !== "";
2269
+ const hasSelector = typeof args.selector === "string" && args.selector.length > 0;
2270
+ const hasX = args.x !== undefined;
2271
+ const hasY = args.y !== undefined;
2272
+ if (hasX !== hasY)
2273
+ throw new SharedToolError("scroll: 'x' and 'y' must be provided together");
2274
+ const hasXY = hasX && hasY;
2275
+ const anchorCount = [hasUid, hasSelector, hasXY].filter(Boolean).length;
2276
+ if (anchorCount > 1)
2277
+ throw new SharedToolError("scroll: provide at most one of { uid }, { selector }, or { x, y } (omit all three to scroll at the viewport center)");
2278
+ if (hasUid || hasSelector)
2279
+ return locatorOf({ uid: args.uid, selector: args.selector });
2280
+ if (hasXY)
2281
+ return { x: args.x, y: args.y };
2282
+ return;
2283
+ }
2284
+ function resolveScrollDelta(args) {
2285
+ const deltaX = args.deltaX ?? 0;
2286
+ const deltaY = args.deltaY ?? 0;
2287
+ if (deltaX === 0 && deltaY === 0)
2288
+ throw new SharedToolError("scroll requires at least one of { deltaX } or { deltaY }");
2289
+ return { deltaX, deltaY };
2290
+ }
2291
+ async function scroll(driver, args) {
2292
+ const anchor = resolveScrollAnchor(args);
2293
+ const { deltaX, deltaY } = resolveScrollDelta(args);
2294
+ return withPage2(driver, args.target, async (page) => {
2295
+ const { x, y } = await page.scroll(anchor, { deltaX, deltaY });
2296
+ return { x, y, deltaX, deltaY, target: target3(page.info) };
2297
+ });
2298
+ }
1858
2299
  async function hover(driver, args) {
1859
2300
  return withPage2(driver, args.target, async (page) => {
1860
2301
  const { x, y } = await page.hover(locatorOf(args));
1861
2302
  return { hovered: true, x, y };
1862
2303
  });
1863
2304
  }
2305
+ function resolveDragTo(to) {
2306
+ const hasUid = to.uid !== undefined && to.uid !== null && to.uid !== "";
2307
+ const hasSelector = typeof to.selector === "string" && to.selector.length > 0;
2308
+ const hasX = to.x !== undefined;
2309
+ const hasY = to.y !== undefined;
2310
+ if (hasX !== hasY)
2311
+ throw new SharedToolError("drag: 'to.x' and 'to.y' must be provided together");
2312
+ const hasXY = hasX && hasY;
2313
+ const count = [hasUid, hasSelector, hasXY].filter(Boolean).length;
2314
+ if (count !== 1)
2315
+ throw new SharedToolError("drag: 'to' takes exactly one of { uid }, { selector }, or { x, y }");
2316
+ if (hasXY) {
2317
+ if (!Number.isFinite(to.x) || !Number.isFinite(to.y))
2318
+ throw new SharedToolError("drag: 'to.x' and 'to.y' must be finite numbers");
2319
+ return { x: to.x, y: to.y };
2320
+ }
2321
+ return locatorOf({ uid: to.uid, selector: to.selector });
2322
+ }
2323
+ function resolveDragDestination(args) {
2324
+ const hasTo = args.to !== undefined && args.to !== null;
2325
+ const hasBy = args.by !== undefined && args.by !== null;
2326
+ if (hasTo === hasBy)
2327
+ throw new SharedToolError("drag requires exactly one of { to } or { by }");
2328
+ if (hasBy) {
2329
+ const by = args.by;
2330
+ const hasDx = by.dx !== undefined;
2331
+ const hasDy = by.dy !== undefined;
2332
+ if (!hasDx && !hasDy)
2333
+ throw new SharedToolError("drag: 'by' requires at least one of { dx } or { dy }");
2334
+ const dx = by.dx ?? 0;
2335
+ const dy = by.dy ?? 0;
2336
+ if (!Number.isFinite(dx) || !Number.isFinite(dy))
2337
+ throw new SharedToolError("drag: 'by.dx' and 'by.dy' must be finite numbers");
2338
+ return { dx, dy };
2339
+ }
2340
+ return resolveDragTo(args.to);
2341
+ }
2342
+ var MAX_DRAG_STEPS = 500;
2343
+ function resolveDragSteps(steps) {
2344
+ if (steps === undefined)
2345
+ return 2;
2346
+ if (typeof steps !== "number" || !Number.isInteger(steps) || steps < 1 || steps > MAX_DRAG_STEPS) {
2347
+ throw new SharedToolError(`drag: 'steps' must be an integer between 1 and ${MAX_DRAG_STEPS} (default 2)`);
2348
+ }
2349
+ return steps;
2350
+ }
2351
+ function resolveDragMode(mode, capabilities) {
2352
+ if (mode === undefined || mode === "mouse")
2353
+ return "mouse";
2354
+ if (mode !== "html5")
2355
+ throw new SharedToolError(`drag: unknown mode '${mode}' (use 'mouse' or 'html5')`);
2356
+ if (!capabilities.has("input.html5Drag")) {
2357
+ throw new SharedToolError("drag: mode:'html5' is not supported by this backend (WebDriver BiDi has no drag-interception primitive). " + "Omit 'mode' for the synthetic-mouse drag, or use --browser chrome for real HTML5 drag events.");
2358
+ }
2359
+ return "html5";
2360
+ }
1864
2361
  async function drag(driver, args) {
1865
- if (!args.from || !args.to)
1866
- throw new SharedToolError("drag requires { from } and { to }");
2362
+ if (!args.from)
2363
+ throw new SharedToolError("drag requires { from }");
2364
+ const destination = resolveDragDestination(args);
2365
+ const steps = resolveDragSteps(args.steps);
2366
+ const mode = resolveDragMode(args.mode, driver.capabilities);
1867
2367
  return withPage2(driver, args.target, async (page) => {
1868
- const { from, to } = await page.drag(locatorOf(args.from), locatorOf(args.to));
1869
- return { dragged: true, from, to };
2368
+ const { from, to } = await page.drag(locatorOf(args.from), destination, { mode, steps });
2369
+ return { dragged: true, mode, steps, from, to };
1870
2370
  });
1871
2371
  }
1872
2372
  async function fill(driver, args) {
@@ -1987,6 +2487,7 @@ var SHARED_TOOLS = {
1987
2487
  click,
1988
2488
  hover,
1989
2489
  drag,
2490
+ scroll,
1990
2491
  fill,
1991
2492
  fill_form: fillForm,
1992
2493
  type_text: typeText,
@@ -2036,12 +2537,15 @@ async function claimPage(driver, args = {}) {
2036
2537
  url = page.url;
2037
2538
  opened = true;
2038
2539
  }
2540
+ const humanMs = opened ? null : await readHumanActiveMs(driver, backend, targetId);
2039
2541
  const liveIds = [...pages.map((p) => p.id), targetId];
2040
2542
  const { record, token } = await claimLease(backend, targetId, {
2041
2543
  label,
2042
2544
  ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
2043
2545
  liveIds
2044
2546
  });
2547
+ await installBeacon(driver, targetId);
2548
+ const contention = contentionWarning(humanMs, { takeover: !opened });
2045
2549
  return {
2046
2550
  lease: token,
2047
2551
  targetId,
@@ -2049,7 +2553,9 @@ async function claimPage(driver, args = {}) {
2049
2553
  label: record.label,
2050
2554
  ttlMs: record.ttlMs,
2051
2555
  expiresAt: record.lastUsedAt + record.ttlMs,
2052
- opened
2556
+ opened,
2557
+ ...beaconSupported(driver) ? { humanActiveMs: humanMs } : {},
2558
+ ...contention !== undefined ? { contention } : {}
2053
2559
  };
2054
2560
  }
2055
2561
  async function releasePage(driver, args = {}) {
@@ -2084,9 +2590,22 @@ async function listLeasesTool(driver, _args = {}) {
2084
2590
  const backend = backendOf2(driver);
2085
2591
  const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
2086
2592
  const reapedIds = new Set(reaped.map((r) => r.targetId));
2087
- const liveIds = (await driver.listPages()).map((p) => p.id);
2593
+ const livePages = await driver.listPages();
2594
+ const liveIds = livePages.map((p) => p.id);
2088
2595
  const all = await listLeases({ liveIds, liveBackend: backend });
2089
- const leases = all.filter((l) => !(l.backend === backend && reapedIds.has(l.targetId)));
2596
+ const kept = all.filter((l) => !(l.backend === backend && reapedIds.has(l.targetId)));
2597
+ const liveSet = new Set(liveIds);
2598
+ const now = Date.now();
2599
+ const leases = [];
2600
+ for (const row of kept) {
2601
+ const computed = row.unreadable !== undefined ? row : { ...row, idleMs: now - row.lastUsedAt, expiresAt: row.lastUsedAt + row.ttlMs };
2602
+ if (row.backend !== backend || row.unreadable !== undefined || !liveSet.has(row.targetId)) {
2603
+ leases.push(computed);
2604
+ continue;
2605
+ }
2606
+ const humanMs = await readHumanActiveMs(driver, backend, row.targetId, now).catch(() => null);
2607
+ leases.push(humanMs === null ? computed : { ...computed, humanActiveMs: humanMs });
2608
+ }
2090
2609
  return { leases, count: leases.length, ...reaped.length ? { reaped } : {} };
2091
2610
  }
2092
2611
  var LEASE_TOOLS = {
@@ -3242,6 +3761,273 @@ function truncate(s, max = 600) {
3242
3761
  return t.length > max ? `${t.slice(0, max)}…` : t;
3243
3762
  }
3244
3763
 
3764
+ // src/tools/dispatch-mouse.ts
3765
+ var CLICK_MODIFIER_BITS2 = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
3766
+ var BUTTONS = new Set(["left", "right", "middle"]);
3767
+ var CDP_EVENT_TYPE = {
3768
+ move: "mouseMoved",
3769
+ down: "mousePressed",
3770
+ up: "mouseReleased"
3771
+ };
3772
+ function modifierBits(mods) {
3773
+ let bits = 0;
3774
+ for (const m of mods ?? []) {
3775
+ const bit = CLICK_MODIFIER_BITS2[m];
3776
+ if (bit === undefined)
3777
+ throw new Error(`dispatch_mouse: unknown modifier '${m}' (use Alt, Control, Meta, or Shift)`);
3778
+ bits |= bit;
3779
+ }
3780
+ return bits;
3781
+ }
3782
+ function validateDispatchMouseArgs(args) {
3783
+ if (args.action !== "move" && args.action !== "down" && args.action !== "up") {
3784
+ throw new Error("dispatch_mouse requires { action } of 'move', 'down', or 'up'");
3785
+ }
3786
+ if (typeof args.x !== "number" || typeof args.y !== "number" || !Number.isFinite(args.x) || !Number.isFinite(args.y)) {
3787
+ throw new Error("dispatch_mouse requires numeric { x } and { y } on every call");
3788
+ }
3789
+ const button = args.button ?? "left";
3790
+ if (!BUTTONS.has(button))
3791
+ throw new Error(`dispatch_mouse: unknown button '${button}' (use left, right, or middle)`);
3792
+ }
3793
+ async function dispatchMouse(args) {
3794
+ validateDispatchMouseArgs(args);
3795
+ const button = args.button ?? "left";
3796
+ const modifiers = modifierBits(args.modifiers);
3797
+ return withPage(args.target, async (conn, target) => {
3798
+ const params = { type: CDP_EVENT_TYPE[args.action], x: args.x, y: args.y, button, modifiers };
3799
+ if (args.action !== "move")
3800
+ params.clickCount = args.clickCount ?? 1;
3801
+ await sendInput(conn, "chrome", target.id, "Input.dispatchMouseEvent", params);
3802
+ return { dispatched: args.action, x: args.x, y: args.y };
3803
+ });
3804
+ }
3805
+
3806
+ // src/tools/downloads.ts
3807
+ import { readdir as readdir3, rename, stat as stat4 } from "node:fs/promises";
3808
+ import { basename, extname, join as join8 } from "node:path";
3809
+
3810
+ // src/tools/browser-session.ts
3811
+ import { mkdir as mkdir9 } from "node:fs/promises";
3812
+ import { join as join7 } from "node:path";
3813
+ var ARTIFACT_DIR7 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
3814
+ var DOWNLOADS_DIR = join7(ARTIFACT_DIR7, "downloads");
3815
+ var DOWNLOAD_BUFFER_LIMIT = 50;
3816
+ var session;
3817
+ function isDisconnect(e) {
3818
+ const m = e instanceof Error ? e.message : String(e);
3819
+ return /connection (not open|closed)|closed by caller|websocket|ECONNREFUSED|socket/i.test(m);
3820
+ }
3821
+ function record(s, guid) {
3822
+ let r = s.downloads.find((d) => d.guid === guid);
3823
+ if (!r) {
3824
+ r = { guid, state: "inProgress", consumed: false };
3825
+ s.downloads.push(r);
3826
+ while (s.downloads.length > DOWNLOAD_BUFFER_LIMIT) {
3827
+ const i = s.downloads.findIndex((d) => d.consumed);
3828
+ s.downloads.splice(i >= 0 ? i : 0, 1);
3829
+ }
3830
+ }
3831
+ return r;
3832
+ }
3833
+ function subscribe(s) {
3834
+ s.conn.on("Browser.downloadWillBegin", (p) => {
3835
+ const r = record(s, String(p.guid));
3836
+ if (typeof p.url === "string")
3837
+ r.url = p.url;
3838
+ if (typeof p.suggestedFilename === "string")
3839
+ r.suggestedFilename = p.suggestedFilename;
3840
+ });
3841
+ s.conn.on("Browser.downloadProgress", (p) => {
3842
+ const r = record(s, String(p.guid));
3843
+ if (typeof p.receivedBytes === "number")
3844
+ r.bytes = p.receivedBytes;
3845
+ if (typeof p.filePath === "string")
3846
+ r.filePath = p.filePath;
3847
+ const state = String(p.state);
3848
+ if (state === "completed" || state === "canceled") {
3849
+ r.state = state;
3850
+ if (state === "completed") {
3851
+ const waiter = s.waiters.shift();
3852
+ if (waiter) {
3853
+ r.consumed = true;
3854
+ waiter(r);
3855
+ }
3856
+ }
3857
+ }
3858
+ });
3859
+ }
3860
+ async function getBrowserSession() {
3861
+ if (session && !session.dead)
3862
+ return session;
3863
+ const previousPath = session?.armedPath;
3864
+ const conn = await openBrowser();
3865
+ const s = { conn, downloads: [], waiters: [], dead: false };
3866
+ subscribe(s);
3867
+ session = s;
3868
+ if (previousPath)
3869
+ await armDownloadsAt(previousPath);
3870
+ return s;
3871
+ }
3872
+ async function browserSend(method, params = {}) {
3873
+ const s = await getBrowserSession();
3874
+ try {
3875
+ return await s.conn.send(method, params);
3876
+ } catch (e) {
3877
+ if (isDisconnect(e)) {
3878
+ s.dead = true;
3879
+ const fresh = await getBrowserSession();
3880
+ return await fresh.conn.send(method, params);
3881
+ }
3882
+ throw e;
3883
+ }
3884
+ }
3885
+ async function armDownloadsAt(path) {
3886
+ await mkdir9(path, { recursive: true });
3887
+ const s = await getBrowserSession();
3888
+ await s.conn.send("Browser.setDownloadBehavior", { behavior: "allowAndName", downloadPath: path, eventsEnabled: true });
3889
+ s.armedPath = path;
3890
+ }
3891
+ async function armDownloads() {
3892
+ const s = await getBrowserSession();
3893
+ if (s.armedPath !== DOWNLOADS_DIR)
3894
+ await armDownloadsAt(DOWNLOADS_DIR);
3895
+ return { downloadPath: DOWNLOADS_DIR };
3896
+ }
3897
+ function pendingDownloads() {
3898
+ return (session?.downloads ?? []).filter((d) => d.state === "completed" && !d.consumed);
3899
+ }
3900
+ async function nextCompletedDownload(timeoutMs) {
3901
+ const s = await getBrowserSession();
3902
+ const buffered = s.downloads.find((d) => d.state === "completed" && !d.consumed);
3903
+ if (buffered) {
3904
+ buffered.consumed = true;
3905
+ return buffered;
3906
+ }
3907
+ return new Promise((resolve, reject) => {
3908
+ const waiter = (r) => {
3909
+ clearTimeout(timer);
3910
+ resolve(r);
3911
+ };
3912
+ const timer = setTimeout(() => {
3913
+ const i = s.waiters.indexOf(waiter);
3914
+ if (i >= 0)
3915
+ s.waiters.splice(i, 1);
3916
+ reject(new Error(`wait_for_download: no download completed within ${timeoutMs}ms. Downloads are only captured while this server holds the arming connection, so the triggering click must come AFTER a wait_for_download call (use arm:true to arm without waiting).`));
3917
+ }, timeoutMs);
3918
+ s.waiters.push(waiter);
3919
+ });
3920
+ }
3921
+ async function disposeBrowserSession() {
3922
+ const s = session;
3923
+ session = undefined;
3924
+ if (!s)
3925
+ return;
3926
+ s.dead = true;
3927
+ try {
3928
+ s.conn.close();
3929
+ } catch {}
3930
+ }
3931
+
3932
+ // src/tools/downloads.ts
3933
+ function safeFilename(suggested) {
3934
+ const raw = (suggested ?? "").replace(/[\u0000-\u001f]/g, "");
3935
+ const name = basename(raw.split("\\").pop() ?? "").trim();
3936
+ if (!name || name === "." || name === "..")
3937
+ return "download";
3938
+ return name;
3939
+ }
3940
+ function nextFreeName(name, taken) {
3941
+ if (!taken.has(name))
3942
+ return name;
3943
+ const ext = extname(name);
3944
+ const stem = ext ? name.slice(0, -ext.length) : name;
3945
+ for (let i = 1;; i++) {
3946
+ const candidate = `${stem}-${i}${ext}`;
3947
+ if (!taken.has(candidate))
3948
+ return candidate;
3949
+ }
3950
+ }
3951
+ function validateWaitForDownloadArgs(args) {
3952
+ if (args.timeoutMs !== undefined) {
3953
+ if (typeof args.timeoutMs !== "number" || !Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0) {
3954
+ throw new Error(`wait_for_download: 'timeoutMs' must be a positive number (got ${JSON.stringify(args.timeoutMs)})`);
3955
+ }
3956
+ }
3957
+ if (args.arm !== undefined && typeof args.arm !== "boolean") {
3958
+ throw new Error(`wait_for_download: 'arm' must be a boolean (got ${JSON.stringify(args.arm)})`);
3959
+ }
3960
+ }
3961
+ async function waitForDownload(args = {}) {
3962
+ validateWaitForDownloadArgs(args);
3963
+ const t = await resolveTarget(args.target);
3964
+ const target = { id: t.id, url: t.url, title: t.title };
3965
+ const { downloadPath } = await armDownloads();
3966
+ if (args.arm === true)
3967
+ return { armed: true, downloadPath, pending: pendingDownloads().length, target };
3968
+ const rec = await nextCompletedDownload(args.timeoutMs ?? 30000);
3969
+ if (!rec.filePath) {
3970
+ throw new Error(`wait_for_download: download ${rec.guid} completed without a file path (nothing to return)`);
3971
+ }
3972
+ const suggestedFilename = safeFilename(rec.suggestedFilename);
3973
+ const taken = new Set(await readdir3(DOWNLOADS_DIR).catch(() => []));
3974
+ taken.delete(basename(rec.filePath));
3975
+ const finalName = nextFreeName(suggestedFilename, taken);
3976
+ const path = join8(DOWNLOADS_DIR, finalName);
3977
+ await rename(rec.filePath, path);
3978
+ const bytes = (await stat4(path)).size;
3979
+ return { path, suggestedFilename, bytes, ...rec.url ? { url: rec.url } : {}, target };
3980
+ }
3981
+
3982
+ // src/tools/permissions.ts
3983
+ function originOf(url) {
3984
+ try {
3985
+ const o = new URL(url).origin;
3986
+ return o && o !== "null" ? o : undefined;
3987
+ } catch {
3988
+ return;
3989
+ }
3990
+ }
3991
+ function validateGrantPermissionsArgs(args) {
3992
+ const reset = args.reset === true;
3993
+ if (args.reset !== undefined && typeof args.reset !== "boolean") {
3994
+ throw new Error(`grant_permissions: 'reset' must be a boolean (got ${JSON.stringify(args.reset)})`);
3995
+ }
3996
+ if (args.permissions === undefined) {
3997
+ if (!reset)
3998
+ throw new Error("grant_permissions: 'permissions' is required (or pass reset:true to clear previous grants)");
3999
+ return;
4000
+ }
4001
+ if (!Array.isArray(args.permissions)) {
4002
+ throw new Error(`grant_permissions: 'permissions' must be an array of CDP PermissionType strings (got ${JSON.stringify(args.permissions)})`);
4003
+ }
4004
+ if (args.permissions.length === 0 && !reset) {
4005
+ throw new Error("grant_permissions: 'permissions' must not be empty (pass reset:true if the intent is to clear grants)");
4006
+ }
4007
+ for (const p of args.permissions) {
4008
+ if (typeof p !== "string" || p.length === 0) {
4009
+ throw new Error(`grant_permissions: every entry of 'permissions' must be a non-empty string (got ${JSON.stringify(p)})`);
4010
+ }
4011
+ }
4012
+ }
4013
+ async function grantPermissions(args = {}) {
4014
+ validateGrantPermissionsArgs(args);
4015
+ const t = await resolveTarget(args.target);
4016
+ const target = { id: t.id, url: t.url, title: t.title };
4017
+ const reset = args.reset === true;
4018
+ const permissions = args.permissions ?? [];
4019
+ if (reset)
4020
+ await browserSend("Browser.resetPermissions", {});
4021
+ if (permissions.length === 0)
4022
+ return { reset: true, target };
4023
+ const origin = args.origin ?? originOf(t.url);
4024
+ if (!origin) {
4025
+ throw new Error(`grant_permissions: cannot derive an origin from the target tab's url (${JSON.stringify(t.url)}); pass 'origin' explicitly, e.g. origin:"https://example.com"`);
4026
+ }
4027
+ await browserSend("Browser.grantPermissions", { origin, permissions });
4028
+ return { granted: [...permissions], origin, ...reset ? { reset: true } : {}, target };
4029
+ }
4030
+
3245
4031
  // src/index.ts
3246
4032
  var cdpDriver = createCdpDriver();
3247
4033
  function onCdp(fn) {
@@ -3262,6 +4048,7 @@ var TOOLS = {
3262
4048
  click: onCdp(SHARED_TOOLS.click),
3263
4049
  hover: onCdp(SHARED_TOOLS.hover),
3264
4050
  drag: onCdp(SHARED_TOOLS.drag),
4051
+ scroll: onCdp(SHARED_TOOLS.scroll),
3265
4052
  fill: onCdp(SHARED_TOOLS.fill),
3266
4053
  fill_form: onCdp(SHARED_TOOLS.fill_form),
3267
4054
  type_text: onCdp(SHARED_TOOLS.type_text),
@@ -3286,6 +4073,9 @@ var TOOLS = {
3286
4073
  mock_request: mockRequest,
3287
4074
  list_mocks: listMocks,
3288
4075
  clear_mocks: clearMocks,
4076
+ dispatch_mouse: dispatchMouse,
4077
+ wait_for_download: waitForDownload,
4078
+ grant_permissions: grantPermissions,
3289
4079
  claim_page: onCdp(LEASE_TOOLS.claim_page),
3290
4080
  release_page: onCdp(LEASE_TOOLS.release_page),
3291
4081
  list_leases: onCdp(LEASE_TOOLS.list_leases)