cdp-toolkit 1.6.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/cli.js CHANGED
@@ -13,6 +13,16 @@ function leaseTtlMs() {
13
13
  const raw = Number(process.env.CDP_LEASE_TTL_MS);
14
14
  return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_LEASE_TTL_MS;
15
15
  }
16
+ var longLived = false;
17
+ function markLongLivedProcess(value = true) {
18
+ longLived = value;
19
+ }
20
+ function requireLease() {
21
+ if (!longLived)
22
+ return false;
23
+ const raw = (process.env.CDP_REQUIRE_LEASE ?? "").trim().toLowerCase();
24
+ return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
25
+ }
16
26
  function defaultLabel() {
17
27
  return `pid-${process.pid}`;
18
28
  }
@@ -99,7 +109,8 @@ async function claimLease(backend, targetId, opts) {
99
109
  label: opts.label,
100
110
  createdAt: now,
101
111
  lastUsedAt: now,
102
- ttlMs: opts.ttlMs ?? leaseTtlMs()
112
+ ttlMs: opts.ttlMs ?? leaseTtlMs(),
113
+ auto: opts.auto === true
103
114
  };
104
115
  try {
105
116
  await writeFile(leaseFile(backend, targetId), JSON.stringify(record), { flag: "wx" });
@@ -175,8 +186,16 @@ async function assertLeaseOk(backend, targetId, ctx = {}) {
175
186
  const held = rec && !staleReason(rec, { now, liveIds: ctx.liveIds }) ? rec : undefined;
176
187
  const where = describeTarget(targetId, ctx);
177
188
  if (token === undefined) {
178
- if (!held)
189
+ if (!held) {
190
+ if (requireLease()) {
191
+ await claimLease(backend, targetId, { label: defaultLabel(), ttlMs: leaseTtlMs(), auto: true, now });
192
+ }
193
+ return;
194
+ }
195
+ if (held.auto === true && held.pid === process.pid) {
196
+ await touchLease(held, now);
179
197
  return;
198
+ }
180
199
  throw new LeaseConflictError(`${where} is leased by '${held.label}' (pid ${held.pid}). Pass that lease's token as the 'lease' argument, or call release_page to free it. list_leases shows every active lease.`, targetId, held.label);
181
200
  }
182
201
  const parts = tokenParts(token);
@@ -184,8 +203,12 @@ async function assertLeaseOk(backend, targetId, ctx = {}) {
184
203
  throw new LeaseConflictError(`malformed lease token for ${where}. Pass back the token claim_page returned, never a constructed one.`, targetId);
185
204
  }
186
205
  if (parts.backend !== backend || parts.targetId !== targetId) {
187
- if (!held)
206
+ if (!held) {
207
+ if (requireLease()) {
208
+ await claimLease(backend, targetId, { label: defaultLabel(), ttlMs: leaseTtlMs(), auto: true, now });
209
+ }
188
210
  return;
211
+ }
189
212
  throw new LeaseConflictError(`the lease token you passed is for ${parts.backend} target '${parts.targetId}', not ${where}, which is leased by '${held.label}'. Resolve the tab you actually hold, or claim this one.`, targetId, held.label);
190
213
  }
191
214
  if (!held) {
@@ -463,6 +486,141 @@ async function withPage(selector, fn, opts = {}) {
463
486
  }
464
487
  }
465
488
 
489
+ // src/activity.ts
490
+ var BEACON_DATA_GLOBAL = "__cdpToolkitLastInput";
491
+ var BEACON_INSTALLED_GLOBAL = "__cdpToolkitBeacon";
492
+ var BEACON_EVENTS = ["pointerdown", "mousedown", "keydown", "wheel", "touchstart"];
493
+ var BEACON_BODY = `
494
+ if (!window.${BEACON_INSTALLED_GLOBAL}) {
495
+ window.${BEACON_INSTALLED_GLOBAL} = true;
496
+ var mark = function () { window.${BEACON_DATA_GLOBAL} = Date.now(); };
497
+ var events = ${JSON.stringify([...BEACON_EVENTS])};
498
+ for (var i = 0; i < events.length; i++) {
499
+ window.addEventListener(events[i], mark, { capture: true, passive: true });
500
+ }
501
+ }`;
502
+ var BEACON_SOURCE = `(function () {${BEACON_BODY}
503
+ })();`;
504
+ var BEACON_FUNCTION_DECLARATION = `function () {${BEACON_BODY}
505
+ }`;
506
+ var BEACON_READ_EXPRESSION = `(typeof window.${BEACON_DATA_GLOBAL} === "number" ? window.${BEACON_DATA_GLOBAL} : null)`;
507
+ var DISPATCH_ATTRIBUTION_WINDOW_MS = 1500;
508
+ function dispatchKey(backend, targetId) {
509
+ return `${backend}:${targetId}`;
510
+ }
511
+ var dispatchLog = new Map;
512
+ var DISPATCH_LOG_LIMIT = 512;
513
+ function recordDispatch(backend, targetId, now = Date.now()) {
514
+ const key = dispatchKey(backend, targetId);
515
+ dispatchLog.delete(key);
516
+ dispatchLog.set(key, now);
517
+ while (dispatchLog.size > DISPATCH_LOG_LIMIT) {
518
+ const oldest = dispatchLog.keys().next();
519
+ if (oldest.done)
520
+ break;
521
+ dispatchLog.delete(oldest.value);
522
+ }
523
+ }
524
+ var NON_SYNTHESIZING_INPUT_METHODS = new Set(["Input.setInterceptDrags", "Input.setIgnoreInputEvents"]);
525
+ async function sendInput(conn, backend, targetId, method, params = {}) {
526
+ if (!NON_SYNTHESIZING_INPUT_METHODS.has(method))
527
+ recordDispatch(backend, targetId);
528
+ return conn.send(method, params);
529
+ }
530
+ function lastDispatchAt(backend, targetId) {
531
+ return dispatchLog.get(dispatchKey(backend, targetId));
532
+ }
533
+ function isHumanAttributed(beaconTs, dispatchAt, windowMs = DISPATCH_ATTRIBUTION_WINDOW_MS) {
534
+ if (typeof beaconTs !== "number" || !Number.isFinite(beaconTs))
535
+ return false;
536
+ return beaconTs > (dispatchAt ?? 0) + windowMs;
537
+ }
538
+ function humanActiveMs(beaconTs, dispatchAt, now = Date.now(), windowMs = DISPATCH_ATTRIBUTION_WINDOW_MS) {
539
+ if (!isHumanAttributed(beaconTs, dispatchAt, windowMs))
540
+ return null;
541
+ return Math.max(0, now - beaconTs);
542
+ }
543
+ var CONTENTION_WINDOW_MS = 30000;
544
+ function contentionWarning(ms, opts) {
545
+ if (!opts.takeover)
546
+ return;
547
+ if (ms === null || ms >= CONTENTION_WINDOW_MS)
548
+ return;
549
+ 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.`;
550
+ }
551
+ function beaconSupported(driver) {
552
+ return typeof driver.installActivityBeacon === "function" && typeof driver.readActivityBeacon === "function";
553
+ }
554
+ async function installBeacon(driver, targetId) {
555
+ if (typeof driver.installActivityBeacon !== "function")
556
+ return false;
557
+ return driver.installActivityBeacon(targetId).catch(() => false);
558
+ }
559
+ async function readHumanActiveMs(driver, backend, targetId, now = Date.now()) {
560
+ if (typeof driver.readActivityBeacon !== "function")
561
+ return null;
562
+ const beaconTs = await driver.readActivityBeacon(targetId).catch(() => null);
563
+ return humanActiveMs(beaconTs, lastDispatchAt(backend, targetId), now);
564
+ }
565
+ var RENDERER_PROBE_TIMEOUT_MS = 500;
566
+ var RENDERER_PROBE_EXPRESSION = `[1, ${BEACON_READ_EXPRESSION}]`;
567
+ function rendererProbeSupported(driver) {
568
+ return typeof driver.probeRenderer === "function";
569
+ }
570
+ async function probeRendererActivity(driver, backend, targetId, now = Date.now()) {
571
+ if (typeof driver.probeRenderer !== "function")
572
+ return { responsive: false };
573
+ const { responsive, beaconTs } = await driver.probeRenderer(targetId, RENDERER_PROBE_TIMEOUT_MS).catch(() => ({ responsive: false, beaconTs: null }));
574
+ const ms = humanActiveMs(beaconTs, lastDispatchAt(backend, targetId), now);
575
+ return ms === null ? { responsive } : { responsive, humanActiveMs: ms };
576
+ }
577
+
578
+ class BeaconSessions {
579
+ close;
580
+ limit;
581
+ live = new Map;
582
+ constructor(close, limit = 32) {
583
+ this.close = close;
584
+ this.limit = limit;
585
+ }
586
+ get(key) {
587
+ return this.live.get(key);
588
+ }
589
+ set(key, value) {
590
+ const previous = this.live.get(key);
591
+ if (previous !== undefined) {
592
+ this.live.delete(key);
593
+ this.safeClose(previous);
594
+ }
595
+ this.live.set(key, value);
596
+ while (this.live.size > this.limit) {
597
+ const oldest = this.live.keys().next();
598
+ if (oldest.done)
599
+ break;
600
+ this.drop(oldest.value);
601
+ }
602
+ }
603
+ drop(key) {
604
+ const value = this.live.get(key);
605
+ if (value === undefined)
606
+ return;
607
+ this.live.delete(key);
608
+ this.safeClose(value);
609
+ }
610
+ clear() {
611
+ for (const key of [...this.live.keys()])
612
+ this.drop(key);
613
+ }
614
+ get size() {
615
+ return this.live.size;
616
+ }
617
+ safeClose(value) {
618
+ try {
619
+ this.close(value);
620
+ } catch {}
621
+ }
622
+ }
623
+
466
624
  // src/tools/snapshot.ts
467
625
  var INTERACTIVE_ROLES = new Set([
468
626
  "button",
@@ -686,11 +844,22 @@ var REQUIRED_CAPABILITIES = {
686
844
  stop_screen_recording: ["capture.screencast"],
687
845
  mock_request: ["network.intercept"],
688
846
  list_mocks: ["network.intercept"],
689
- clear_mocks: ["network.intercept"]
847
+ clear_mocks: ["network.intercept"],
848
+ dispatch_mouse: ["input.raw"],
849
+ wait_for_download: ["browser.downloads"],
850
+ grant_permissions: ["browser.permissions"]
690
851
  };
691
852
  function isDriverError(e) {
692
853
  return e instanceof Error && typeof e.code === "string";
693
854
  }
855
+ function interpolatePoints(from, to, steps) {
856
+ const out = [];
857
+ for (let i = 1;i < steps; i++) {
858
+ out.push({ x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps });
859
+ }
860
+ out.push({ x: to.x, y: to.y });
861
+ return out;
862
+ }
694
863
 
695
864
  // src/cdp/driver.ts
696
865
  function driverError(code, message, data) {
@@ -746,12 +915,21 @@ async function centerOf(conn, objectId) {
746
915
  async function focusElement(conn, objectId) {
747
916
  await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: "function(){this.focus&&this.focus();}", returnByValue: true });
748
917
  }
749
- async function setValueOnObject(conn, objectId, value) {
918
+ 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);});})()";
919
+ async function armScrollSettleWatch(conn) {
920
+ await conn.send("Runtime.evaluate", { expression: ARM_SCROLL_SETTLE_WATCH, returnByValue: true });
921
+ }
922
+ async function awaitScrollSettle(conn) {
923
+ await conn.send("Runtime.evaluate", { expression: "window.__cdpScrollSettle", awaitPromise: true, returnByValue: true }).catch(() => {
924
+ return;
925
+ });
926
+ }
927
+ async function setValueOnObject(conn, objectId, value, sendInput2) {
750
928
  await focusElement(conn, objectId);
751
929
  const fn = "function(){if('value'in this){this.value='';this.dispatchEvent(new Event('input',{bubbles:true}));}else if(this.isContentEditable){this.textContent='';}}";
752
930
  await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: fn, returnByValue: true });
753
931
  if (value.length)
754
- await conn.send("Input.insertText", { text: value });
932
+ await sendInput2("Input.insertText", { text: value });
755
933
  }
756
934
  var NAMED_KEYS = {
757
935
  enter: { key: "Enter", code: "Enter", keyCode: 13, text: "\r" },
@@ -776,6 +954,18 @@ var NAMED_KEYS = {
776
954
  pagedown: { key: "PageDown", code: "PageDown", keyCode: 34 }
777
955
  };
778
956
  var MODIFIER_BITS = { alt: 1, control: 2, ctrl: 2, meta: 4, cmd: 4, command: 4, shift: 8 };
957
+ var CLICK_MODIFIER_BITS = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
958
+ function inputModifierBits(mods) {
959
+ let bits = 0;
960
+ for (const m of mods ?? []) {
961
+ const bit = CLICK_MODIFIER_BITS[m];
962
+ if (bit === undefined)
963
+ throw driverError("page-error", `unknown modifier '${m}' (use Alt, Control, Meta, or Shift)`);
964
+ bits |= bit;
965
+ }
966
+ return bits;
967
+ }
968
+ var HTML5_DRAG_INTERCEPT_TIMEOUT_MS = 5000;
779
969
  function resolveKey(key) {
780
970
  const named = NAMED_KEYS[key.toLowerCase()];
781
971
  if (named)
@@ -940,7 +1130,11 @@ var CDP_CAPABILITIES = new Set([
940
1130
  "input.insertTextAtomic",
941
1131
  "locate.text",
942
1132
  "locate.xpath",
943
- "capture.screencast"
1133
+ "capture.screencast",
1134
+ "input.raw",
1135
+ "input.html5Drag",
1136
+ "browser.downloads",
1137
+ "browser.permissions"
944
1138
  ]);
945
1139
 
946
1140
  class CdpPageDriver {
@@ -960,6 +1154,9 @@ class CdpPageDriver {
960
1154
  this.enabledDomains.add(name);
961
1155
  await this.conn.send(`${name}.enable`);
962
1156
  }
1157
+ async dispatchInput(method, params = {}) {
1158
+ return sendInput(this.conn, "chrome", this.info.id, method, params);
1159
+ }
963
1160
  async ensureDomainSoft(name) {
964
1161
  if (this.enabledDomains.has(name))
965
1162
  return;
@@ -968,11 +1165,22 @@ class CdpPageDriver {
968
1165
  return;
969
1166
  });
970
1167
  }
1168
+ async historyEntry(direction) {
1169
+ const h = await this.conn.send("Page.getNavigationHistory");
1170
+ const entries = h.entries ?? [];
1171
+ const entry = entries[h.currentIndex + (direction === "back" ? -1 : 1)];
1172
+ if (!entry) {
1173
+ throw driverError("page-error", `navigate: no history entry to go ${direction} to (at entry ${h.currentIndex + 1} of ${entries.length})`);
1174
+ }
1175
+ return entry;
1176
+ }
971
1177
  async navigate(opts) {
972
1178
  await this.ensureDomain("Page");
973
1179
  const reload = opts.reload === true;
974
- if (!reload && !opts.url)
1180
+ const history = opts.history;
1181
+ if (!reload && !history && !opts.url)
975
1182
  throw driverError("page-error", "navigate: 'url' is required unless reload:true");
1183
+ const entry = history ? await this.historyEntry(history) : undefined;
976
1184
  const waitUntil = opts.waitUntil ?? "load";
977
1185
  const milestoneMethod = waitUntil === "load" ? "Page.loadEventFired" : "Page.domContentEventFired";
978
1186
  const milestone = this.conn.waitFor(milestoneMethod, undefined, opts.timeoutMs).then(() => waitUntil).catch(() => {
@@ -990,6 +1198,10 @@ class CdpPageDriver {
990
1198
  contextId = tree?.frameTree?.frame?.id ?? "";
991
1199
  url = tree?.frameTree?.frame?.url ?? opts.url ?? "";
992
1200
  reloaded = true;
1201
+ } else if (entry) {
1202
+ await this.conn.send("Page.navigateToHistoryEntry", { entryId: entry.id }, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {});
1203
+ contextId = "";
1204
+ url = entry.url;
993
1205
  } else {
994
1206
  const nav = await this.conn.send("Page.navigate", { url: opts.url }, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {});
995
1207
  if (nav.errorText)
@@ -998,7 +1210,20 @@ class CdpPageDriver {
998
1210
  url = opts.url;
999
1211
  }
1000
1212
  const settled = await Promise.race([milestone, stopped]);
1001
- return { url, contextId, ...reloaded ? { reloaded } : {}, waitedFor: settled ?? "timeout" };
1213
+ if (entry) {
1214
+ const tree = await this.conn.send("Page.getFrameTree").catch(() => {
1215
+ return;
1216
+ });
1217
+ contextId = tree?.frameTree?.frame?.id ?? contextId;
1218
+ url = tree?.frameTree?.frame?.url ?? url;
1219
+ }
1220
+ return {
1221
+ url,
1222
+ contextId,
1223
+ ...reloaded ? { reloaded } : {},
1224
+ ...history ? { traversed: history } : {},
1225
+ waitedFor: settled ?? "timeout"
1226
+ };
1002
1227
  }
1003
1228
  async waitForText(text, timeoutMs = 15000, pollMs = 250) {
1004
1229
  await this.ensureDomain("Runtime");
@@ -1078,36 +1303,96 @@ class CdpPageDriver {
1078
1303
  async hover(loc) {
1079
1304
  const { objectId } = await resolveElementLocator(this.conn, loc);
1080
1305
  const { x, y } = await centerOf(this.conn, objectId);
1081
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
1306
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
1082
1307
  return { x, y };
1083
1308
  }
1084
1309
  async click(loc, opts) {
1085
1310
  const button = opts?.button ?? "left";
1086
1311
  const clickCount = opts?.clickCount ?? 1;
1312
+ const modifiers = inputModifierBits(opts?.modifiers);
1087
1313
  const { x, y } = await this.hover(loc);
1088
- await this.conn.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount });
1089
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, clickCount });
1314
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount, modifiers });
1315
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, clickCount, modifiers });
1090
1316
  return { x, y };
1091
1317
  }
1092
- async drag(from, to) {
1318
+ async scroll(anchor, opts) {
1319
+ await this.ensureDomain("Runtime");
1320
+ const { x, y } = await this.resolveScrollPoint(anchor);
1321
+ await armScrollSettleWatch(this.conn);
1322
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseWheel", x, y, deltaX: opts.deltaX ?? 0, deltaY: opts.deltaY ?? 0 });
1323
+ await awaitScrollSettle(this.conn);
1324
+ return { x, y };
1325
+ }
1326
+ async resolveScrollPoint(anchor) {
1327
+ if (anchor === undefined) {
1328
+ await this.ensureDomain("Runtime");
1329
+ const { result, exceptionDetails } = await this.conn.send("Runtime.evaluate", { expression: "({x: window.innerWidth / 2, y: window.innerHeight / 2})", returnByValue: true });
1330
+ if (exceptionDetails || !result.value)
1331
+ throw driverError("page-error", `could not measure viewport for scroll center: ${exceptionDetails?.text ?? "no value"}`);
1332
+ return result.value;
1333
+ }
1334
+ if ("x" in anchor)
1335
+ return anchor;
1336
+ const { objectId } = await resolveElementLocator(this.conn, anchor);
1337
+ return centerOf(this.conn, objectId);
1338
+ }
1339
+ async drag(from, to, opts) {
1093
1340
  const fromPt = await this.hover(from);
1094
- const { objectId: dstId } = await resolveElementLocator(this.conn, to);
1095
- const toPt = await centerOf(this.conn, dstId);
1096
- await this.conn.send("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
1097
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: (fromPt.x + toPt.x) / 2, y: (fromPt.y + toPt.y) / 2, button: "left" });
1098
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: toPt.x, y: toPt.y, button: "left" });
1099
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
1341
+ const toPt = await this.resolveDragDestination(to, fromPt);
1342
+ const steps = Math.max(1, Math.trunc(opts?.steps ?? 2));
1343
+ if ((opts?.mode ?? "mouse") === "html5") {
1344
+ await this.html5Drag(fromPt, toPt, steps);
1345
+ return { from: fromPt, to: toPt };
1346
+ }
1347
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
1348
+ for (const pt of interpolatePoints(fromPt, toPt, steps)) {
1349
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x: pt.x, y: pt.y, button: "left" });
1350
+ }
1351
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
1100
1352
  return { from: fromPt, to: toPt };
1101
1353
  }
1354
+ async resolveDragDestination(to, fromPt) {
1355
+ if ("dx" in to)
1356
+ return { x: fromPt.x + to.dx, y: fromPt.y + to.dy };
1357
+ if ("x" in to)
1358
+ return to;
1359
+ const { objectId } = await resolveElementLocator(this.conn, to);
1360
+ return centerOf(this.conn, objectId);
1361
+ }
1362
+ async html5Drag(fromPt, toPt, steps) {
1363
+ await this.dispatchInput("Input.setInterceptDrags", { enabled: true });
1364
+ try {
1365
+ const intercepted = this.conn.waitFor("Input.dragIntercepted", undefined, HTML5_DRAG_INTERCEPT_TIMEOUT_MS).catch(() => {
1366
+ return;
1367
+ });
1368
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
1369
+ for (const pt of interpolatePoints(fromPt, toPt, steps)) {
1370
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x: pt.x, y: pt.y, button: "left" });
1371
+ }
1372
+ const event = await intercepted;
1373
+ if (!event?.data) {
1374
+ 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.');
1375
+ }
1376
+ const data = event.data;
1377
+ for (const type of ["dragEnter", "dragOver", "drop"]) {
1378
+ await this.dispatchInput("Input.dispatchDragEvent", { type, x: toPt.x, y: toPt.y, data });
1379
+ }
1380
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
1381
+ } finally {
1382
+ await this.dispatchInput("Input.setInterceptDrags", { enabled: false }).catch(() => {
1383
+ return;
1384
+ });
1385
+ }
1386
+ }
1102
1387
  async setValue(loc, value) {
1103
1388
  const { objectId } = await resolveElementLocator(this.conn, loc);
1104
- await setValueOnObject(this.conn, objectId, value);
1389
+ await setValueOnObject(this.conn, objectId, value, (method, params) => this.dispatchInput(method, params));
1105
1390
  }
1106
1391
  async typeText(loc, text) {
1107
1392
  const { objectId } = await resolveElementLocator(this.conn, loc);
1108
1393
  await focusElement(this.conn, objectId);
1109
1394
  if (text.length)
1110
- await this.conn.send("Input.insertText", { text });
1395
+ await this.dispatchInput("Input.insertText", { text });
1111
1396
  }
1112
1397
  async pressKey(press) {
1113
1398
  const spec = resolveKey(press.key);
@@ -1122,8 +1407,8 @@ class CdpPageDriver {
1122
1407
  const down = { type: "keyDown", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits };
1123
1408
  if (spec.text && !suppressText)
1124
1409
  down.text = spec.text;
1125
- await this.conn.send("Input.dispatchKeyEvent", down);
1126
- await this.conn.send("Input.dispatchKeyEvent", { type: "keyUp", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits });
1410
+ await this.dispatchInput("Input.dispatchKeyEvent", down);
1411
+ await this.dispatchInput("Input.dispatchKeyEvent", { type: "keyUp", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits });
1127
1412
  }
1128
1413
  async setFiles(loc, files) {
1129
1414
  const { objectId } = await resolveElementLocator(this.conn, loc);
@@ -1339,6 +1624,36 @@ class CdpPageDriver {
1339
1624
  this.conn.close();
1340
1625
  }
1341
1626
  }
1627
+ var beaconSessions = new BeaconSessions((conn) => conn.close());
1628
+ async function targetWsUrl(targetId) {
1629
+ const hit = (await listTargets()).find((t) => t.id === targetId);
1630
+ return hit?.webSocketDebuggerUrl;
1631
+ }
1632
+ async function readBeaconOver(conn) {
1633
+ try {
1634
+ const res = await conn.send("Runtime.evaluate", {
1635
+ expression: BEACON_READ_EXPRESSION,
1636
+ returnByValue: true
1637
+ });
1638
+ const value = res.result?.value;
1639
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
1640
+ } catch {
1641
+ return;
1642
+ }
1643
+ }
1644
+ async function probeOver(conn, timeoutMs) {
1645
+ try {
1646
+ const res = await conn.send("Runtime.evaluate", { expression: RENDERER_PROBE_EXPRESSION, returnByValue: true }, { timeoutMs });
1647
+ const value = res.result?.value;
1648
+ if (Array.isArray(value) && value[0] === 1) {
1649
+ const ts = value[1];
1650
+ return { responsive: true, beaconTs: typeof ts === "number" && Number.isFinite(ts) ? ts : null };
1651
+ }
1652
+ return { responsive: false, beaconTs: null };
1653
+ } catch {
1654
+ return { responsive: false, beaconTs: null };
1655
+ }
1656
+ }
1342
1657
  async function withBrowserConn(fn) {
1343
1658
  const conn = await openBrowser();
1344
1659
  try {
@@ -1377,6 +1692,8 @@ class CdpBrowserDriver {
1377
1692
  async closePage(id) {
1378
1693
  return withBrowserConn(async (conn) => {
1379
1694
  const res = await conn.send("Target.closeTarget", { targetId: id });
1695
+ if (res.success !== false)
1696
+ beaconSessions.drop(id);
1380
1697
  return { success: res.success ?? true };
1381
1698
  });
1382
1699
  }
@@ -1389,7 +1706,83 @@ class CdpBrowserDriver {
1389
1706
  const { conn, target } = await openPage(selector).catch(noSuchTarget);
1390
1707
  return new CdpPageDriver(conn, target, this);
1391
1708
  }
1392
- async dispose() {}
1709
+ async installActivityBeacon(targetId) {
1710
+ const held = beaconSessions.get(targetId);
1711
+ if (held) {
1712
+ const rearmed = await held.send("Runtime.evaluate", { expression: BEACON_SOURCE, returnByValue: true }).then(() => true).catch(() => false);
1713
+ if (rearmed)
1714
+ return true;
1715
+ beaconSessions.drop(targetId);
1716
+ }
1717
+ const wsUrl = await targetWsUrl(targetId).catch(() => {
1718
+ return;
1719
+ });
1720
+ if (!wsUrl)
1721
+ return false;
1722
+ const conn = await new CdpConnection(wsUrl).connect().catch(() => {
1723
+ return;
1724
+ });
1725
+ if (!conn)
1726
+ return false;
1727
+ try {
1728
+ await conn.send("Page.enable");
1729
+ await conn.send("Page.addScriptToEvaluateOnNewDocument", { source: BEACON_SOURCE });
1730
+ await conn.send("Runtime.evaluate", { expression: BEACON_SOURCE, returnByValue: true });
1731
+ } catch {
1732
+ conn.close();
1733
+ return false;
1734
+ }
1735
+ beaconSessions.set(targetId, conn);
1736
+ return true;
1737
+ }
1738
+ async readActivityBeacon(targetId) {
1739
+ const held = beaconSessions.get(targetId);
1740
+ if (held) {
1741
+ const value = await readBeaconOver(held);
1742
+ if (value !== undefined)
1743
+ return value;
1744
+ beaconSessions.drop(targetId);
1745
+ }
1746
+ const wsUrl = await targetWsUrl(targetId).catch(() => {
1747
+ return;
1748
+ });
1749
+ if (!wsUrl)
1750
+ return null;
1751
+ const conn = await new CdpConnection(wsUrl).connect().catch(() => {
1752
+ return;
1753
+ });
1754
+ if (!conn)
1755
+ return null;
1756
+ try {
1757
+ return await readBeaconOver(conn) ?? null;
1758
+ } finally {
1759
+ conn.close();
1760
+ }
1761
+ }
1762
+ async probeRenderer(targetId, timeoutMs = RENDERER_PROBE_TIMEOUT_MS) {
1763
+ const held = beaconSessions.get(targetId);
1764
+ if (held)
1765
+ return probeOver(held, timeoutMs);
1766
+ const wsUrl = await targetWsUrl(targetId).catch(() => {
1767
+ return;
1768
+ });
1769
+ if (!wsUrl)
1770
+ return { responsive: false, beaconTs: null };
1771
+ const conn = new CdpConnection(wsUrl, { timeoutMs });
1772
+ try {
1773
+ await conn.connect();
1774
+ } catch {
1775
+ return { responsive: false, beaconTs: null };
1776
+ }
1777
+ try {
1778
+ return await probeOver(conn, timeoutMs);
1779
+ } finally {
1780
+ conn.close();
1781
+ }
1782
+ }
1783
+ async dispose() {
1784
+ beaconSessions.clear();
1785
+ }
1393
1786
  }
1394
1787
  function createCdpDriver() {
1395
1788
  return new CdpBrowserDriver;
@@ -1429,6 +1822,20 @@ async function recordOrigin(backend, targetId, opts = {}) {
1429
1822
  await writeFile2(file, JSON.stringify(record), { flag: "wx" });
1430
1823
  } catch {}
1431
1824
  }
1825
+ async function readOrigin(backend, targetId) {
1826
+ let raw;
1827
+ try {
1828
+ raw = await readFile2(originFile(backend, targetId), "utf8");
1829
+ } catch {
1830
+ return;
1831
+ }
1832
+ try {
1833
+ const rec = JSON.parse(raw);
1834
+ if (typeof rec?.targetId === "string" && typeof rec?.label === "string" && typeof rec?.createdAt === "number")
1835
+ return rec;
1836
+ } catch {}
1837
+ return;
1838
+ }
1432
1839
  async function listOrigins(opts = {}) {
1433
1840
  let names = [];
1434
1841
  try {
@@ -1486,59 +1893,51 @@ async function newTrackedPage(driver, backend, opts = {}) {
1486
1893
  return page;
1487
1894
  }
1488
1895
 
1489
- // src/leases-tools.ts
1490
- class LeaseToolError extends Error {
1491
- }
1492
- function backendOf(driver) {
1493
- return driver.scheme === "bidi" ? "firefox" : "chrome";
1494
- }
1495
- async function claimPage(driver, args = {}) {
1496
- const backend = backendOf(driver);
1497
- const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
1498
- const pages = await driver.listPages();
1499
- let targetId = args.targetId;
1500
- let url = args.url ?? "about:blank";
1501
- if (targetId === undefined || targetId === "") {
1502
- const page = await newTrackedPage(driver, backend, { url: args.url, label });
1503
- targetId = page.id;
1504
- url = page.url;
1505
- } else {
1506
- const existing = pages.find((p) => p.id === targetId);
1507
- if (!existing)
1508
- throw new LeaseToolError(`claim_page: no page target with id '${targetId}'`);
1509
- url = existing.url;
1896
+ // src/reap.ts
1897
+ function staleAgentTabs(input) {
1898
+ const live = new Set(input.livePageIds);
1899
+ const out = [];
1900
+ for (const row of input.leases) {
1901
+ if (row.backend !== input.backend)
1902
+ continue;
1903
+ if (row.unreadable !== undefined)
1904
+ continue;
1905
+ if (row.stale !== "dead-pid" && row.stale !== "expired")
1906
+ continue;
1907
+ if (row.stale === "expired" && input.now - row.lastUsedAt <= row.ttlMs + input.reapGraceMs)
1908
+ continue;
1909
+ if (!live.has(row.targetId))
1910
+ continue;
1911
+ const origin = input.origins.get(row.targetId);
1912
+ if (origin === undefined || origin.unreadable !== undefined)
1913
+ continue;
1914
+ out.push({ targetId: row.targetId, label: row.label, reason: row.stale });
1510
1915
  }
1511
- const liveIds = [...pages.map((p) => p.id), targetId];
1512
- const { record, token } = await claimLease(backend, targetId, {
1513
- label,
1514
- ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
1515
- liveIds
1516
- });
1517
- return {
1518
- lease: token,
1519
- targetId,
1520
- url,
1521
- label: record.label,
1522
- ttlMs: record.ttlMs,
1523
- expiresAt: record.lastUsedAt + record.ttlMs
1524
- };
1916
+ return out;
1525
1917
  }
1526
- async function releasePage(_driver, args) {
1527
- if (typeof args.lease !== "string" || !args.lease.length) {
1528
- throw new LeaseToolError("release_page requires the 'lease' token returned by claim_page");
1918
+ var DEFAULT_REAP_GRACE_MS = 2700000;
1919
+ function reapGraceMs() {
1920
+ const raw = Number(process.env.CDP_REAP_GRACE_MS);
1921
+ return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_REAP_GRACE_MS;
1922
+ }
1923
+ async function reapStaleAgentTabs(driver, backend) {
1924
+ const now = Date.now();
1925
+ const livePageIds = (await driver.listPages()).map((p) => p.id);
1926
+ const [origins, leases] = await Promise.all([
1927
+ originIndex(backend, undefined),
1928
+ listLeases({ now, liveIds: livePageIds, liveBackend: backend }).catch(() => [])
1929
+ ]);
1930
+ const candidates = staleAgentTabs({ backend, livePageIds, origins, leases, now, reapGraceMs: reapGraceMs() });
1931
+ const closed = [];
1932
+ for (const c of candidates) {
1933
+ const res = await driver.closePage(c.targetId).catch(() => ({ success: false }));
1934
+ if (res.success !== true)
1935
+ continue;
1936
+ await releaseLeaseFor(backend, c.targetId);
1937
+ closed.push(c);
1529
1938
  }
1530
- return releaseLease(args.lease);
1939
+ return closed;
1531
1940
  }
1532
- async function listLeasesTool(driver, _args = {}) {
1533
- const liveIds = (await driver.listPages()).map((p) => p.id);
1534
- const leases = await listLeases({ liveIds, liveBackend: backendOf(driver) });
1535
- return { leases, count: leases.length };
1536
- }
1537
- var LEASE_TOOLS = {
1538
- claim_page: claimPage,
1539
- release_page: releasePage,
1540
- list_leases: listLeasesTool
1541
- };
1542
1941
 
1543
1942
  // src/shared-tools.ts
1544
1943
  import { mkdir as mkdir3, writeFile as writeFile3 } from "node:fs/promises";
@@ -1551,7 +1950,7 @@ class SharedToolError extends Error {
1551
1950
  function target3(p) {
1552
1951
  return { id: p.id, url: p.url, title: p.title };
1553
1952
  }
1554
- function backendOf2(driver) {
1953
+ function backendOf(driver) {
1555
1954
  return driver.scheme === "bidi" ? "firefox" : "chrome";
1556
1955
  }
1557
1956
  async function withPage2(driver, target, fn) {
@@ -1565,7 +1964,7 @@ async function withPage2(driver, target, fn) {
1565
1964
  async function resolvePage(driver, selector) {
1566
1965
  const pages = await driver.listPages();
1567
1966
  const hit = await pickPage(driver, pages, selector);
1568
- await assertLeaseOk(backendOf2(driver), hit.id, { url: hit.url, title: hit.title });
1967
+ await assertLeaseOk(backendOf(driver), hit.id, { url: hit.url, title: hit.title });
1569
1968
  return hit;
1570
1969
  }
1571
1970
  async function pickPage(driver, pages, selector) {
@@ -1620,27 +2019,43 @@ async function reapSet(driver, pages, all) {
1620
2019
  }
1621
2020
  }
1622
2021
  async function listPages(driver, args = {}) {
1623
- const pages = await driver.listPages({ all: args.all });
1624
- const ledger = await originIndex(backendOf2(driver), await reapSet(driver, pages, args.all));
1625
- const annotated = pages.map((p) => {
2022
+ const backend = backendOf(driver);
2023
+ const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
2024
+ const reapedIds = new Set(reaped.map((r) => r.targetId));
2025
+ const pages = (await driver.listPages({ all: args.all })).filter((p) => !reapedIds.has(p.id));
2026
+ const ledger = await originIndex(backend, await reapSet(driver, pages, args.all));
2027
+ const now = Date.now();
2028
+ 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]));
2029
+ const doProbe = args.probe === true && rendererProbeSupported(driver);
2030
+ const annotated = await Promise.all(pages.map(async (p) => {
1626
2031
  const rec = ledger.get(p.id);
1627
- if (!rec)
1628
- return { ...p, origin: "unknown" };
1629
- if (rec.unreadable !== undefined)
1630
- return { ...p, origin: "unknown", originUnreadable: rec.unreadable };
1631
- return { ...p, origin: "agent", label: rec.label, createdAt: rec.createdAt };
1632
- });
1633
- return { pages: annotated, count: annotated.length };
2032
+ const base = !rec ? { ...p, origin: "unknown" } : rec.unreadable !== undefined ? { ...p, origin: "unknown", originUnreadable: rec.unreadable } : { ...p, origin: "agent", label: rec.label, createdAt: rec.createdAt };
2033
+ const leaseRow = leaseByTarget.get(p.id);
2034
+ if (leaseRow) {
2035
+ base.lease = { label: leaseRow.label, pid: leaseRow.pid, idleMs: now - leaseRow.lastUsedAt, expiresAt: leaseRow.lastUsedAt + leaseRow.ttlMs, stale: leaseRow.stale };
2036
+ }
2037
+ if (doProbe && (p.type === undefined || p.type === "page")) {
2038
+ const probe = await probeRendererActivity(driver, backend, p.id, now);
2039
+ base.responsive = probe.responsive;
2040
+ if (probe.humanActiveMs !== undefined)
2041
+ base.humanActiveMs = probe.humanActiveMs;
2042
+ }
2043
+ return base;
2044
+ }));
2045
+ return { pages: annotated, count: annotated.length, ...reaped.length ? { reaped } : {} };
1634
2046
  }
1635
2047
  async function newPage(driver, args = {}) {
1636
2048
  const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
1637
- const p = await newTrackedPage(driver, backendOf2(driver), { url: args.url, label });
1638
- if (args.claim !== true)
2049
+ const p = await newTrackedPage(driver, backendOf(driver), { url: args.url, label });
2050
+ const explicit = args.claim === true;
2051
+ if (!explicit && !requireLease())
1639
2052
  return { targetId: p.id, url: p.url };
1640
- const { record, token } = await claimLease(backendOf2(driver), p.id, {
2053
+ const { record, token } = await claimLease(backendOf(driver), p.id, {
1641
2054
  label,
1642
- ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs()
2055
+ ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
2056
+ auto: !explicit
1643
2057
  });
2058
+ await installBeacon(driver, p.id);
1644
2059
  return { targetId: p.id, url: p.url, lease: token, label: record.label, expiresAt: record.lastUsedAt + record.ttlMs };
1645
2060
  }
1646
2061
  async function closePage(driver, args) {
@@ -1650,7 +2065,7 @@ async function closePage(driver, args) {
1650
2065
  const res = await driver.closePage(p.id);
1651
2066
  if (!res.success)
1652
2067
  return { closed: p.id, success: false, leaseReleased: false };
1653
- const released = await releaseLeaseFor(backendOf2(driver), p.id);
2068
+ const released = await releaseLeaseFor(backendOf(driver), p.id);
1654
2069
  return { closed: p.id, success: true, ...released.released ? { leaseReleased: true } : {} };
1655
2070
  }
1656
2071
  async function writeSelectedFile(id) {
@@ -1672,17 +2087,36 @@ function toLegacyWaitedFor(w) {
1672
2087
  return "navigate-only";
1673
2088
  return w;
1674
2089
  }
1675
- async function navigatePage(driver, args) {
2090
+ function resolveNavigateMode(args) {
1676
2091
  const reload = args.reload === true;
1677
- if (!reload && (!args.url || typeof args.url !== "string")) {
1678
- throw new SharedToolError("navigate_page: 'url' is required (or pass reload:true to reload the current page)");
2092
+ const hasUrl = typeof args.url === "string" && args.url.length > 0;
2093
+ const hasHistory = args.history !== undefined && args.history !== null;
2094
+ const given = [hasUrl ? "url" : "", reload ? "reload" : "", hasHistory ? "history" : ""].filter(Boolean);
2095
+ if (given.length > 1) {
2096
+ throw new SharedToolError(`navigate_page: 'url', 'reload' and 'history' are mutually exclusive (got ${given.join(" + ")})`);
2097
+ }
2098
+ if (hasHistory) {
2099
+ if (args.history !== "back" && args.history !== "forward") {
2100
+ throw new SharedToolError(`navigate_page: 'history' must be 'back' or 'forward' (got ${JSON.stringify(args.history)})`);
2101
+ }
2102
+ return { mode: "history", history: args.history };
1679
2103
  }
2104
+ if (reload)
2105
+ return { mode: "reload" };
2106
+ if (!hasUrl) {
2107
+ 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)");
2108
+ }
2109
+ return { mode: "url", url: args.url };
2110
+ }
2111
+ async function navigatePage(driver, args) {
2112
+ const mode = resolveNavigateMode(args);
1680
2113
  return withPage2(driver, args.target, async (page) => {
1681
- const r = await page.navigate(args);
2114
+ const r = await page.navigate(mode.mode === "history" ? { ...args, history: mode.history } : args);
1682
2115
  return {
1683
2116
  url: r.url,
1684
2117
  frameId: r.contextId,
1685
2118
  ...r.reloaded ? { reloaded: r.reloaded } : {},
2119
+ ...r.traversed ? { traversed: r.traversed } : {},
1686
2120
  waitedFor: toLegacyWaitedFor(r.waitedFor)
1687
2121
  };
1688
2122
  });
@@ -1828,22 +2262,113 @@ async function takeSnapshot(driver, args = {}) {
1828
2262
  }
1829
2263
  async function click(driver, args) {
1830
2264
  return withPage2(driver, args.target, async (page) => {
1831
- const { x, y } = await page.click(locatorOf(args), { button: args.button ?? "left", clickCount: args.clickCount ?? 1 });
2265
+ const { x, y } = await page.click(locatorOf(args), { button: args.button ?? "left", clickCount: args.clickCount ?? 1, modifiers: args.modifiers });
1832
2266
  return { clicked: true, x, y };
1833
2267
  });
1834
2268
  }
2269
+ function resolveScrollAnchor(args) {
2270
+ const hasUid = args.uid !== undefined && args.uid !== null && args.uid !== "";
2271
+ const hasSelector = typeof args.selector === "string" && args.selector.length > 0;
2272
+ const hasX = args.x !== undefined;
2273
+ const hasY = args.y !== undefined;
2274
+ if (hasX !== hasY)
2275
+ throw new SharedToolError("scroll: 'x' and 'y' must be provided together");
2276
+ const hasXY = hasX && hasY;
2277
+ const anchorCount = [hasUid, hasSelector, hasXY].filter(Boolean).length;
2278
+ if (anchorCount > 1)
2279
+ throw new SharedToolError("scroll: provide at most one of { uid }, { selector }, or { x, y } (omit all three to scroll at the viewport center)");
2280
+ if (hasUid || hasSelector)
2281
+ return locatorOf({ uid: args.uid, selector: args.selector });
2282
+ if (hasXY)
2283
+ return { x: args.x, y: args.y };
2284
+ return;
2285
+ }
2286
+ function resolveScrollDelta(args) {
2287
+ const deltaX = args.deltaX ?? 0;
2288
+ const deltaY = args.deltaY ?? 0;
2289
+ if (deltaX === 0 && deltaY === 0)
2290
+ throw new SharedToolError("scroll requires at least one of { deltaX } or { deltaY }");
2291
+ return { deltaX, deltaY };
2292
+ }
2293
+ async function scroll(driver, args) {
2294
+ const anchor = resolveScrollAnchor(args);
2295
+ const { deltaX, deltaY } = resolveScrollDelta(args);
2296
+ return withPage2(driver, args.target, async (page) => {
2297
+ const { x, y } = await page.scroll(anchor, { deltaX, deltaY });
2298
+ return { x, y, deltaX, deltaY, target: target3(page.info) };
2299
+ });
2300
+ }
1835
2301
  async function hover(driver, args) {
1836
2302
  return withPage2(driver, args.target, async (page) => {
1837
2303
  const { x, y } = await page.hover(locatorOf(args));
1838
2304
  return { hovered: true, x, y };
1839
2305
  });
1840
2306
  }
2307
+ function resolveDragTo(to) {
2308
+ const hasUid = to.uid !== undefined && to.uid !== null && to.uid !== "";
2309
+ const hasSelector = typeof to.selector === "string" && to.selector.length > 0;
2310
+ const hasX = to.x !== undefined;
2311
+ const hasY = to.y !== undefined;
2312
+ if (hasX !== hasY)
2313
+ throw new SharedToolError("drag: 'to.x' and 'to.y' must be provided together");
2314
+ const hasXY = hasX && hasY;
2315
+ const count = [hasUid, hasSelector, hasXY].filter(Boolean).length;
2316
+ if (count !== 1)
2317
+ throw new SharedToolError("drag: 'to' takes exactly one of { uid }, { selector }, or { x, y }");
2318
+ if (hasXY) {
2319
+ if (!Number.isFinite(to.x) || !Number.isFinite(to.y))
2320
+ throw new SharedToolError("drag: 'to.x' and 'to.y' must be finite numbers");
2321
+ return { x: to.x, y: to.y };
2322
+ }
2323
+ return locatorOf({ uid: to.uid, selector: to.selector });
2324
+ }
2325
+ function resolveDragDestination(args) {
2326
+ const hasTo = args.to !== undefined && args.to !== null;
2327
+ const hasBy = args.by !== undefined && args.by !== null;
2328
+ if (hasTo === hasBy)
2329
+ throw new SharedToolError("drag requires exactly one of { to } or { by }");
2330
+ if (hasBy) {
2331
+ const by = args.by;
2332
+ const hasDx = by.dx !== undefined;
2333
+ const hasDy = by.dy !== undefined;
2334
+ if (!hasDx && !hasDy)
2335
+ throw new SharedToolError("drag: 'by' requires at least one of { dx } or { dy }");
2336
+ const dx = by.dx ?? 0;
2337
+ const dy = by.dy ?? 0;
2338
+ if (!Number.isFinite(dx) || !Number.isFinite(dy))
2339
+ throw new SharedToolError("drag: 'by.dx' and 'by.dy' must be finite numbers");
2340
+ return { dx, dy };
2341
+ }
2342
+ return resolveDragTo(args.to);
2343
+ }
2344
+ var MAX_DRAG_STEPS = 500;
2345
+ function resolveDragSteps(steps) {
2346
+ if (steps === undefined)
2347
+ return 2;
2348
+ if (typeof steps !== "number" || !Number.isInteger(steps) || steps < 1 || steps > MAX_DRAG_STEPS) {
2349
+ throw new SharedToolError(`drag: 'steps' must be an integer between 1 and ${MAX_DRAG_STEPS} (default 2)`);
2350
+ }
2351
+ return steps;
2352
+ }
2353
+ function resolveDragMode(mode, capabilities) {
2354
+ if (mode === undefined || mode === "mouse")
2355
+ return "mouse";
2356
+ if (mode !== "html5")
2357
+ throw new SharedToolError(`drag: unknown mode '${mode}' (use 'mouse' or 'html5')`);
2358
+ if (!capabilities.has("input.html5Drag")) {
2359
+ 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.");
2360
+ }
2361
+ return "html5";
2362
+ }
1841
2363
  async function drag(driver, args) {
1842
- if (!args.from || !args.to)
1843
- throw new SharedToolError("drag requires { from } and { to }");
2364
+ if (!args.from)
2365
+ throw new SharedToolError("drag requires { from }");
2366
+ const destination = resolveDragDestination(args);
2367
+ const steps = resolveDragSteps(args.steps);
2368
+ const mode = resolveDragMode(args.mode, driver.capabilities);
1844
2369
  return withPage2(driver, args.target, async (page) => {
1845
- const { from, to } = await page.drag(locatorOf(args.from), locatorOf(args.to));
1846
- return { dragged: true, from, to };
2370
+ const { from, to } = await page.drag(locatorOf(args.from), destination, { mode, steps });
2371
+ return { dragged: true, mode, steps, from, to };
1847
2372
  });
1848
2373
  }
1849
2374
  async function fill(driver, args) {
@@ -1964,6 +2489,7 @@ var SHARED_TOOLS = {
1964
2489
  click,
1965
2490
  hover,
1966
2491
  drag,
2492
+ scroll,
1967
2493
  fill,
1968
2494
  fill_form: fillForm,
1969
2495
  type_text: typeText,
@@ -1975,6 +2501,121 @@ var SHARED_TOOLS = {
1975
2501
  handle_dialog: handleDialog
1976
2502
  };
1977
2503
 
2504
+ // src/leases-tools.ts
2505
+ class LeaseToolError extends Error {
2506
+ }
2507
+ function backendOf2(driver) {
2508
+ return driver.scheme === "bidi" ? "firefox" : "chrome";
2509
+ }
2510
+ async function claimPage(driver, args = {}) {
2511
+ const hasTarget = typeof args.target === "string" && args.target.length > 0;
2512
+ const hasTargetId = typeof args.targetId === "string" && args.targetId.length > 0;
2513
+ if (hasTarget && hasTargetId) {
2514
+ throw new LeaseToolError("claim_page takes at most one of 'target' (any selector: active | index:N | url:<substr> | title:<substr> | <targetId>) or 'targetId' (an exact target id, kept for back-compat). Pass 'target' alone to claim an open tab, or neither to open a fresh one.");
2515
+ }
2516
+ const backend = backendOf2(driver);
2517
+ const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
2518
+ const pages = await driver.listPages();
2519
+ let targetId;
2520
+ let url;
2521
+ let opened;
2522
+ if (hasTarget) {
2523
+ const hit = await pickPage(driver, pages, args.target).catch((err) => {
2524
+ throw new LeaseToolError(`claim_page: no open tab matches target '${args.target}' (${err.message}). No tab was opened: claim_page never creates one when 'target' is given.`);
2525
+ });
2526
+ targetId = hit.id;
2527
+ url = hit.url;
2528
+ opened = false;
2529
+ } else if (hasTargetId) {
2530
+ const existing = pages.find((p) => p.id === args.targetId);
2531
+ if (!existing)
2532
+ throw new LeaseToolError(`claim_page: no page target with id '${args.targetId}'`);
2533
+ targetId = existing.id;
2534
+ url = existing.url;
2535
+ opened = false;
2536
+ } else {
2537
+ const page = await newTrackedPage(driver, backend, { url: args.url, label });
2538
+ targetId = page.id;
2539
+ url = page.url;
2540
+ opened = true;
2541
+ }
2542
+ const humanMs = opened ? null : await readHumanActiveMs(driver, backend, targetId);
2543
+ const liveIds = [...pages.map((p) => p.id), targetId];
2544
+ const { record, token } = await claimLease(backend, targetId, {
2545
+ label,
2546
+ ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
2547
+ liveIds
2548
+ });
2549
+ await installBeacon(driver, targetId);
2550
+ const contention = contentionWarning(humanMs, { takeover: !opened });
2551
+ return {
2552
+ lease: token,
2553
+ targetId,
2554
+ url,
2555
+ label: record.label,
2556
+ ttlMs: record.ttlMs,
2557
+ expiresAt: record.lastUsedAt + record.ttlMs,
2558
+ opened,
2559
+ ...beaconSupported(driver) ? { humanActiveMs: humanMs } : {},
2560
+ ...contention !== undefined ? { contention } : {}
2561
+ };
2562
+ }
2563
+ async function releasePage(driver, args = {}) {
2564
+ const hasLease = typeof args.lease === "string" && args.lease.length > 0;
2565
+ const hasTarget = typeof args.target === "string" && args.target.length > 0;
2566
+ if (hasLease === hasTarget) {
2567
+ throw new LeaseToolError("release_page takes exactly one of 'lease' (the token claim_page returned) or 'target' (a selector for a tab this process holds)");
2568
+ }
2569
+ const backend = backendOf2(driver);
2570
+ let targetId;
2571
+ let released;
2572
+ if (hasLease) {
2573
+ const parts = tokenParts(args.lease);
2574
+ if (!parts)
2575
+ return { released: false, closed: false };
2576
+ targetId = parts.targetId;
2577
+ released = (await releaseLease(args.lease)).released;
2578
+ } else {
2579
+ const page = await resolvePage(driver, args.target);
2580
+ targetId = page.id;
2581
+ released = (await releaseLeaseFor(backend, targetId)).released;
2582
+ }
2583
+ if (!released)
2584
+ return { released: false, closed: false, ...hasTarget ? { targetId } : {} };
2585
+ const shouldClose = args.close === false ? false : args.close === true ? true : await readOrigin(backend, targetId) !== undefined;
2586
+ if (!shouldClose)
2587
+ return { released: true, closed: false, targetId };
2588
+ const res = await driver.closePage(targetId).catch(() => ({ success: false }));
2589
+ return { released: true, closed: res.success === true, targetId };
2590
+ }
2591
+ async function listLeasesTool(driver, _args = {}) {
2592
+ const backend = backendOf2(driver);
2593
+ const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
2594
+ const reapedIds = new Set(reaped.map((r) => r.targetId));
2595
+ const livePages = await driver.listPages();
2596
+ const liveIds = livePages.map((p) => p.id);
2597
+ const all = await listLeases({ liveIds, liveBackend: backend });
2598
+ const kept = all.filter((l) => !(l.backend === backend && reapedIds.has(l.targetId)));
2599
+ const liveSet = new Set(liveIds);
2600
+ const now = Date.now();
2601
+ const leases = [];
2602
+ for (const row of kept) {
2603
+ const computed = row.unreadable !== undefined ? row : { ...row, idleMs: now - row.lastUsedAt, expiresAt: row.lastUsedAt + row.ttlMs };
2604
+ if (row.backend !== backend || row.unreadable !== undefined || !liveSet.has(row.targetId)) {
2605
+ leases.push(computed);
2606
+ continue;
2607
+ }
2608
+ const humanMs = await readHumanActiveMs(driver, backend, row.targetId, now).catch(() => null);
2609
+ leases.push(humanMs === null ? computed : { ...computed, humanActiveMs: humanMs });
2610
+ }
2611
+ return { leases, count: leases.length, ...reaped.length ? { reaped } : {} };
2612
+ }
2613
+ var LEASE_TOOLS = {
2614
+ claim_page: claimPage,
2615
+ release_page: releasePage,
2616
+ list_leases: listLeasesTool
2617
+ };
2618
+
1978
2619
  // src/tools/console.ts
1979
2620
  import { readFile as readFile3 } from "node:fs/promises";
1980
2621
 
@@ -3122,6 +3763,273 @@ function truncate(s, max = 600) {
3122
3763
  return t.length > max ? `${t.slice(0, max)}…` : t;
3123
3764
  }
3124
3765
 
3766
+ // src/tools/dispatch-mouse.ts
3767
+ var CLICK_MODIFIER_BITS2 = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
3768
+ var BUTTONS = new Set(["left", "right", "middle"]);
3769
+ var CDP_EVENT_TYPE = {
3770
+ move: "mouseMoved",
3771
+ down: "mousePressed",
3772
+ up: "mouseReleased"
3773
+ };
3774
+ function modifierBits(mods) {
3775
+ let bits = 0;
3776
+ for (const m of mods ?? []) {
3777
+ const bit = CLICK_MODIFIER_BITS2[m];
3778
+ if (bit === undefined)
3779
+ throw new Error(`dispatch_mouse: unknown modifier '${m}' (use Alt, Control, Meta, or Shift)`);
3780
+ bits |= bit;
3781
+ }
3782
+ return bits;
3783
+ }
3784
+ function validateDispatchMouseArgs(args) {
3785
+ if (args.action !== "move" && args.action !== "down" && args.action !== "up") {
3786
+ throw new Error("dispatch_mouse requires { action } of 'move', 'down', or 'up'");
3787
+ }
3788
+ if (typeof args.x !== "number" || typeof args.y !== "number" || !Number.isFinite(args.x) || !Number.isFinite(args.y)) {
3789
+ throw new Error("dispatch_mouse requires numeric { x } and { y } on every call");
3790
+ }
3791
+ const button = args.button ?? "left";
3792
+ if (!BUTTONS.has(button))
3793
+ throw new Error(`dispatch_mouse: unknown button '${button}' (use left, right, or middle)`);
3794
+ }
3795
+ async function dispatchMouse(args) {
3796
+ validateDispatchMouseArgs(args);
3797
+ const button = args.button ?? "left";
3798
+ const modifiers = modifierBits(args.modifiers);
3799
+ return withPage(args.target, async (conn, target) => {
3800
+ const params = { type: CDP_EVENT_TYPE[args.action], x: args.x, y: args.y, button, modifiers };
3801
+ if (args.action !== "move")
3802
+ params.clickCount = args.clickCount ?? 1;
3803
+ await sendInput(conn, "chrome", target.id, "Input.dispatchMouseEvent", params);
3804
+ return { dispatched: args.action, x: args.x, y: args.y };
3805
+ });
3806
+ }
3807
+
3808
+ // src/tools/downloads.ts
3809
+ import { readdir as readdir3, rename, stat as stat4 } from "node:fs/promises";
3810
+ import { basename, extname, join as join8 } from "node:path";
3811
+
3812
+ // src/tools/browser-session.ts
3813
+ import { mkdir as mkdir9 } from "node:fs/promises";
3814
+ import { join as join7 } from "node:path";
3815
+ var ARTIFACT_DIR7 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
3816
+ var DOWNLOADS_DIR = join7(ARTIFACT_DIR7, "downloads");
3817
+ var DOWNLOAD_BUFFER_LIMIT = 50;
3818
+ var session;
3819
+ function isDisconnect(e) {
3820
+ const m = e instanceof Error ? e.message : String(e);
3821
+ return /connection (not open|closed)|closed by caller|websocket|ECONNREFUSED|socket/i.test(m);
3822
+ }
3823
+ function record(s, guid) {
3824
+ let r = s.downloads.find((d) => d.guid === guid);
3825
+ if (!r) {
3826
+ r = { guid, state: "inProgress", consumed: false };
3827
+ s.downloads.push(r);
3828
+ while (s.downloads.length > DOWNLOAD_BUFFER_LIMIT) {
3829
+ const i = s.downloads.findIndex((d) => d.consumed);
3830
+ s.downloads.splice(i >= 0 ? i : 0, 1);
3831
+ }
3832
+ }
3833
+ return r;
3834
+ }
3835
+ function subscribe(s) {
3836
+ s.conn.on("Browser.downloadWillBegin", (p) => {
3837
+ const r = record(s, String(p.guid));
3838
+ if (typeof p.url === "string")
3839
+ r.url = p.url;
3840
+ if (typeof p.suggestedFilename === "string")
3841
+ r.suggestedFilename = p.suggestedFilename;
3842
+ });
3843
+ s.conn.on("Browser.downloadProgress", (p) => {
3844
+ const r = record(s, String(p.guid));
3845
+ if (typeof p.receivedBytes === "number")
3846
+ r.bytes = p.receivedBytes;
3847
+ if (typeof p.filePath === "string")
3848
+ r.filePath = p.filePath;
3849
+ const state = String(p.state);
3850
+ if (state === "completed" || state === "canceled") {
3851
+ r.state = state;
3852
+ if (state === "completed") {
3853
+ const waiter = s.waiters.shift();
3854
+ if (waiter) {
3855
+ r.consumed = true;
3856
+ waiter(r);
3857
+ }
3858
+ }
3859
+ }
3860
+ });
3861
+ }
3862
+ async function getBrowserSession() {
3863
+ if (session && !session.dead)
3864
+ return session;
3865
+ const previousPath = session?.armedPath;
3866
+ const conn = await openBrowser();
3867
+ const s = { conn, downloads: [], waiters: [], dead: false };
3868
+ subscribe(s);
3869
+ session = s;
3870
+ if (previousPath)
3871
+ await armDownloadsAt(previousPath);
3872
+ return s;
3873
+ }
3874
+ async function browserSend(method, params = {}) {
3875
+ const s = await getBrowserSession();
3876
+ try {
3877
+ return await s.conn.send(method, params);
3878
+ } catch (e) {
3879
+ if (isDisconnect(e)) {
3880
+ s.dead = true;
3881
+ const fresh = await getBrowserSession();
3882
+ return await fresh.conn.send(method, params);
3883
+ }
3884
+ throw e;
3885
+ }
3886
+ }
3887
+ async function armDownloadsAt(path) {
3888
+ await mkdir9(path, { recursive: true });
3889
+ const s = await getBrowserSession();
3890
+ await s.conn.send("Browser.setDownloadBehavior", { behavior: "allowAndName", downloadPath: path, eventsEnabled: true });
3891
+ s.armedPath = path;
3892
+ }
3893
+ async function armDownloads() {
3894
+ const s = await getBrowserSession();
3895
+ if (s.armedPath !== DOWNLOADS_DIR)
3896
+ await armDownloadsAt(DOWNLOADS_DIR);
3897
+ return { downloadPath: DOWNLOADS_DIR };
3898
+ }
3899
+ function pendingDownloads() {
3900
+ return (session?.downloads ?? []).filter((d) => d.state === "completed" && !d.consumed);
3901
+ }
3902
+ async function nextCompletedDownload(timeoutMs) {
3903
+ const s = await getBrowserSession();
3904
+ const buffered = s.downloads.find((d) => d.state === "completed" && !d.consumed);
3905
+ if (buffered) {
3906
+ buffered.consumed = true;
3907
+ return buffered;
3908
+ }
3909
+ return new Promise((resolve, reject) => {
3910
+ const waiter = (r) => {
3911
+ clearTimeout(timer);
3912
+ resolve(r);
3913
+ };
3914
+ const timer = setTimeout(() => {
3915
+ const i = s.waiters.indexOf(waiter);
3916
+ if (i >= 0)
3917
+ s.waiters.splice(i, 1);
3918
+ 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).`));
3919
+ }, timeoutMs);
3920
+ s.waiters.push(waiter);
3921
+ });
3922
+ }
3923
+ async function disposeBrowserSession() {
3924
+ const s = session;
3925
+ session = undefined;
3926
+ if (!s)
3927
+ return;
3928
+ s.dead = true;
3929
+ try {
3930
+ s.conn.close();
3931
+ } catch {}
3932
+ }
3933
+
3934
+ // src/tools/downloads.ts
3935
+ function safeFilename(suggested) {
3936
+ const raw = (suggested ?? "").replace(/[\u0000-\u001f]/g, "");
3937
+ const name = basename(raw.split("\\").pop() ?? "").trim();
3938
+ if (!name || name === "." || name === "..")
3939
+ return "download";
3940
+ return name;
3941
+ }
3942
+ function nextFreeName(name, taken) {
3943
+ if (!taken.has(name))
3944
+ return name;
3945
+ const ext = extname(name);
3946
+ const stem = ext ? name.slice(0, -ext.length) : name;
3947
+ for (let i = 1;; i++) {
3948
+ const candidate = `${stem}-${i}${ext}`;
3949
+ if (!taken.has(candidate))
3950
+ return candidate;
3951
+ }
3952
+ }
3953
+ function validateWaitForDownloadArgs(args) {
3954
+ if (args.timeoutMs !== undefined) {
3955
+ if (typeof args.timeoutMs !== "number" || !Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0) {
3956
+ throw new Error(`wait_for_download: 'timeoutMs' must be a positive number (got ${JSON.stringify(args.timeoutMs)})`);
3957
+ }
3958
+ }
3959
+ if (args.arm !== undefined && typeof args.arm !== "boolean") {
3960
+ throw new Error(`wait_for_download: 'arm' must be a boolean (got ${JSON.stringify(args.arm)})`);
3961
+ }
3962
+ }
3963
+ async function waitForDownload(args = {}) {
3964
+ validateWaitForDownloadArgs(args);
3965
+ const t = await resolveTarget(args.target);
3966
+ const target = { id: t.id, url: t.url, title: t.title };
3967
+ const { downloadPath } = await armDownloads();
3968
+ if (args.arm === true)
3969
+ return { armed: true, downloadPath, pending: pendingDownloads().length, target };
3970
+ const rec = await nextCompletedDownload(args.timeoutMs ?? 30000);
3971
+ if (!rec.filePath) {
3972
+ throw new Error(`wait_for_download: download ${rec.guid} completed without a file path (nothing to return)`);
3973
+ }
3974
+ const suggestedFilename = safeFilename(rec.suggestedFilename);
3975
+ const taken = new Set(await readdir3(DOWNLOADS_DIR).catch(() => []));
3976
+ taken.delete(basename(rec.filePath));
3977
+ const finalName = nextFreeName(suggestedFilename, taken);
3978
+ const path = join8(DOWNLOADS_DIR, finalName);
3979
+ await rename(rec.filePath, path);
3980
+ const bytes = (await stat4(path)).size;
3981
+ return { path, suggestedFilename, bytes, ...rec.url ? { url: rec.url } : {}, target };
3982
+ }
3983
+
3984
+ // src/tools/permissions.ts
3985
+ function originOf(url) {
3986
+ try {
3987
+ const o = new URL(url).origin;
3988
+ return o && o !== "null" ? o : undefined;
3989
+ } catch {
3990
+ return;
3991
+ }
3992
+ }
3993
+ function validateGrantPermissionsArgs(args) {
3994
+ const reset = args.reset === true;
3995
+ if (args.reset !== undefined && typeof args.reset !== "boolean") {
3996
+ throw new Error(`grant_permissions: 'reset' must be a boolean (got ${JSON.stringify(args.reset)})`);
3997
+ }
3998
+ if (args.permissions === undefined) {
3999
+ if (!reset)
4000
+ throw new Error("grant_permissions: 'permissions' is required (or pass reset:true to clear previous grants)");
4001
+ return;
4002
+ }
4003
+ if (!Array.isArray(args.permissions)) {
4004
+ throw new Error(`grant_permissions: 'permissions' must be an array of CDP PermissionType strings (got ${JSON.stringify(args.permissions)})`);
4005
+ }
4006
+ if (args.permissions.length === 0 && !reset) {
4007
+ throw new Error("grant_permissions: 'permissions' must not be empty (pass reset:true if the intent is to clear grants)");
4008
+ }
4009
+ for (const p of args.permissions) {
4010
+ if (typeof p !== "string" || p.length === 0) {
4011
+ throw new Error(`grant_permissions: every entry of 'permissions' must be a non-empty string (got ${JSON.stringify(p)})`);
4012
+ }
4013
+ }
4014
+ }
4015
+ async function grantPermissions(args = {}) {
4016
+ validateGrantPermissionsArgs(args);
4017
+ const t = await resolveTarget(args.target);
4018
+ const target = { id: t.id, url: t.url, title: t.title };
4019
+ const reset = args.reset === true;
4020
+ const permissions = args.permissions ?? [];
4021
+ if (reset)
4022
+ await browserSend("Browser.resetPermissions", {});
4023
+ if (permissions.length === 0)
4024
+ return { reset: true, target };
4025
+ const origin = args.origin ?? originOf(t.url);
4026
+ if (!origin) {
4027
+ 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"`);
4028
+ }
4029
+ await browserSend("Browser.grantPermissions", { origin, permissions });
4030
+ return { granted: [...permissions], origin, ...reset ? { reset: true } : {}, target };
4031
+ }
4032
+
3125
4033
  // src/index.ts
3126
4034
  var cdpDriver = createCdpDriver();
3127
4035
  function onCdp(fn) {
@@ -3142,6 +4050,7 @@ var TOOLS = {
3142
4050
  click: onCdp(SHARED_TOOLS.click),
3143
4051
  hover: onCdp(SHARED_TOOLS.hover),
3144
4052
  drag: onCdp(SHARED_TOOLS.drag),
4053
+ scroll: onCdp(SHARED_TOOLS.scroll),
3145
4054
  fill: onCdp(SHARED_TOOLS.fill),
3146
4055
  fill_form: onCdp(SHARED_TOOLS.fill_form),
3147
4056
  type_text: onCdp(SHARED_TOOLS.type_text),
@@ -3166,6 +4075,9 @@ var TOOLS = {
3166
4075
  mock_request: mockRequest,
3167
4076
  list_mocks: listMocks,
3168
4077
  clear_mocks: clearMocks,
4078
+ dispatch_mouse: dispatchMouse,
4079
+ wait_for_download: waitForDownload,
4080
+ grant_permissions: grantPermissions,
3169
4081
  claim_page: onCdp(LEASE_TOOLS.claim_page),
3170
4082
  release_page: onCdp(LEASE_TOOLS.release_page),
3171
4083
  list_leases: onCdp(LEASE_TOOLS.list_leases)
@@ -3926,6 +4838,8 @@ var KEY_VALUES = {
3926
4838
  };
3927
4839
  var MODIFIER_VALUES = { shift: "", control: "", ctrl: "", alt: "", meta: "", cmd: "", command: "" };
3928
4840
  var BUTTON_CODES = { left: 0, middle: 1, right: 2 };
4841
+ var ARM_SCROLL_SETTLE_WATCH_SOURCE = "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);});}";
4842
+ var AWAIT_SCROLL_SETTLE_SOURCE = "function(){return window.__cdpScrollSettle;}";
3929
4843
  var BIDI_CAPABILITIES = new Set([
3930
4844
  "emulate.deviceMetrics",
3931
4845
  "screenshot.fullPage",
@@ -3947,6 +4861,7 @@ async function getConnection(port, timeoutMs) {
3947
4861
  connections.set(port, conn);
3948
4862
  return conn;
3949
4863
  }
4864
+ var bidiBeaconScripts = new Map;
3950
4865
  async function safeTitle(conn, contextId) {
3951
4866
  try {
3952
4867
  const res = await conn.send("script.evaluate", { expression: "document.title", target: { context: contextId }, awaitPromise: false });
@@ -4010,9 +4925,31 @@ class BidiPageDriver {
4010
4925
  this.info = info;
4011
4926
  this.browser = browser;
4012
4927
  }
4928
+ async urlAndReadyState() {
4929
+ const raw = await this.conn.send("script.evaluate", {
4930
+ expression: "location.href + '\\u0000' + document.readyState",
4931
+ target: { context: this.contextId },
4932
+ awaitPromise: false
4933
+ }).then((r) => r.type === "success" ? String(deserializeRemote(r.result) ?? "") : "").catch(() => "");
4934
+ const [url = "", ready = ""] = raw.split("\x00");
4935
+ return [url, ready];
4936
+ }
4937
+ async settleAfterTraversal(beforeUrl, waitUntil, timeoutMs) {
4938
+ const ready = waitUntil === "domcontentloaded" ? ["interactive", "complete"] : ["complete"];
4939
+ const deadline = Date.now() + timeoutMs;
4940
+ for (;; ) {
4941
+ const [url, state] = await this.urlAndReadyState();
4942
+ if (url && url !== beforeUrl && ready.includes(state))
4943
+ return { waitedFor: waitUntil, url };
4944
+ if (Date.now() >= deadline)
4945
+ return { waitedFor: "timeout", url: url || beforeUrl };
4946
+ await new Promise((r) => setTimeout(r, 50));
4947
+ }
4948
+ }
4013
4949
  async navigate(opts) {
4014
4950
  const reload = opts.reload === true;
4015
- if (!reload && !opts.url)
4951
+ const history = opts.history;
4952
+ if (!reload && !history && !opts.url)
4016
4953
  throw driverError2("page-error", "navigate: 'url' is required unless reload:true");
4017
4954
  const waitUntil = opts.waitUntil ?? "load";
4018
4955
  const wait = waitUntil === "domcontentloaded" ? "interactive" : "complete";
@@ -4022,6 +4959,17 @@ class BidiPageDriver {
4022
4959
  const r2 = await this.conn.send("browsingContext.reload", { context: this.contextId, ignoreCache: opts.ignoreCache === true, wait }, sendOpts);
4023
4960
  return { url: r2.url, contextId: this.contextId, reloaded: true, waitedFor: waitUntil };
4024
4961
  }
4962
+ if (history) {
4963
+ const [beforeUrl] = await this.urlAndReadyState();
4964
+ await this.conn.send("browsingContext.traverseHistory", { context: this.contextId, delta: history === "back" ? -1 : 1 }, sendOpts).catch((e) => {
4965
+ const msg = e instanceof Error ? e.message : String(e);
4966
+ if (/no such history entry|history/i.test(msg))
4967
+ throw driverError2("page-error", `navigate: no history entry to go ${history} to (${msg})`);
4968
+ throw e;
4969
+ });
4970
+ const settled = await this.settleAfterTraversal(beforeUrl, waitUntil, opts.timeoutMs ?? 15000);
4971
+ return { url: settled.url, contextId: this.contextId, traversed: history, waitedFor: settled.waitedFor };
4972
+ }
4025
4973
  const r = await this.conn.send("browsingContext.navigate", { context: this.contextId, url: opts.url, wait }, sendOpts);
4026
4974
  return { url: r.url, contextId: this.contextId, waitedFor: waitUntil };
4027
4975
  } catch (e) {
@@ -4104,6 +5052,7 @@ class BidiPageDriver {
4104
5052
  throw driverError2("stale-uid", `uid does not resolve: ${uid}`);
4105
5053
  }
4106
5054
  async performActions(actions) {
5055
+ recordDispatch("firefox", this.contextId);
4107
5056
  try {
4108
5057
  await this.conn.send("input.performActions", { context: this.contextId, actions });
4109
5058
  } catch (e) {
@@ -4124,6 +5073,9 @@ class BidiPageDriver {
4124
5073
  return { x, y };
4125
5074
  }
4126
5075
  async click(loc, opts) {
5076
+ if (opts?.modifiers && opts.modifiers.length > 0) {
5077
+ throw driverError2("unsupported", "click: 'modifiers' is not supported by the Firefox/BiDi driver; omit 'modifiers' or use --browser chrome for a modifier click.");
5078
+ }
4127
5079
  const ref = await resolveElementLocator2(this.conn, this.contextId, loc);
4128
5080
  const { x, y } = await this.centerOf(ref);
4129
5081
  const button = BUTTON_CODES[opts?.button ?? "left"];
@@ -4133,19 +5085,53 @@ class BidiPageDriver {
4133
5085
  await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
4134
5086
  return { x, y };
4135
5087
  }
4136
- async drag(from, to) {
5088
+ async scroll(anchor, opts) {
5089
+ const { x, y } = await this.resolveScrollPoint(anchor);
5090
+ const deltaX = Math.round(opts.deltaX ?? 0);
5091
+ const deltaY = Math.round(opts.deltaY ?? 0);
5092
+ await this.callFunctionValue(ARM_SCROLL_SETTLE_WATCH_SOURCE, []);
5093
+ const actions = [{ type: "scroll", x: Math.round(x), y: Math.round(y), deltaX, deltaY }];
5094
+ await this.performActions([{ type: "wheel", id: "cdp-wheel", actions }]);
5095
+ await this.callFunctionValue(AWAIT_SCROLL_SETTLE_SOURCE, []).catch(() => {
5096
+ return;
5097
+ });
5098
+ return { x, y };
5099
+ }
5100
+ async resolveScrollPoint(anchor) {
5101
+ if (anchor === undefined) {
5102
+ const v = await this.callFunctionValue("function(){return {x: window.innerWidth / 2, y: window.innerHeight / 2};}", []);
5103
+ if (!v || typeof v !== "object")
5104
+ throw driverError2("page-error", "could not measure viewport for scroll center");
5105
+ return v;
5106
+ }
5107
+ if ("x" in anchor)
5108
+ return anchor;
5109
+ return this.centerOf(await resolveElementLocator2(this.conn, this.contextId, anchor));
5110
+ }
5111
+ async drag(from, to, opts) {
5112
+ if (opts?.mode === "html5") {
5113
+ throw driverError2("unsupported", "drag: mode:'html5' is not supported by the Firefox/BiDi driver (WebDriver BiDi has no drag-interception primitive); " + "omit 'mode' for the synthetic-mouse drag, or use --browser chrome for real HTML5 drag events.");
5114
+ }
4137
5115
  const fromPt = await this.centerOf(await resolveElementLocator2(this.conn, this.contextId, from));
4138
- const toPt = await this.centerOf(await resolveElementLocator2(this.conn, this.contextId, to));
5116
+ const toPt = await this.resolveDragDestination(to, fromPt);
5117
+ const steps = Math.max(1, Math.trunc(opts?.steps ?? 2));
4139
5118
  const actions = [
4140
5119
  { type: "pointerMove", x: fromPt.x, y: fromPt.y, duration: 0 },
4141
- { type: "pointerDown", button: 0 },
4142
- { type: "pointerMove", x: (fromPt.x + toPt.x) / 2, y: (fromPt.y + toPt.y) / 2, duration: 50 },
4143
- { type: "pointerMove", x: toPt.x, y: toPt.y, duration: 50 },
4144
- { type: "pointerUp", button: 0 }
5120
+ { type: "pointerDown", button: 0 }
4145
5121
  ];
5122
+ for (const pt of interpolatePoints(fromPt, toPt, steps))
5123
+ actions.push({ type: "pointerMove", x: pt.x, y: pt.y, duration: 50 });
5124
+ actions.push({ type: "pointerUp", button: 0 });
4146
5125
  await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
4147
5126
  return { from: fromPt, to: toPt };
4148
5127
  }
5128
+ async resolveDragDestination(to, fromPt) {
5129
+ if ("dx" in to)
5130
+ return { x: fromPt.x + to.dx, y: fromPt.y + to.dy };
5131
+ if ("x" in to)
5132
+ return to;
5133
+ return this.centerOf(await resolveElementLocator2(this.conn, this.contextId, to));
5134
+ }
4149
5135
  async sendKeys(text) {
4150
5136
  if (!text.length)
4151
5137
  return;
@@ -4515,7 +5501,60 @@ class BidiBrowserDriver {
4515
5501
  const info = await pageInfoOf(conn, ctx);
4516
5502
  return new BidiPageDriver(conn, ctx.context, info, this);
4517
5503
  }
5504
+ async installActivityBeacon(contextId) {
5505
+ const conn = await getConnection(this.port, this.timeoutMs).catch(() => {
5506
+ return;
5507
+ });
5508
+ if (!conn)
5509
+ return false;
5510
+ try {
5511
+ const key = `${this.port}:${contextId}`;
5512
+ if (!bidiBeaconScripts.has(key)) {
5513
+ const registered = await conn.send("script.addPreloadScript", {
5514
+ functionDeclaration: BEACON_FUNCTION_DECLARATION,
5515
+ contexts: [contextId]
5516
+ });
5517
+ bidiBeaconScripts.set(key, registered.script);
5518
+ }
5519
+ await conn.send("script.evaluate", {
5520
+ expression: BEACON_SOURCE,
5521
+ target: { context: contextId },
5522
+ awaitPromise: false
5523
+ });
5524
+ return true;
5525
+ } catch {
5526
+ return false;
5527
+ } finally {
5528
+ conn.release();
5529
+ }
5530
+ }
5531
+ async readActivityBeacon(contextId) {
5532
+ const conn = await getConnection(this.port, this.timeoutMs).catch(() => {
5533
+ return;
5534
+ });
5535
+ if (!conn)
5536
+ return null;
5537
+ try {
5538
+ const res = await conn.send("script.evaluate", {
5539
+ expression: BEACON_READ_EXPRESSION,
5540
+ target: { context: contextId },
5541
+ awaitPromise: false
5542
+ });
5543
+ if (res.type !== "success")
5544
+ return null;
5545
+ const value = deserializeRemote(res.result);
5546
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
5547
+ } catch {
5548
+ return null;
5549
+ } finally {
5550
+ conn.release();
5551
+ }
5552
+ }
4518
5553
  async dispose() {
5554
+ for (const key of [...bidiBeaconScripts.keys()]) {
5555
+ if (key.startsWith(`${this.port}:`))
5556
+ bidiBeaconScripts.delete(key);
5557
+ }
4519
5558
  const conn = connections.get(this.port);
4520
5559
  if (!conn)
4521
5560
  return;
@@ -4532,7 +5571,7 @@ import { spawn as spawn3 } from "node:child_process";
4532
5571
  import { mkdtempSync, rmSync } from "node:fs";
4533
5572
  import { createConnection, createServer } from "node:net";
4534
5573
  import { tmpdir } from "node:os";
4535
- import { join as join7 } from "node:path";
5574
+ import { join as join9 } from "node:path";
4536
5575
  function resolveBinary() {
4537
5576
  const candidates = [
4538
5577
  process.env.FIREFOX_BIN,
@@ -4606,7 +5645,7 @@ async function launchFirefox(opts = {}) {
4606
5645
  const binary = resolveBinary();
4607
5646
  const port = await pickPort(opts.port);
4608
5647
  const isThrowaway = opts.profilePath === undefined;
4609
- const profilePath = opts.profilePath ?? mkdtempSync(join7(tmpdir(), "cdp-toolkit-ff-profile-"));
5648
+ const profilePath = opts.profilePath ?? mkdtempSync(join9(tmpdir(), "cdp-toolkit-ff-profile-"));
4610
5649
  const args = ["--profile", profilePath, "--remote-debugging-port", String(port), "--no-remote"];
4611
5650
  if (opts.headless)
4612
5651
  args.push("--headless");
@@ -5024,14 +6063,14 @@ async function main() {
5024
6063
  `);
5025
6064
  return 1;
5026
6065
  }
5027
- const session = await startFirefoxSession();
6066
+ const session2 = await startFirefoxSession();
5028
6067
  try {
5029
- const result = await neutralFn(session.driver, parsed.args);
6068
+ const result = await neutralFn(session2.driver, parsed.args);
5030
6069
  process.stdout.write(`${JSON.stringify(result, null, 2)}
5031
6070
  `);
5032
6071
  return 0;
5033
6072
  } finally {
5034
- await session.dispose();
6073
+ await session2.dispose();
5035
6074
  }
5036
6075
  });
5037
6076
  }