cdp-toolkit 1.7.0 → 1.9.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
@@ -278,6 +278,169 @@ async function listLeases(opts = {}) {
278
278
  return out;
279
279
  }
280
280
 
281
+ // src/origins.ts
282
+ import { mkdir as mkdir2, readFile as readFile2, readdir as readdir2, unlink as unlink2, writeFile as writeFile2 } from "node:fs/promises";
283
+ import { join as join2 } from "node:path";
284
+ function safeId2(id) {
285
+ return id.replace(/[^A-Za-z0-9._-]/g, "_");
286
+ }
287
+ function originFile(backend, targetId) {
288
+ return join2(leaseDir(), `origin-${backend}-${safeId2(targetId)}.json`);
289
+ }
290
+ var ORIGIN_FILE_RE = /^origin-(chrome|firefox)-(.+)\.json$/;
291
+ async function recordOrigin(backend, targetId, opts = {}) {
292
+ const record = {
293
+ backend,
294
+ targetId,
295
+ label: typeof opts.label === "string" && opts.label.length ? opts.label : defaultLabel(),
296
+ pid: process.pid,
297
+ createdAt: opts.now ?? Date.now()
298
+ };
299
+ const file = originFile(backend, targetId);
300
+ try {
301
+ await mkdir2(leaseDir(), { recursive: true });
302
+ try {
303
+ await writeFile2(file, JSON.stringify(record), { flag: "wx" });
304
+ return;
305
+ } catch (err) {
306
+ if (err.code !== "EEXIST")
307
+ throw err;
308
+ }
309
+ await unlink2(file).catch(() => {
310
+ return;
311
+ });
312
+ await writeFile2(file, JSON.stringify(record), { flag: "wx" });
313
+ } catch {}
314
+ }
315
+ async function readOrigin(backend, targetId) {
316
+ let raw;
317
+ try {
318
+ raw = await readFile2(originFile(backend, targetId), "utf8");
319
+ } catch {
320
+ return;
321
+ }
322
+ try {
323
+ const rec = JSON.parse(raw);
324
+ if (typeof rec?.targetId === "string" && typeof rec?.label === "string" && typeof rec?.createdAt === "number")
325
+ return rec;
326
+ } catch {}
327
+ return;
328
+ }
329
+ async function listOrigins(opts = {}) {
330
+ let names = [];
331
+ try {
332
+ names = await readdir2(leaseDir());
333
+ } catch {
334
+ return [];
335
+ }
336
+ const live = opts.liveIds === undefined ? undefined : new Set(opts.liveIds.map((id) => safeId2(id)));
337
+ const out = [];
338
+ for (const name of names) {
339
+ const parts = ORIGIN_FILE_RE.exec(name);
340
+ if (!parts)
341
+ continue;
342
+ const backend = parts[1];
343
+ const nameId = parts[2];
344
+ const scoped = live !== undefined && (opts.liveBackend === undefined || opts.liveBackend === backend);
345
+ if (scoped && !live.has(nameId)) {
346
+ await unlink2(join2(leaseDir(), name)).catch(() => {
347
+ return;
348
+ });
349
+ continue;
350
+ }
351
+ const fromName = { backend, targetId: nameId, label: "", pid: 0, createdAt: 0 };
352
+ let rec;
353
+ try {
354
+ rec = JSON.parse(await readFile2(join2(leaseDir(), name), "utf8"));
355
+ } catch (err) {
356
+ const code = err.code;
357
+ if (code === "ENOENT")
358
+ continue;
359
+ out.push({ ...fromName, unreadable: code ?? "unparseable" });
360
+ continue;
361
+ }
362
+ if (typeof rec?.targetId !== "string" || typeof rec?.label !== "string" || typeof rec?.createdAt !== "number") {
363
+ out.push({ ...fromName, unreadable: "unparseable" });
364
+ continue;
365
+ }
366
+ out.push({ backend: rec.backend, targetId: rec.targetId, label: rec.label, pid: rec.pid, createdAt: rec.createdAt });
367
+ }
368
+ return out;
369
+ }
370
+ async function originIndex(backend, liveIds) {
371
+ const rows = await listOrigins({ liveIds, liveBackend: backend }).catch(() => []);
372
+ const index = new Map;
373
+ for (const row of rows) {
374
+ if (row.backend !== backend)
375
+ continue;
376
+ index.set(row.targetId, row);
377
+ }
378
+ return index;
379
+ }
380
+ async function newTrackedPage(driver, backend, opts = {}) {
381
+ const page = await driver.newPage(opts.url);
382
+ await recordOrigin(backend, page.id, { label: opts.label });
383
+ return page;
384
+ }
385
+ async function resolveLiveLabel(backend, label, liveIds) {
386
+ const live = new Set(liveIds);
387
+ const [origins, leases] = await Promise.all([originIndex(backend), listLeases({ liveBackend: backend })]);
388
+ const knownLabels = new Set;
389
+ const matchIds = new Set;
390
+ for (const rec of origins.values()) {
391
+ if (rec.unreadable !== undefined || !live.has(rec.targetId))
392
+ continue;
393
+ knownLabels.add(rec.label);
394
+ if (rec.label === label)
395
+ matchIds.add(rec.targetId);
396
+ }
397
+ for (const rec of leases) {
398
+ if (rec.backend !== backend || rec.unreadable !== undefined || !live.has(rec.targetId))
399
+ continue;
400
+ knownLabels.add(rec.label);
401
+ if (rec.label === label)
402
+ matchIds.add(rec.targetId);
403
+ }
404
+ return { matchIds: [...matchIds], knownLabels: [...knownLabels].sort() };
405
+ }
406
+
407
+ // src/workers.ts
408
+ var WORKER_SELECTOR_PREFIX = "worker:";
409
+ var WORKER_TARGET_TYPES = new Set(["service_worker", "shared_worker"]);
410
+ function isWorkerSelector(selector) {
411
+ return typeof selector === "string" && selector.startsWith(WORKER_SELECTOR_PREFIX);
412
+ }
413
+ function workerNeedle(selector) {
414
+ return selector.slice(WORKER_SELECTOR_PREFIX.length);
415
+ }
416
+ function isWorkerTargetType(type) {
417
+ return type !== undefined && WORKER_TARGET_TYPES.has(type);
418
+ }
419
+ function resolveWorkerTargets(targets, needle) {
420
+ const liveWorkers = targets.filter((t) => isWorkerTargetType(t.type));
421
+ return {
422
+ matches: needle === "" ? [] : liveWorkers.filter((t) => t.url.includes(needle)),
423
+ liveWorkers
424
+ };
425
+ }
426
+ var WORKER_EMPTY_NEEDLE_MESSAGE = "'worker:' needs a substring of the worker's url, e.g. worker:<extension-id> or worker:background.js";
427
+ var WORKER_SELECTOR_UNSUPPORTED_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, which this backend cannot address " + "(WebDriver BiDi has no extension service-worker target; Capability 'worker.targets' is Chrome-only). " + "Use --browser chrome to evaluate in an extension's background worker.";
428
+ var WORKER_SELECTOR_PAGE_ONLY_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, and this tool is page-only " + "(closing, selecting or releasing a worker is meaningless). evaluate_script is the one tool that " + "accepts a worker: target.";
429
+ function urlList(workers) {
430
+ return workers.map((w) => w.url).join(", ");
431
+ }
432
+ function workerAmbiguityMessage(needle, matches) {
433
+ return `'worker:${needle}' matches more than one running worker: ` + matches.map((m) => `${m.url} (${m.id})`).join(", ") + ". Narrow the substring, or pass the worker's target id directly.";
434
+ }
435
+ function workerMissMessage(needle, opts) {
436
+ const running = opts.liveWorkers?.length ? ` (running workers: ${urlList(opts.liveWorkers)})` : " (no workers are running)";
437
+ if (!opts.wakeAttempted) {
438
+ return `no running worker whose url contains '${needle}'${running}. ` + "An MV3 extension service worker is idle-evicted after seconds of inactivity and then appears in NO target " + "listing, so this is usually a sleeping worker rather than a wrong substring. You passed wake:false; " + "wake:true (the default) asks Chrome to start it first.";
439
+ }
440
+ const scopes = opts.registeredScopes?.length ? ` Registered service-worker scopes in this browser: ${opts.registeredScopes.join(", ")}.` : " No service-worker registrations were reported by this browser at all.";
441
+ return `no worker whose url contains '${needle}' could be reached${running}. ` + `A start was requested (wake:true) and no matching worker appeared.${scopes} ` + "If the extension is installed but its worker never starts, check the worker script for a top-level throw.";
442
+ }
443
+
281
444
  // src/client.ts
282
445
  var BASE = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
283
446
  var DEFAULT_TIMEOUT_MS = Number(process.env.CDP_TIMEOUT_MS ?? 15000);
@@ -433,11 +596,13 @@ async function browserWsUrl() {
433
596
  }
434
597
  async function resolveTarget(selector, opts = {}) {
435
598
  const targets = await listTargets();
436
- const hit = pickTarget(targets, selector);
599
+ const hit = await pickTarget(targets, selector);
600
+ if (isWorkerTargetType(hit.type))
601
+ return hit;
437
602
  await assertLeaseOk("chrome", hit.id, { lease: opts.lease, url: hit.url, title: hit.title });
438
603
  return hit;
439
604
  }
440
- function pickTarget(targets, selector) {
605
+ async function pickTarget(targets, selector) {
441
606
  const pages = targets.filter((t) => t.type === "page");
442
607
  if (!selector || selector === "active") {
443
608
  if (!pages.length)
@@ -464,6 +629,30 @@ function pickTarget(targets, selector) {
464
629
  throw new CdpError(`no page title containing '${needle}'`);
465
630
  return hit;
466
631
  }
632
+ if (selector.startsWith("label:")) {
633
+ const label = selector.slice(6);
634
+ const { matchIds, knownLabels } = await resolveLiveLabel("chrome", label, pages.map((t) => t.id));
635
+ if (matchIds.length > 1) {
636
+ throw new CdpError(`label 'label:${label}' matches more than one live target: ${matchIds.join(", ")}. Resolve by target id instead.`);
637
+ }
638
+ if (matchIds.length === 1) {
639
+ const hit = pages.find((t) => t.id === matchIds[0]);
640
+ if (hit)
641
+ return hit;
642
+ }
643
+ throw new CdpError(knownLabels.length ? `no live target with label '${label}' (labels currently in use: ${knownLabels.join(", ")})` : `no live target with label '${label}' (no labels are currently assigned to any open target)`);
644
+ }
645
+ if (isWorkerSelector(selector)) {
646
+ const needle = workerNeedle(selector);
647
+ if (needle === "")
648
+ throw new CdpError(WORKER_EMPTY_NEEDLE_MESSAGE);
649
+ const { matches, liveWorkers } = resolveWorkerTargets(targets, needle);
650
+ if (matches.length > 1)
651
+ throw new CdpError(workerAmbiguityMessage(needle, matches));
652
+ if (matches.length === 1)
653
+ return matches[0];
654
+ throw new CdpError(workerMissMessage(needle, { wakeAttempted: false, liveWorkers }));
655
+ }
467
656
  const byId = targets.find((t) => t.id === selector);
468
657
  if (!byId)
469
658
  throw new CdpError(`no target with id '${selector}'`);
@@ -486,6 +675,141 @@ async function withPage(selector, fn, opts = {}) {
486
675
  }
487
676
  }
488
677
 
678
+ // src/activity.ts
679
+ var BEACON_DATA_GLOBAL = "__cdpToolkitLastInput";
680
+ var BEACON_INSTALLED_GLOBAL = "__cdpToolkitBeacon";
681
+ var BEACON_EVENTS = ["pointerdown", "mousedown", "keydown", "wheel", "touchstart"];
682
+ var BEACON_BODY = `
683
+ if (!window.${BEACON_INSTALLED_GLOBAL}) {
684
+ window.${BEACON_INSTALLED_GLOBAL} = true;
685
+ var mark = function () { window.${BEACON_DATA_GLOBAL} = Date.now(); };
686
+ var events = ${JSON.stringify([...BEACON_EVENTS])};
687
+ for (var i = 0; i < events.length; i++) {
688
+ window.addEventListener(events[i], mark, { capture: true, passive: true });
689
+ }
690
+ }`;
691
+ var BEACON_SOURCE = `(function () {${BEACON_BODY}
692
+ })();`;
693
+ var BEACON_FUNCTION_DECLARATION = `function () {${BEACON_BODY}
694
+ }`;
695
+ var BEACON_READ_EXPRESSION = `(typeof window.${BEACON_DATA_GLOBAL} === "number" ? window.${BEACON_DATA_GLOBAL} : null)`;
696
+ var DISPATCH_ATTRIBUTION_WINDOW_MS = 1500;
697
+ function dispatchKey(backend, targetId) {
698
+ return `${backend}:${targetId}`;
699
+ }
700
+ var dispatchLog = new Map;
701
+ var DISPATCH_LOG_LIMIT = 512;
702
+ function recordDispatch(backend, targetId, now = Date.now()) {
703
+ const key = dispatchKey(backend, targetId);
704
+ dispatchLog.delete(key);
705
+ dispatchLog.set(key, now);
706
+ while (dispatchLog.size > DISPATCH_LOG_LIMIT) {
707
+ const oldest = dispatchLog.keys().next();
708
+ if (oldest.done)
709
+ break;
710
+ dispatchLog.delete(oldest.value);
711
+ }
712
+ }
713
+ var NON_SYNTHESIZING_INPUT_METHODS = new Set(["Input.setInterceptDrags", "Input.setIgnoreInputEvents"]);
714
+ async function sendInput(conn, backend, targetId, method, params = {}) {
715
+ if (!NON_SYNTHESIZING_INPUT_METHODS.has(method))
716
+ recordDispatch(backend, targetId);
717
+ return conn.send(method, params);
718
+ }
719
+ function lastDispatchAt(backend, targetId) {
720
+ return dispatchLog.get(dispatchKey(backend, targetId));
721
+ }
722
+ function isHumanAttributed(beaconTs, dispatchAt, windowMs = DISPATCH_ATTRIBUTION_WINDOW_MS) {
723
+ if (typeof beaconTs !== "number" || !Number.isFinite(beaconTs))
724
+ return false;
725
+ return beaconTs > (dispatchAt ?? 0) + windowMs;
726
+ }
727
+ function humanActiveMs(beaconTs, dispatchAt, now = Date.now(), windowMs = DISPATCH_ATTRIBUTION_WINDOW_MS) {
728
+ if (!isHumanAttributed(beaconTs, dispatchAt, windowMs))
729
+ return null;
730
+ return Math.max(0, now - beaconTs);
731
+ }
732
+ var CONTENTION_WINDOW_MS = 30000;
733
+ function contentionWarning(ms, opts) {
734
+ if (!opts.takeover)
735
+ return;
736
+ if (ms === null || ms >= CONTENTION_WINDOW_MS)
737
+ return;
738
+ 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.`;
739
+ }
740
+ function beaconSupported(driver) {
741
+ return typeof driver.installActivityBeacon === "function" && typeof driver.readActivityBeacon === "function";
742
+ }
743
+ async function installBeacon(driver, targetId) {
744
+ if (typeof driver.installActivityBeacon !== "function")
745
+ return false;
746
+ return driver.installActivityBeacon(targetId).catch(() => false);
747
+ }
748
+ async function readHumanActiveMs(driver, backend, targetId, now = Date.now()) {
749
+ if (typeof driver.readActivityBeacon !== "function")
750
+ return null;
751
+ const beaconTs = await driver.readActivityBeacon(targetId).catch(() => null);
752
+ return humanActiveMs(beaconTs, lastDispatchAt(backend, targetId), now);
753
+ }
754
+ var RENDERER_PROBE_TIMEOUT_MS = 500;
755
+ var RENDERER_PROBE_EXPRESSION = `[1, ${BEACON_READ_EXPRESSION}]`;
756
+ function rendererProbeSupported(driver) {
757
+ return typeof driver.probeRenderer === "function";
758
+ }
759
+ async function probeRendererActivity(driver, backend, targetId, now = Date.now()) {
760
+ if (typeof driver.probeRenderer !== "function")
761
+ return { responsive: false };
762
+ const { responsive, beaconTs } = await driver.probeRenderer(targetId, RENDERER_PROBE_TIMEOUT_MS).catch(() => ({ responsive: false, beaconTs: null }));
763
+ const ms = humanActiveMs(beaconTs, lastDispatchAt(backend, targetId), now);
764
+ return ms === null ? { responsive } : { responsive, humanActiveMs: ms };
765
+ }
766
+
767
+ class BeaconSessions {
768
+ close;
769
+ limit;
770
+ live = new Map;
771
+ constructor(close, limit = 32) {
772
+ this.close = close;
773
+ this.limit = limit;
774
+ }
775
+ get(key) {
776
+ return this.live.get(key);
777
+ }
778
+ set(key, value) {
779
+ const previous = this.live.get(key);
780
+ if (previous !== undefined) {
781
+ this.live.delete(key);
782
+ this.safeClose(previous);
783
+ }
784
+ this.live.set(key, value);
785
+ while (this.live.size > this.limit) {
786
+ const oldest = this.live.keys().next();
787
+ if (oldest.done)
788
+ break;
789
+ this.drop(oldest.value);
790
+ }
791
+ }
792
+ drop(key) {
793
+ const value = this.live.get(key);
794
+ if (value === undefined)
795
+ return;
796
+ this.live.delete(key);
797
+ this.safeClose(value);
798
+ }
799
+ clear() {
800
+ for (const key of [...this.live.keys()])
801
+ this.drop(key);
802
+ }
803
+ get size() {
804
+ return this.live.size;
805
+ }
806
+ safeClose(value) {
807
+ try {
808
+ this.close(value);
809
+ } catch {}
810
+ }
811
+ }
812
+
489
813
  // src/tools/snapshot.ts
490
814
  var INTERACTIVE_ROLES = new Set([
491
815
  "button",
@@ -518,6 +842,91 @@ async function resolveUid(conn, uid) {
518
842
  return { objectId: object.objectId };
519
843
  }
520
844
 
845
+ // src/cdp/workers.ts
846
+ var INVENTORY_FIRST_EVENT_MS = 1500;
847
+ var INVENTORY_SETTLE_MS = 250;
848
+ var WAKE_POLL_MS = 300;
849
+ var WAKE_POLL_ATTEMPTS = 30;
850
+ var NO_HOST_PAGE_MESSAGE = "cannot start a service worker: the ServiceWorker CDP domain is only reachable over a page session, " + "and this browser has no page target open. Open any tab (new_page) and retry — the tab does not have " + "to belong to the extension.";
851
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
852
+ async function requestWorkerStart(needle) {
853
+ const conn = await openBrowser();
854
+ try {
855
+ const host = (await listTargets()).find((t) => t.type === "page");
856
+ if (!host)
857
+ throw new CdpError(NO_HOST_PAGE_MESSAGE);
858
+ const { sessionId } = await conn.send("Target.attachToTarget", {
859
+ targetId: host.id,
860
+ flatten: true
861
+ });
862
+ if (!sessionId)
863
+ throw new CdpError("could not attach a session to host the ServiceWorker domain");
864
+ const scopeById = new Map;
865
+ const versions = [];
866
+ conn.on("ServiceWorker.workerRegistrationUpdated", (params) => {
867
+ for (const r of params.registrations ?? []) {
868
+ if (r.isDeleted === true)
869
+ continue;
870
+ scopeById.set(String(r.registrationId), String(r.scopeURL ?? ""));
871
+ }
872
+ });
873
+ conn.on("ServiceWorker.workerVersionUpdated", (params) => {
874
+ for (const v of params.versions ?? []) {
875
+ versions.push({ registrationId: String(v.registrationId), scriptURL: String(v.scriptURL ?? "") });
876
+ }
877
+ });
878
+ await conn.send("ServiceWorker.enable", {}, { sessionId });
879
+ await conn.waitFor("ServiceWorker.workerVersionUpdated", undefined, INVENTORY_FIRST_EVENT_MS).catch(() => {
880
+ return;
881
+ });
882
+ await sleep(INVENTORY_SETTLE_MS);
883
+ const scopes = [...new Set(scopeById.values())].filter((s) => s.length > 0).sort();
884
+ const matched = new Set;
885
+ for (const v of versions) {
886
+ const scope = scopeById.get(v.registrationId);
887
+ if (scope === undefined)
888
+ continue;
889
+ if (v.scriptURL.includes(needle) || scope.includes(needle))
890
+ matched.add(scope);
891
+ }
892
+ if (matched.size === 0)
893
+ return { startRequested: false, scopes };
894
+ if (matched.size > 1) {
895
+ throw new CdpError(`'worker:${needle}' matches more than one registered service-worker scope: ${[...matched].sort().join(", ")}. ` + "Narrow the substring.");
896
+ }
897
+ await conn.send("ServiceWorker.startWorker", { scopeURL: [...matched][0] }, { sessionId });
898
+ return { startRequested: true, scopes };
899
+ } finally {
900
+ conn.close();
901
+ }
902
+ }
903
+ async function resolveWorkerSelector(selector, opts) {
904
+ const needle = workerNeedle(selector);
905
+ if (needle === "")
906
+ throw new CdpError(WORKER_EMPTY_NEEDLE_MESSAGE);
907
+ const first = resolveWorkerTargets(await listTargets(), needle);
908
+ if (first.matches.length > 1)
909
+ throw new CdpError(workerAmbiguityMessage(needle, first.matches));
910
+ if (first.matches.length === 1)
911
+ return first.matches[0];
912
+ if (!opts.wake) {
913
+ throw new CdpError(workerMissMessage(needle, { wakeAttempted: false, liveWorkers: first.liveWorkers }));
914
+ }
915
+ const { startRequested, scopes } = await requestWorkerStart(needle);
916
+ if (startRequested) {
917
+ for (let i = 0;i < WAKE_POLL_ATTEMPTS; i++) {
918
+ await sleep(WAKE_POLL_MS);
919
+ const again = resolveWorkerTargets(await listTargets(), needle);
920
+ if (again.matches.length > 1)
921
+ throw new CdpError(workerAmbiguityMessage(needle, again.matches));
922
+ if (again.matches.length === 1)
923
+ return again.matches[0];
924
+ }
925
+ }
926
+ const final = resolveWorkerTargets(await listTargets(), needle);
927
+ throw new CdpError(workerMissMessage(needle, { wakeAttempted: true, liveWorkers: final.liveWorkers, registeredScopes: scopes }));
928
+ }
929
+
521
930
  // src/tools/network_mock.ts
522
931
  function escapeRegExpChar(ch) {
523
932
  return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
@@ -709,11 +1118,22 @@ var REQUIRED_CAPABILITIES = {
709
1118
  stop_screen_recording: ["capture.screencast"],
710
1119
  mock_request: ["network.intercept"],
711
1120
  list_mocks: ["network.intercept"],
712
- clear_mocks: ["network.intercept"]
1121
+ clear_mocks: ["network.intercept"],
1122
+ dispatch_mouse: ["input.raw"],
1123
+ wait_for_download: ["browser.downloads"],
1124
+ grant_permissions: ["browser.permissions"]
713
1125
  };
714
1126
  function isDriverError(e) {
715
1127
  return e instanceof Error && typeof e.code === "string";
716
1128
  }
1129
+ function interpolatePoints(from, to, steps) {
1130
+ const out = [];
1131
+ for (let i = 1;i < steps; i++) {
1132
+ out.push({ x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps });
1133
+ }
1134
+ out.push({ x: to.x, y: to.y });
1135
+ return out;
1136
+ }
717
1137
 
718
1138
  // src/cdp/driver.ts
719
1139
  function driverError(code, message, data) {
@@ -769,12 +1189,21 @@ async function centerOf(conn, objectId) {
769
1189
  async function focusElement(conn, objectId) {
770
1190
  await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: "function(){this.focus&&this.focus();}", returnByValue: true });
771
1191
  }
772
- async function setValueOnObject(conn, objectId, value) {
1192
+ 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);});})()";
1193
+ async function armScrollSettleWatch(conn) {
1194
+ await conn.send("Runtime.evaluate", { expression: ARM_SCROLL_SETTLE_WATCH, returnByValue: true });
1195
+ }
1196
+ async function awaitScrollSettle(conn) {
1197
+ await conn.send("Runtime.evaluate", { expression: "window.__cdpScrollSettle", awaitPromise: true, returnByValue: true }).catch(() => {
1198
+ return;
1199
+ });
1200
+ }
1201
+ async function setValueOnObject(conn, objectId, value, sendInput2) {
773
1202
  await focusElement(conn, objectId);
774
1203
  const fn = "function(){if('value'in this){this.value='';this.dispatchEvent(new Event('input',{bubbles:true}));}else if(this.isContentEditable){this.textContent='';}}";
775
1204
  await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: fn, returnByValue: true });
776
1205
  if (value.length)
777
- await conn.send("Input.insertText", { text: value });
1206
+ await sendInput2("Input.insertText", { text: value });
778
1207
  }
779
1208
  var NAMED_KEYS = {
780
1209
  enter: { key: "Enter", code: "Enter", keyCode: 13, text: "\r" },
@@ -799,6 +1228,18 @@ var NAMED_KEYS = {
799
1228
  pagedown: { key: "PageDown", code: "PageDown", keyCode: 34 }
800
1229
  };
801
1230
  var MODIFIER_BITS = { alt: 1, control: 2, ctrl: 2, meta: 4, cmd: 4, command: 4, shift: 8 };
1231
+ var CLICK_MODIFIER_BITS = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
1232
+ function inputModifierBits(mods) {
1233
+ let bits = 0;
1234
+ for (const m of mods ?? []) {
1235
+ const bit = CLICK_MODIFIER_BITS[m];
1236
+ if (bit === undefined)
1237
+ throw driverError("page-error", `unknown modifier '${m}' (use Alt, Control, Meta, or Shift)`);
1238
+ bits |= bit;
1239
+ }
1240
+ return bits;
1241
+ }
1242
+ var HTML5_DRAG_INTERCEPT_TIMEOUT_MS = 5000;
802
1243
  function resolveKey(key) {
803
1244
  const named = NAMED_KEYS[key.toLowerCase()];
804
1245
  if (named)
@@ -963,7 +1404,12 @@ var CDP_CAPABILITIES = new Set([
963
1404
  "input.insertTextAtomic",
964
1405
  "locate.text",
965
1406
  "locate.xpath",
966
- "capture.screencast"
1407
+ "capture.screencast",
1408
+ "input.raw",
1409
+ "input.html5Drag",
1410
+ "browser.downloads",
1411
+ "browser.permissions",
1412
+ "worker.targets"
967
1413
  ]);
968
1414
 
969
1415
  class CdpPageDriver {
@@ -983,6 +1429,9 @@ class CdpPageDriver {
983
1429
  this.enabledDomains.add(name);
984
1430
  await this.conn.send(`${name}.enable`);
985
1431
  }
1432
+ async dispatchInput(method, params = {}) {
1433
+ return sendInput(this.conn, "chrome", this.info.id, method, params);
1434
+ }
986
1435
  async ensureDomainSoft(name) {
987
1436
  if (this.enabledDomains.has(name))
988
1437
  return;
@@ -991,11 +1440,22 @@ class CdpPageDriver {
991
1440
  return;
992
1441
  });
993
1442
  }
1443
+ async historyEntry(direction) {
1444
+ const h = await this.conn.send("Page.getNavigationHistory");
1445
+ const entries = h.entries ?? [];
1446
+ const entry = entries[h.currentIndex + (direction === "back" ? -1 : 1)];
1447
+ if (!entry) {
1448
+ throw driverError("page-error", `navigate: no history entry to go ${direction} to (at entry ${h.currentIndex + 1} of ${entries.length})`);
1449
+ }
1450
+ return entry;
1451
+ }
994
1452
  async navigate(opts) {
995
1453
  await this.ensureDomain("Page");
996
1454
  const reload = opts.reload === true;
997
- if (!reload && !opts.url)
1455
+ const history = opts.history;
1456
+ if (!reload && !history && !opts.url)
998
1457
  throw driverError("page-error", "navigate: 'url' is required unless reload:true");
1458
+ const entry = history ? await this.historyEntry(history) : undefined;
999
1459
  const waitUntil = opts.waitUntil ?? "load";
1000
1460
  const milestoneMethod = waitUntil === "load" ? "Page.loadEventFired" : "Page.domContentEventFired";
1001
1461
  const milestone = this.conn.waitFor(milestoneMethod, undefined, opts.timeoutMs).then(() => waitUntil).catch(() => {
@@ -1013,6 +1473,10 @@ class CdpPageDriver {
1013
1473
  contextId = tree?.frameTree?.frame?.id ?? "";
1014
1474
  url = tree?.frameTree?.frame?.url ?? opts.url ?? "";
1015
1475
  reloaded = true;
1476
+ } else if (entry) {
1477
+ await this.conn.send("Page.navigateToHistoryEntry", { entryId: entry.id }, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {});
1478
+ contextId = "";
1479
+ url = entry.url;
1016
1480
  } else {
1017
1481
  const nav = await this.conn.send("Page.navigate", { url: opts.url }, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {});
1018
1482
  if (nav.errorText)
@@ -1021,7 +1485,20 @@ class CdpPageDriver {
1021
1485
  url = opts.url;
1022
1486
  }
1023
1487
  const settled = await Promise.race([milestone, stopped]);
1024
- return { url, contextId, ...reloaded ? { reloaded } : {}, waitedFor: settled ?? "timeout" };
1488
+ if (entry) {
1489
+ const tree = await this.conn.send("Page.getFrameTree").catch(() => {
1490
+ return;
1491
+ });
1492
+ contextId = tree?.frameTree?.frame?.id ?? contextId;
1493
+ url = tree?.frameTree?.frame?.url ?? url;
1494
+ }
1495
+ return {
1496
+ url,
1497
+ contextId,
1498
+ ...reloaded ? { reloaded } : {},
1499
+ ...history ? { traversed: history } : {},
1500
+ waitedFor: settled ?? "timeout"
1501
+ };
1025
1502
  }
1026
1503
  async waitForText(text, timeoutMs = 15000, pollMs = 250) {
1027
1504
  await this.ensureDomain("Runtime");
@@ -1101,36 +1578,96 @@ class CdpPageDriver {
1101
1578
  async hover(loc) {
1102
1579
  const { objectId } = await resolveElementLocator(this.conn, loc);
1103
1580
  const { x, y } = await centerOf(this.conn, objectId);
1104
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
1581
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
1105
1582
  return { x, y };
1106
1583
  }
1107
1584
  async click(loc, opts) {
1108
1585
  const button = opts?.button ?? "left";
1109
1586
  const clickCount = opts?.clickCount ?? 1;
1587
+ const modifiers = inputModifierBits(opts?.modifiers);
1110
1588
  const { x, y } = await this.hover(loc);
1111
- await this.conn.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount });
1112
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, clickCount });
1589
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount, modifiers });
1590
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, clickCount, modifiers });
1113
1591
  return { x, y };
1114
1592
  }
1115
- async drag(from, to) {
1593
+ async scroll(anchor, opts) {
1594
+ await this.ensureDomain("Runtime");
1595
+ const { x, y } = await this.resolveScrollPoint(anchor);
1596
+ await armScrollSettleWatch(this.conn);
1597
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseWheel", x, y, deltaX: opts.deltaX ?? 0, deltaY: opts.deltaY ?? 0 });
1598
+ await awaitScrollSettle(this.conn);
1599
+ return { x, y };
1600
+ }
1601
+ async resolveScrollPoint(anchor) {
1602
+ if (anchor === undefined) {
1603
+ await this.ensureDomain("Runtime");
1604
+ const { result, exceptionDetails } = await this.conn.send("Runtime.evaluate", { expression: "({x: window.innerWidth / 2, y: window.innerHeight / 2})", returnByValue: true });
1605
+ if (exceptionDetails || !result.value)
1606
+ throw driverError("page-error", `could not measure viewport for scroll center: ${exceptionDetails?.text ?? "no value"}`);
1607
+ return result.value;
1608
+ }
1609
+ if ("x" in anchor)
1610
+ return anchor;
1611
+ const { objectId } = await resolveElementLocator(this.conn, anchor);
1612
+ return centerOf(this.conn, objectId);
1613
+ }
1614
+ async drag(from, to, opts) {
1116
1615
  const fromPt = await this.hover(from);
1117
- const { objectId: dstId } = await resolveElementLocator(this.conn, to);
1118
- const toPt = await centerOf(this.conn, dstId);
1119
- await this.conn.send("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
1120
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: (fromPt.x + toPt.x) / 2, y: (fromPt.y + toPt.y) / 2, button: "left" });
1121
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: toPt.x, y: toPt.y, button: "left" });
1122
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
1616
+ const toPt = await this.resolveDragDestination(to, fromPt);
1617
+ const steps = Math.max(1, Math.trunc(opts?.steps ?? 2));
1618
+ if ((opts?.mode ?? "mouse") === "html5") {
1619
+ await this.html5Drag(fromPt, toPt, steps);
1620
+ return { from: fromPt, to: toPt };
1621
+ }
1622
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
1623
+ for (const pt of interpolatePoints(fromPt, toPt, steps)) {
1624
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x: pt.x, y: pt.y, button: "left" });
1625
+ }
1626
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
1123
1627
  return { from: fromPt, to: toPt };
1124
1628
  }
1629
+ async resolveDragDestination(to, fromPt) {
1630
+ if ("dx" in to)
1631
+ return { x: fromPt.x + to.dx, y: fromPt.y + to.dy };
1632
+ if ("x" in to)
1633
+ return to;
1634
+ const { objectId } = await resolveElementLocator(this.conn, to);
1635
+ return centerOf(this.conn, objectId);
1636
+ }
1637
+ async html5Drag(fromPt, toPt, steps) {
1638
+ await this.dispatchInput("Input.setInterceptDrags", { enabled: true });
1639
+ try {
1640
+ const intercepted = this.conn.waitFor("Input.dragIntercepted", undefined, HTML5_DRAG_INTERCEPT_TIMEOUT_MS).catch(() => {
1641
+ return;
1642
+ });
1643
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
1644
+ for (const pt of interpolatePoints(fromPt, toPt, steps)) {
1645
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x: pt.x, y: pt.y, button: "left" });
1646
+ }
1647
+ const event = await intercepted;
1648
+ if (!event?.data) {
1649
+ 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.');
1650
+ }
1651
+ const data = event.data;
1652
+ for (const type of ["dragEnter", "dragOver", "drop"]) {
1653
+ await this.dispatchInput("Input.dispatchDragEvent", { type, x: toPt.x, y: toPt.y, data });
1654
+ }
1655
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
1656
+ } finally {
1657
+ await this.dispatchInput("Input.setInterceptDrags", { enabled: false }).catch(() => {
1658
+ return;
1659
+ });
1660
+ }
1661
+ }
1125
1662
  async setValue(loc, value) {
1126
1663
  const { objectId } = await resolveElementLocator(this.conn, loc);
1127
- await setValueOnObject(this.conn, objectId, value);
1664
+ await setValueOnObject(this.conn, objectId, value, (method, params) => this.dispatchInput(method, params));
1128
1665
  }
1129
1666
  async typeText(loc, text) {
1130
1667
  const { objectId } = await resolveElementLocator(this.conn, loc);
1131
1668
  await focusElement(this.conn, objectId);
1132
1669
  if (text.length)
1133
- await this.conn.send("Input.insertText", { text });
1670
+ await this.dispatchInput("Input.insertText", { text });
1134
1671
  }
1135
1672
  async pressKey(press) {
1136
1673
  const spec = resolveKey(press.key);
@@ -1145,8 +1682,8 @@ class CdpPageDriver {
1145
1682
  const down = { type: "keyDown", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits };
1146
1683
  if (spec.text && !suppressText)
1147
1684
  down.text = spec.text;
1148
- await this.conn.send("Input.dispatchKeyEvent", down);
1149
- await this.conn.send("Input.dispatchKeyEvent", { type: "keyUp", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits });
1685
+ await this.dispatchInput("Input.dispatchKeyEvent", down);
1686
+ await this.dispatchInput("Input.dispatchKeyEvent", { type: "keyUp", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits });
1150
1687
  }
1151
1688
  async setFiles(loc, files) {
1152
1689
  const { objectId } = await resolveElementLocator(this.conn, loc);
@@ -1362,6 +1899,36 @@ class CdpPageDriver {
1362
1899
  this.conn.close();
1363
1900
  }
1364
1901
  }
1902
+ var beaconSessions = new BeaconSessions((conn) => conn.close());
1903
+ async function targetWsUrl(targetId) {
1904
+ const hit = (await listTargets()).find((t) => t.id === targetId);
1905
+ return hit?.webSocketDebuggerUrl;
1906
+ }
1907
+ async function readBeaconOver(conn) {
1908
+ try {
1909
+ const res = await conn.send("Runtime.evaluate", {
1910
+ expression: BEACON_READ_EXPRESSION,
1911
+ returnByValue: true
1912
+ });
1913
+ const value = res.result?.value;
1914
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
1915
+ } catch {
1916
+ return;
1917
+ }
1918
+ }
1919
+ async function probeOver(conn, timeoutMs) {
1920
+ try {
1921
+ const res = await conn.send("Runtime.evaluate", { expression: RENDERER_PROBE_EXPRESSION, returnByValue: true }, { timeoutMs });
1922
+ const value = res.result?.value;
1923
+ if (Array.isArray(value) && value[0] === 1) {
1924
+ const ts = value[1];
1925
+ return { responsive: true, beaconTs: typeof ts === "number" && Number.isFinite(ts) ? ts : null };
1926
+ }
1927
+ return { responsive: false, beaconTs: null };
1928
+ } catch {
1929
+ return { responsive: false, beaconTs: null };
1930
+ }
1931
+ }
1365
1932
  async function withBrowserConn(fn) {
1366
1933
  const conn = await openBrowser();
1367
1934
  try {
@@ -1400,6 +1967,8 @@ class CdpBrowserDriver {
1400
1967
  async closePage(id) {
1401
1968
  return withBrowserConn(async (conn) => {
1402
1969
  const res = await conn.send("Target.closeTarget", { targetId: id });
1970
+ if (res.success !== false)
1971
+ beaconSessions.drop(id);
1403
1972
  return { success: res.success ?? true };
1404
1973
  });
1405
1974
  }
@@ -1408,119 +1977,94 @@ class CdpBrowserDriver {
1408
1977
  await withBrowserConn((conn) => conn.send("Target.activateTarget", { targetId: target.id }));
1409
1978
  return { id: target.id, url: target.url, title: target.title };
1410
1979
  }
1980
+ async resolveWorkerTarget(selector, opts) {
1981
+ const target = await resolveWorkerSelector(selector, opts).catch(noSuchTarget);
1982
+ return { id: target.id, url: target.url, title: target.title, type: target.type };
1983
+ }
1411
1984
  async page(selector) {
1412
1985
  const { conn, target } = await openPage(selector).catch(noSuchTarget);
1413
1986
  return new CdpPageDriver(conn, target, this);
1414
1987
  }
1415
- async dispose() {}
1416
- }
1417
- function createCdpDriver() {
1418
- return new CdpBrowserDriver;
1419
- }
1420
-
1421
- // src/origins.ts
1422
- import { mkdir as mkdir2, readFile as readFile2, readdir as readdir2, unlink as unlink2, writeFile as writeFile2 } from "node:fs/promises";
1423
- import { join as join2 } from "node:path";
1424
- function safeId2(id) {
1425
- return id.replace(/[^A-Za-z0-9._-]/g, "_");
1426
- }
1427
- function originFile(backend, targetId) {
1428
- return join2(leaseDir(), `origin-${backend}-${safeId2(targetId)}.json`);
1429
- }
1430
- var ORIGIN_FILE_RE = /^origin-(chrome|firefox)-(.+)\.json$/;
1431
- async function recordOrigin(backend, targetId, opts = {}) {
1432
- const record = {
1433
- backend,
1434
- targetId,
1435
- label: typeof opts.label === "string" && opts.label.length ? opts.label : defaultLabel(),
1436
- pid: process.pid,
1437
- createdAt: opts.now ?? Date.now()
1438
- };
1439
- const file = originFile(backend, targetId);
1440
- try {
1441
- await mkdir2(leaseDir(), { recursive: true });
1442
- try {
1443
- await writeFile2(file, JSON.stringify(record), { flag: "wx" });
1444
- return;
1445
- } catch (err) {
1446
- if (err.code !== "EEXIST")
1447
- throw err;
1988
+ async installActivityBeacon(targetId) {
1989
+ const held = beaconSessions.get(targetId);
1990
+ if (held) {
1991
+ const rearmed = await held.send("Runtime.evaluate", { expression: BEACON_SOURCE, returnByValue: true }).then(() => true).catch(() => false);
1992
+ if (rearmed)
1993
+ return true;
1994
+ beaconSessions.drop(targetId);
1448
1995
  }
1449
- await unlink2(file).catch(() => {
1996
+ const wsUrl = await targetWsUrl(targetId).catch(() => {
1450
1997
  return;
1451
1998
  });
1452
- await writeFile2(file, JSON.stringify(record), { flag: "wx" });
1453
- } catch {}
1454
- }
1455
- async function readOrigin(backend, targetId) {
1456
- let raw;
1457
- try {
1458
- raw = await readFile2(originFile(backend, targetId), "utf8");
1459
- } catch {
1460
- return;
1461
- }
1462
- try {
1463
- const rec = JSON.parse(raw);
1464
- if (typeof rec?.targetId === "string" && typeof rec?.label === "string" && typeof rec?.createdAt === "number")
1465
- return rec;
1466
- } catch {}
1467
- return;
1468
- }
1469
- async function listOrigins(opts = {}) {
1470
- let names = [];
1471
- try {
1472
- names = await readdir2(leaseDir());
1473
- } catch {
1474
- return [];
1999
+ if (!wsUrl)
2000
+ return false;
2001
+ const conn = await new CdpConnection(wsUrl).connect().catch(() => {
2002
+ return;
2003
+ });
2004
+ if (!conn)
2005
+ return false;
2006
+ try {
2007
+ await conn.send("Page.enable");
2008
+ await conn.send("Page.addScriptToEvaluateOnNewDocument", { source: BEACON_SOURCE });
2009
+ await conn.send("Runtime.evaluate", { expression: BEACON_SOURCE, returnByValue: true });
2010
+ } catch {
2011
+ conn.close();
2012
+ return false;
2013
+ }
2014
+ beaconSessions.set(targetId, conn);
2015
+ return true;
1475
2016
  }
1476
- const live = opts.liveIds === undefined ? undefined : new Set(opts.liveIds.map((id) => safeId2(id)));
1477
- const out = [];
1478
- for (const name of names) {
1479
- const parts = ORIGIN_FILE_RE.exec(name);
1480
- if (!parts)
1481
- continue;
1482
- const backend = parts[1];
1483
- const nameId = parts[2];
1484
- const scoped = live !== undefined && (opts.liveBackend === undefined || opts.liveBackend === backend);
1485
- if (scoped && !live.has(nameId)) {
1486
- await unlink2(join2(leaseDir(), name)).catch(() => {
1487
- return;
1488
- });
1489
- continue;
2017
+ async readActivityBeacon(targetId) {
2018
+ const held = beaconSessions.get(targetId);
2019
+ if (held) {
2020
+ const value = await readBeaconOver(held);
2021
+ if (value !== undefined)
2022
+ return value;
2023
+ beaconSessions.drop(targetId);
1490
2024
  }
1491
- const fromName = { backend, targetId: nameId, label: "", pid: 0, createdAt: 0 };
1492
- let rec;
2025
+ const wsUrl = await targetWsUrl(targetId).catch(() => {
2026
+ return;
2027
+ });
2028
+ if (!wsUrl)
2029
+ return null;
2030
+ const conn = await new CdpConnection(wsUrl).connect().catch(() => {
2031
+ return;
2032
+ });
2033
+ if (!conn)
2034
+ return null;
1493
2035
  try {
1494
- rec = JSON.parse(await readFile2(join2(leaseDir(), name), "utf8"));
1495
- } catch (err) {
1496
- const code = err.code;
1497
- if (code === "ENOENT")
1498
- continue;
1499
- out.push({ ...fromName, unreadable: code ?? "unparseable" });
1500
- continue;
2036
+ return await readBeaconOver(conn) ?? null;
2037
+ } finally {
2038
+ conn.close();
1501
2039
  }
1502
- if (typeof rec?.targetId !== "string" || typeof rec?.label !== "string" || typeof rec?.createdAt !== "number") {
1503
- out.push({ ...fromName, unreadable: "unparseable" });
1504
- continue;
2040
+ }
2041
+ async probeRenderer(targetId, timeoutMs = RENDERER_PROBE_TIMEOUT_MS) {
2042
+ const held = beaconSessions.get(targetId);
2043
+ if (held)
2044
+ return probeOver(held, timeoutMs);
2045
+ const wsUrl = await targetWsUrl(targetId).catch(() => {
2046
+ return;
2047
+ });
2048
+ if (!wsUrl)
2049
+ return { responsive: false, beaconTs: null };
2050
+ const conn = new CdpConnection(wsUrl, { timeoutMs });
2051
+ try {
2052
+ await conn.connect();
2053
+ } catch {
2054
+ return { responsive: false, beaconTs: null };
2055
+ }
2056
+ try {
2057
+ return await probeOver(conn, timeoutMs);
2058
+ } finally {
2059
+ conn.close();
1505
2060
  }
1506
- out.push({ backend: rec.backend, targetId: rec.targetId, label: rec.label, pid: rec.pid, createdAt: rec.createdAt });
1507
2061
  }
1508
- return out;
1509
- }
1510
- async function originIndex(backend, liveIds) {
1511
- const rows = await listOrigins({ liveIds, liveBackend: backend }).catch(() => []);
1512
- const index = new Map;
1513
- for (const row of rows) {
1514
- if (row.backend !== backend)
1515
- continue;
1516
- index.set(row.targetId, row);
2062
+ async dispose() {
2063
+ beaconSessions.clear();
1517
2064
  }
1518
- return index;
1519
2065
  }
1520
- async function newTrackedPage(driver, backend, opts = {}) {
1521
- const page = await driver.newPage(opts.url);
1522
- await recordOrigin(backend, page.id, { label: opts.label });
1523
- return page;
2066
+ function createCdpDriver() {
2067
+ return new CdpBrowserDriver;
1524
2068
  }
1525
2069
 
1526
2070
  // src/reap.ts
@@ -1534,6 +2078,8 @@ function staleAgentTabs(input) {
1534
2078
  continue;
1535
2079
  if (row.stale !== "dead-pid" && row.stale !== "expired")
1536
2080
  continue;
2081
+ if (row.stale === "expired" && input.now - row.lastUsedAt <= row.ttlMs + input.reapGraceMs)
2082
+ continue;
1537
2083
  if (!live.has(row.targetId))
1538
2084
  continue;
1539
2085
  const origin = input.origins.get(row.targetId);
@@ -1543,13 +2089,19 @@ function staleAgentTabs(input) {
1543
2089
  }
1544
2090
  return out;
1545
2091
  }
2092
+ var DEFAULT_REAP_GRACE_MS = 2700000;
2093
+ function reapGraceMs() {
2094
+ const raw = Number(process.env.CDP_REAP_GRACE_MS);
2095
+ return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_REAP_GRACE_MS;
2096
+ }
1546
2097
  async function reapStaleAgentTabs(driver, backend) {
2098
+ const now = Date.now();
1547
2099
  const livePageIds = (await driver.listPages()).map((p) => p.id);
1548
2100
  const [origins, leases] = await Promise.all([
1549
2101
  originIndex(backend, undefined),
1550
- listLeases({ liveIds: livePageIds, liveBackend: backend }).catch(() => [])
2102
+ listLeases({ now, liveIds: livePageIds, liveBackend: backend }).catch(() => [])
1551
2103
  ]);
1552
- const candidates = staleAgentTabs({ backend, livePageIds, origins, leases });
2104
+ const candidates = staleAgentTabs({ backend, livePageIds, origins, leases, now, reapGraceMs: reapGraceMs() });
1553
2105
  const closed = [];
1554
2106
  for (const c of candidates) {
1555
2107
  const res = await driver.closePage(c.targetId).catch(() => ({ success: false }));
@@ -1615,6 +2167,11 @@ async function pickPage(driver, pages, selector) {
1615
2167
  throw new SharedToolError(`no page title containing '${needle}'`);
1616
2168
  return p;
1617
2169
  }
2170
+ if (selector.startsWith("label:"))
2171
+ return pickByLabel(driver, pages, selector.slice(6));
2172
+ if (isWorkerSelector(selector)) {
2173
+ throw new SharedToolError(backendOf(driver) === "firefox" ? WORKER_SELECTOR_UNSUPPORTED_MESSAGE : WORKER_SELECTOR_PAGE_ONLY_MESSAGE);
2174
+ }
1618
2175
  const exactPage = pages.find((x) => x.id === selector);
1619
2176
  if (exactPage)
1620
2177
  return exactPage;
@@ -1624,6 +2181,18 @@ async function pickPage(driver, pages, selector) {
1624
2181
  return exact;
1625
2182
  throw new SharedToolError(`no target with id '${selector}'`);
1626
2183
  }
2184
+ async function pickByLabel(driver, pages, label) {
2185
+ const { matchIds, knownLabels } = await resolveLiveLabel(backendOf(driver), label, pages.map((x) => x.id));
2186
+ if (matchIds.length > 1) {
2187
+ throw new SharedToolError(`label 'label:${label}' matches more than one live target: ${matchIds.join(", ")}. Resolve by target id instead.`);
2188
+ }
2189
+ if (matchIds.length === 1) {
2190
+ const p = pages.find((x) => x.id === matchIds[0]);
2191
+ if (p)
2192
+ return p;
2193
+ }
2194
+ throw new SharedToolError(knownLabels.length ? `no live target with label '${label}' (labels currently in use: ${knownLabels.join(", ")})` : `no live target with label '${label}' (no labels are currently assigned to any open target)`);
2195
+ }
1627
2196
  function locatorOf(args) {
1628
2197
  const hasUid = args.uid !== undefined && args.uid !== null && args.uid !== "";
1629
2198
  const hasSelector = typeof args.selector === "string" && args.selector.length > 0;
@@ -1641,18 +2210,29 @@ async function reapSet(driver, pages, all) {
1641
2210
  }
1642
2211
  }
1643
2212
  async function listPages(driver, args = {}) {
1644
- const reaped = requireLease() ? await reapStaleAgentTabs(driver, backendOf(driver)) : [];
2213
+ const backend = backendOf(driver);
2214
+ const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
1645
2215
  const reapedIds = new Set(reaped.map((r) => r.targetId));
1646
2216
  const pages = (await driver.listPages({ all: args.all })).filter((p) => !reapedIds.has(p.id));
1647
- const ledger = await originIndex(backendOf(driver), await reapSet(driver, pages, args.all));
1648
- const annotated = pages.map((p) => {
2217
+ const ledger = await originIndex(backend, await reapSet(driver, pages, args.all));
2218
+ const now = Date.now();
2219
+ 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]));
2220
+ const doProbe = args.probe === true && rendererProbeSupported(driver);
2221
+ const annotated = await Promise.all(pages.map(async (p) => {
1649
2222
  const rec = ledger.get(p.id);
1650
- if (!rec)
1651
- return { ...p, origin: "unknown" };
1652
- if (rec.unreadable !== undefined)
1653
- return { ...p, origin: "unknown", originUnreadable: rec.unreadable };
1654
- return { ...p, origin: "agent", label: rec.label, createdAt: rec.createdAt };
1655
- });
2223
+ const base = !rec ? { ...p, origin: "unknown" } : rec.unreadable !== undefined ? { ...p, origin: "unknown", originUnreadable: rec.unreadable } : { ...p, origin: "agent", label: rec.label, createdAt: rec.createdAt };
2224
+ const leaseRow = leaseByTarget.get(p.id);
2225
+ if (leaseRow) {
2226
+ base.lease = { label: leaseRow.label, pid: leaseRow.pid, idleMs: now - leaseRow.lastUsedAt, expiresAt: leaseRow.lastUsedAt + leaseRow.ttlMs, stale: leaseRow.stale };
2227
+ }
2228
+ if (doProbe && (p.type === undefined || p.type === "page")) {
2229
+ const probe = await probeRendererActivity(driver, backend, p.id, now);
2230
+ base.responsive = probe.responsive;
2231
+ if (probe.humanActiveMs !== undefined)
2232
+ base.humanActiveMs = probe.humanActiveMs;
2233
+ }
2234
+ return base;
2235
+ }));
1656
2236
  return { pages: annotated, count: annotated.length, ...reaped.length ? { reaped } : {} };
1657
2237
  }
1658
2238
  async function newPage(driver, args = {}) {
@@ -1666,6 +2246,7 @@ async function newPage(driver, args = {}) {
1666
2246
  ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
1667
2247
  auto: !explicit
1668
2248
  });
2249
+ await installBeacon(driver, p.id);
1669
2250
  return { targetId: p.id, url: p.url, lease: token, label: record.label, expiresAt: record.lastUsedAt + record.ttlMs };
1670
2251
  }
1671
2252
  async function closePage(driver, args) {
@@ -1697,17 +2278,36 @@ function toLegacyWaitedFor(w) {
1697
2278
  return "navigate-only";
1698
2279
  return w;
1699
2280
  }
1700
- async function navigatePage(driver, args) {
2281
+ function resolveNavigateMode(args) {
1701
2282
  const reload = args.reload === true;
1702
- if (!reload && (!args.url || typeof args.url !== "string")) {
1703
- throw new SharedToolError("navigate_page: 'url' is required (or pass reload:true to reload the current page)");
2283
+ const hasUrl = typeof args.url === "string" && args.url.length > 0;
2284
+ const hasHistory = args.history !== undefined && args.history !== null;
2285
+ const given = [hasUrl ? "url" : "", reload ? "reload" : "", hasHistory ? "history" : ""].filter(Boolean);
2286
+ if (given.length > 1) {
2287
+ throw new SharedToolError(`navigate_page: 'url', 'reload' and 'history' are mutually exclusive (got ${given.join(" + ")})`);
2288
+ }
2289
+ if (hasHistory) {
2290
+ if (args.history !== "back" && args.history !== "forward") {
2291
+ throw new SharedToolError(`navigate_page: 'history' must be 'back' or 'forward' (got ${JSON.stringify(args.history)})`);
2292
+ }
2293
+ return { mode: "history", history: args.history };
2294
+ }
2295
+ if (reload)
2296
+ return { mode: "reload" };
2297
+ if (!hasUrl) {
2298
+ 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)");
1704
2299
  }
2300
+ return { mode: "url", url: args.url };
2301
+ }
2302
+ async function navigatePage(driver, args) {
2303
+ const mode = resolveNavigateMode(args);
1705
2304
  return withPage2(driver, args.target, async (page) => {
1706
- const r = await page.navigate(args);
2305
+ const r = await page.navigate(mode.mode === "history" ? { ...args, history: mode.history } : args);
1707
2306
  return {
1708
2307
  url: r.url,
1709
2308
  frameId: r.contextId,
1710
2309
  ...r.reloaded ? { reloaded: r.reloaded } : {},
2310
+ ...r.traversed ? { traversed: r.traversed } : {},
1711
2311
  waitedFor: toLegacyWaitedFor(r.waitedFor)
1712
2312
  };
1713
2313
  });
@@ -1734,11 +2334,29 @@ async function writeJsonSink(savePath, value) {
1734
2334
  function sinkRequested(savePath) {
1735
2335
  return savePath !== undefined && savePath !== "";
1736
2336
  }
2337
+ var EXPRESSION_ALIAS_KEYS = ["function", "code", "js", "script", "fn", "body"];
2338
+ async function resolveWorkerSelectorFor(driver, selector, wake) {
2339
+ if (!driver.capabilities.has("worker.targets") || typeof driver.resolveWorkerTarget !== "function") {
2340
+ throw new SharedToolError(`evaluateScript: ${WORKER_SELECTOR_UNSUPPORTED_MESSAGE}`);
2341
+ }
2342
+ return driver.resolveWorkerTarget(selector, { wake });
2343
+ }
1737
2344
  async function evaluateScript(driver, args) {
1738
2345
  if (typeof args.expression !== "string" || args.expression.length === 0) {
2346
+ const wrongKey = EXPRESSION_ALIAS_KEYS.find((k) => args[k] !== undefined);
2347
+ if (wrongKey !== undefined) {
2348
+ throw new SharedToolError(`evaluateScript: you passed '${wrongKey}'; the key is 'expression' — function literals go in 'expression', invoked with 'args'.`);
2349
+ }
1739
2350
  throw new SharedToolError("evaluateScript: 'expression' must be a non-empty string");
1740
2351
  }
1741
- return withPage2(driver, args.target, async (page) => {
2352
+ if (args.wake !== undefined && typeof args.wake !== "boolean") {
2353
+ throw new SharedToolError("evaluateScript: 'wake' must be a boolean");
2354
+ }
2355
+ if (args.wake !== undefined && !isWorkerSelector(args.target)) {
2356
+ throw new SharedToolError("evaluateScript: 'wake' only applies to a target of the form 'worker:<substring>'; a page target is never asleep.");
2357
+ }
2358
+ const target = isWorkerSelector(args.target) ? (await resolveWorkerSelectorFor(driver, args.target, args.wake ?? true)).id : args.target;
2359
+ return withPage2(driver, target, async (page) => {
1742
2360
  const value = await page.evaluate(args.expression, { args: args.args, awaitPromise: args.awaitPromise ?? true });
1743
2361
  if (!sinkRequested(args.savePath))
1744
2362
  return value;
@@ -1853,22 +2471,113 @@ async function takeSnapshot(driver, args = {}) {
1853
2471
  }
1854
2472
  async function click(driver, args) {
1855
2473
  return withPage2(driver, args.target, async (page) => {
1856
- const { x, y } = await page.click(locatorOf(args), { button: args.button ?? "left", clickCount: args.clickCount ?? 1 });
2474
+ const { x, y } = await page.click(locatorOf(args), { button: args.button ?? "left", clickCount: args.clickCount ?? 1, modifiers: args.modifiers });
1857
2475
  return { clicked: true, x, y };
1858
2476
  });
1859
2477
  }
2478
+ function resolveScrollAnchor(args) {
2479
+ const hasUid = args.uid !== undefined && args.uid !== null && args.uid !== "";
2480
+ const hasSelector = typeof args.selector === "string" && args.selector.length > 0;
2481
+ const hasX = args.x !== undefined;
2482
+ const hasY = args.y !== undefined;
2483
+ if (hasX !== hasY)
2484
+ throw new SharedToolError("scroll: 'x' and 'y' must be provided together");
2485
+ const hasXY = hasX && hasY;
2486
+ const anchorCount = [hasUid, hasSelector, hasXY].filter(Boolean).length;
2487
+ if (anchorCount > 1)
2488
+ throw new SharedToolError("scroll: provide at most one of { uid }, { selector }, or { x, y } (omit all three to scroll at the viewport center)");
2489
+ if (hasUid || hasSelector)
2490
+ return locatorOf({ uid: args.uid, selector: args.selector });
2491
+ if (hasXY)
2492
+ return { x: args.x, y: args.y };
2493
+ return;
2494
+ }
2495
+ function resolveScrollDelta(args) {
2496
+ const deltaX = args.deltaX ?? 0;
2497
+ const deltaY = args.deltaY ?? 0;
2498
+ if (deltaX === 0 && deltaY === 0)
2499
+ throw new SharedToolError("scroll requires at least one of { deltaX } or { deltaY }");
2500
+ return { deltaX, deltaY };
2501
+ }
2502
+ async function scroll(driver, args) {
2503
+ const anchor = resolveScrollAnchor(args);
2504
+ const { deltaX, deltaY } = resolveScrollDelta(args);
2505
+ return withPage2(driver, args.target, async (page) => {
2506
+ const { x, y } = await page.scroll(anchor, { deltaX, deltaY });
2507
+ return { x, y, deltaX, deltaY, target: target3(page.info) };
2508
+ });
2509
+ }
1860
2510
  async function hover(driver, args) {
1861
2511
  return withPage2(driver, args.target, async (page) => {
1862
2512
  const { x, y } = await page.hover(locatorOf(args));
1863
2513
  return { hovered: true, x, y };
1864
2514
  });
1865
2515
  }
2516
+ function resolveDragTo(to) {
2517
+ const hasUid = to.uid !== undefined && to.uid !== null && to.uid !== "";
2518
+ const hasSelector = typeof to.selector === "string" && to.selector.length > 0;
2519
+ const hasX = to.x !== undefined;
2520
+ const hasY = to.y !== undefined;
2521
+ if (hasX !== hasY)
2522
+ throw new SharedToolError("drag: 'to.x' and 'to.y' must be provided together");
2523
+ const hasXY = hasX && hasY;
2524
+ const count = [hasUid, hasSelector, hasXY].filter(Boolean).length;
2525
+ if (count !== 1)
2526
+ throw new SharedToolError("drag: 'to' takes exactly one of { uid }, { selector }, or { x, y }");
2527
+ if (hasXY) {
2528
+ if (!Number.isFinite(to.x) || !Number.isFinite(to.y))
2529
+ throw new SharedToolError("drag: 'to.x' and 'to.y' must be finite numbers");
2530
+ return { x: to.x, y: to.y };
2531
+ }
2532
+ return locatorOf({ uid: to.uid, selector: to.selector });
2533
+ }
2534
+ function resolveDragDestination(args) {
2535
+ const hasTo = args.to !== undefined && args.to !== null;
2536
+ const hasBy = args.by !== undefined && args.by !== null;
2537
+ if (hasTo === hasBy)
2538
+ throw new SharedToolError("drag requires exactly one of { to } or { by }");
2539
+ if (hasBy) {
2540
+ const by = args.by;
2541
+ const hasDx = by.dx !== undefined;
2542
+ const hasDy = by.dy !== undefined;
2543
+ if (!hasDx && !hasDy)
2544
+ throw new SharedToolError("drag: 'by' requires at least one of { dx } or { dy }");
2545
+ const dx = by.dx ?? 0;
2546
+ const dy = by.dy ?? 0;
2547
+ if (!Number.isFinite(dx) || !Number.isFinite(dy))
2548
+ throw new SharedToolError("drag: 'by.dx' and 'by.dy' must be finite numbers");
2549
+ return { dx, dy };
2550
+ }
2551
+ return resolveDragTo(args.to);
2552
+ }
2553
+ var MAX_DRAG_STEPS = 500;
2554
+ function resolveDragSteps(steps) {
2555
+ if (steps === undefined)
2556
+ return 2;
2557
+ if (typeof steps !== "number" || !Number.isInteger(steps) || steps < 1 || steps > MAX_DRAG_STEPS) {
2558
+ throw new SharedToolError(`drag: 'steps' must be an integer between 1 and ${MAX_DRAG_STEPS} (default 2)`);
2559
+ }
2560
+ return steps;
2561
+ }
2562
+ function resolveDragMode(mode, capabilities) {
2563
+ if (mode === undefined || mode === "mouse")
2564
+ return "mouse";
2565
+ if (mode !== "html5")
2566
+ throw new SharedToolError(`drag: unknown mode '${mode}' (use 'mouse' or 'html5')`);
2567
+ if (!capabilities.has("input.html5Drag")) {
2568
+ 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.");
2569
+ }
2570
+ return "html5";
2571
+ }
1866
2572
  async function drag(driver, args) {
1867
- if (!args.from || !args.to)
1868
- throw new SharedToolError("drag requires { from } and { to }");
2573
+ if (!args.from)
2574
+ throw new SharedToolError("drag requires { from }");
2575
+ const destination = resolveDragDestination(args);
2576
+ const steps = resolveDragSteps(args.steps);
2577
+ const mode = resolveDragMode(args.mode, driver.capabilities);
1869
2578
  return withPage2(driver, args.target, async (page) => {
1870
- const { from, to } = await page.drag(locatorOf(args.from), locatorOf(args.to));
1871
- return { dragged: true, from, to };
2579
+ const { from, to } = await page.drag(locatorOf(args.from), destination, { mode, steps });
2580
+ return { dragged: true, mode, steps, from, to };
1872
2581
  });
1873
2582
  }
1874
2583
  async function fill(driver, args) {
@@ -1989,6 +2698,7 @@ var SHARED_TOOLS = {
1989
2698
  click,
1990
2699
  hover,
1991
2700
  drag,
2701
+ scroll,
1992
2702
  fill,
1993
2703
  fill_form: fillForm,
1994
2704
  type_text: typeText,
@@ -2010,7 +2720,7 @@ async function claimPage(driver, args = {}) {
2010
2720
  const hasTarget = typeof args.target === "string" && args.target.length > 0;
2011
2721
  const hasTargetId = typeof args.targetId === "string" && args.targetId.length > 0;
2012
2722
  if (hasTarget && hasTargetId) {
2013
- 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.");
2723
+ throw new LeaseToolError("claim_page takes at most one of 'target' (any selector: active | index:N | url:<substr> | title:<substr> | label:<name> | <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.");
2014
2724
  }
2015
2725
  const backend = backendOf2(driver);
2016
2726
  const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
@@ -2038,12 +2748,15 @@ async function claimPage(driver, args = {}) {
2038
2748
  url = page.url;
2039
2749
  opened = true;
2040
2750
  }
2751
+ const humanMs = opened ? null : await readHumanActiveMs(driver, backend, targetId);
2041
2752
  const liveIds = [...pages.map((p) => p.id), targetId];
2042
2753
  const { record, token } = await claimLease(backend, targetId, {
2043
2754
  label,
2044
2755
  ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
2045
2756
  liveIds
2046
2757
  });
2758
+ await installBeacon(driver, targetId);
2759
+ const contention = contentionWarning(humanMs, { takeover: !opened });
2047
2760
  return {
2048
2761
  lease: token,
2049
2762
  targetId,
@@ -2051,7 +2764,9 @@ async function claimPage(driver, args = {}) {
2051
2764
  label: record.label,
2052
2765
  ttlMs: record.ttlMs,
2053
2766
  expiresAt: record.lastUsedAt + record.ttlMs,
2054
- opened
2767
+ opened,
2768
+ ...beaconSupported(driver) ? { humanActiveMs: humanMs } : {},
2769
+ ...contention !== undefined ? { contention } : {}
2055
2770
  };
2056
2771
  }
2057
2772
  async function releasePage(driver, args = {}) {
@@ -2086,9 +2801,22 @@ async function listLeasesTool(driver, _args = {}) {
2086
2801
  const backend = backendOf2(driver);
2087
2802
  const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
2088
2803
  const reapedIds = new Set(reaped.map((r) => r.targetId));
2089
- const liveIds = (await driver.listPages()).map((p) => p.id);
2804
+ const livePages = await driver.listPages();
2805
+ const liveIds = livePages.map((p) => p.id);
2090
2806
  const all = await listLeases({ liveIds, liveBackend: backend });
2091
- const leases = all.filter((l) => !(l.backend === backend && reapedIds.has(l.targetId)));
2807
+ const kept = all.filter((l) => !(l.backend === backend && reapedIds.has(l.targetId)));
2808
+ const liveSet = new Set(liveIds);
2809
+ const now = Date.now();
2810
+ const leases = [];
2811
+ for (const row of kept) {
2812
+ const computed = row.unreadable !== undefined ? row : { ...row, idleMs: now - row.lastUsedAt, expiresAt: row.lastUsedAt + row.ttlMs };
2813
+ if (row.backend !== backend || row.unreadable !== undefined || !liveSet.has(row.targetId)) {
2814
+ leases.push(computed);
2815
+ continue;
2816
+ }
2817
+ const humanMs = await readHumanActiveMs(driver, backend, row.targetId, now).catch(() => null);
2818
+ leases.push(humanMs === null ? computed : { ...computed, humanActiveMs: humanMs });
2819
+ }
2092
2820
  return { leases, count: leases.length, ...reaped.length ? { reaped } : {} };
2093
2821
  }
2094
2822
  var LEASE_TOOLS = {
@@ -3244,6 +3972,273 @@ function truncate(s, max = 600) {
3244
3972
  return t.length > max ? `${t.slice(0, max)}…` : t;
3245
3973
  }
3246
3974
 
3975
+ // src/tools/dispatch-mouse.ts
3976
+ var CLICK_MODIFIER_BITS2 = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
3977
+ var BUTTONS = new Set(["left", "right", "middle"]);
3978
+ var CDP_EVENT_TYPE = {
3979
+ move: "mouseMoved",
3980
+ down: "mousePressed",
3981
+ up: "mouseReleased"
3982
+ };
3983
+ function modifierBits(mods) {
3984
+ let bits = 0;
3985
+ for (const m of mods ?? []) {
3986
+ const bit = CLICK_MODIFIER_BITS2[m];
3987
+ if (bit === undefined)
3988
+ throw new Error(`dispatch_mouse: unknown modifier '${m}' (use Alt, Control, Meta, or Shift)`);
3989
+ bits |= bit;
3990
+ }
3991
+ return bits;
3992
+ }
3993
+ function validateDispatchMouseArgs(args) {
3994
+ if (args.action !== "move" && args.action !== "down" && args.action !== "up") {
3995
+ throw new Error("dispatch_mouse requires { action } of 'move', 'down', or 'up'");
3996
+ }
3997
+ if (typeof args.x !== "number" || typeof args.y !== "number" || !Number.isFinite(args.x) || !Number.isFinite(args.y)) {
3998
+ throw new Error("dispatch_mouse requires numeric { x } and { y } on every call");
3999
+ }
4000
+ const button = args.button ?? "left";
4001
+ if (!BUTTONS.has(button))
4002
+ throw new Error(`dispatch_mouse: unknown button '${button}' (use left, right, or middle)`);
4003
+ }
4004
+ async function dispatchMouse(args) {
4005
+ validateDispatchMouseArgs(args);
4006
+ const button = args.button ?? "left";
4007
+ const modifiers = modifierBits(args.modifiers);
4008
+ return withPage(args.target, async (conn, target) => {
4009
+ const params = { type: CDP_EVENT_TYPE[args.action], x: args.x, y: args.y, button, modifiers };
4010
+ if (args.action !== "move")
4011
+ params.clickCount = args.clickCount ?? 1;
4012
+ await sendInput(conn, "chrome", target.id, "Input.dispatchMouseEvent", params);
4013
+ return { dispatched: args.action, x: args.x, y: args.y };
4014
+ });
4015
+ }
4016
+
4017
+ // src/tools/downloads.ts
4018
+ import { readdir as readdir3, rename, stat as stat4 } from "node:fs/promises";
4019
+ import { basename, extname, join as join8 } from "node:path";
4020
+
4021
+ // src/tools/browser-session.ts
4022
+ import { mkdir as mkdir9 } from "node:fs/promises";
4023
+ import { join as join7 } from "node:path";
4024
+ var ARTIFACT_DIR7 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
4025
+ var DOWNLOADS_DIR = join7(ARTIFACT_DIR7, "downloads");
4026
+ var DOWNLOAD_BUFFER_LIMIT = 50;
4027
+ var session;
4028
+ function isDisconnect(e) {
4029
+ const m = e instanceof Error ? e.message : String(e);
4030
+ return /connection (not open|closed)|closed by caller|websocket|ECONNREFUSED|socket/i.test(m);
4031
+ }
4032
+ function record(s, guid) {
4033
+ let r = s.downloads.find((d) => d.guid === guid);
4034
+ if (!r) {
4035
+ r = { guid, state: "inProgress", consumed: false };
4036
+ s.downloads.push(r);
4037
+ while (s.downloads.length > DOWNLOAD_BUFFER_LIMIT) {
4038
+ const i = s.downloads.findIndex((d) => d.consumed);
4039
+ s.downloads.splice(i >= 0 ? i : 0, 1);
4040
+ }
4041
+ }
4042
+ return r;
4043
+ }
4044
+ function subscribe(s) {
4045
+ s.conn.on("Browser.downloadWillBegin", (p) => {
4046
+ const r = record(s, String(p.guid));
4047
+ if (typeof p.url === "string")
4048
+ r.url = p.url;
4049
+ if (typeof p.suggestedFilename === "string")
4050
+ r.suggestedFilename = p.suggestedFilename;
4051
+ });
4052
+ s.conn.on("Browser.downloadProgress", (p) => {
4053
+ const r = record(s, String(p.guid));
4054
+ if (typeof p.receivedBytes === "number")
4055
+ r.bytes = p.receivedBytes;
4056
+ if (typeof p.filePath === "string")
4057
+ r.filePath = p.filePath;
4058
+ const state = String(p.state);
4059
+ if (state === "completed" || state === "canceled") {
4060
+ r.state = state;
4061
+ if (state === "completed") {
4062
+ const waiter = s.waiters.shift();
4063
+ if (waiter) {
4064
+ r.consumed = true;
4065
+ waiter(r);
4066
+ }
4067
+ }
4068
+ }
4069
+ });
4070
+ }
4071
+ async function getBrowserSession() {
4072
+ if (session && !session.dead)
4073
+ return session;
4074
+ const previousPath = session?.armedPath;
4075
+ const conn = await openBrowser();
4076
+ const s = { conn, downloads: [], waiters: [], dead: false };
4077
+ subscribe(s);
4078
+ session = s;
4079
+ if (previousPath)
4080
+ await armDownloadsAt(previousPath);
4081
+ return s;
4082
+ }
4083
+ async function browserSend(method, params = {}) {
4084
+ const s = await getBrowserSession();
4085
+ try {
4086
+ return await s.conn.send(method, params);
4087
+ } catch (e) {
4088
+ if (isDisconnect(e)) {
4089
+ s.dead = true;
4090
+ const fresh = await getBrowserSession();
4091
+ return await fresh.conn.send(method, params);
4092
+ }
4093
+ throw e;
4094
+ }
4095
+ }
4096
+ async function armDownloadsAt(path) {
4097
+ await mkdir9(path, { recursive: true });
4098
+ const s = await getBrowserSession();
4099
+ await s.conn.send("Browser.setDownloadBehavior", { behavior: "allowAndName", downloadPath: path, eventsEnabled: true });
4100
+ s.armedPath = path;
4101
+ }
4102
+ async function armDownloads() {
4103
+ const s = await getBrowserSession();
4104
+ if (s.armedPath !== DOWNLOADS_DIR)
4105
+ await armDownloadsAt(DOWNLOADS_DIR);
4106
+ return { downloadPath: DOWNLOADS_DIR };
4107
+ }
4108
+ function pendingDownloads() {
4109
+ return (session?.downloads ?? []).filter((d) => d.state === "completed" && !d.consumed);
4110
+ }
4111
+ async function nextCompletedDownload(timeoutMs) {
4112
+ const s = await getBrowserSession();
4113
+ const buffered = s.downloads.find((d) => d.state === "completed" && !d.consumed);
4114
+ if (buffered) {
4115
+ buffered.consumed = true;
4116
+ return buffered;
4117
+ }
4118
+ return new Promise((resolve, reject) => {
4119
+ const waiter = (r) => {
4120
+ clearTimeout(timer);
4121
+ resolve(r);
4122
+ };
4123
+ const timer = setTimeout(() => {
4124
+ const i = s.waiters.indexOf(waiter);
4125
+ if (i >= 0)
4126
+ s.waiters.splice(i, 1);
4127
+ 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).`));
4128
+ }, timeoutMs);
4129
+ s.waiters.push(waiter);
4130
+ });
4131
+ }
4132
+ async function disposeBrowserSession() {
4133
+ const s = session;
4134
+ session = undefined;
4135
+ if (!s)
4136
+ return;
4137
+ s.dead = true;
4138
+ try {
4139
+ s.conn.close();
4140
+ } catch {}
4141
+ }
4142
+
4143
+ // src/tools/downloads.ts
4144
+ function safeFilename(suggested) {
4145
+ const raw = (suggested ?? "").replace(/[\u0000-\u001f]/g, "");
4146
+ const name = basename(raw.split("\\").pop() ?? "").trim();
4147
+ if (!name || name === "." || name === "..")
4148
+ return "download";
4149
+ return name;
4150
+ }
4151
+ function nextFreeName(name, taken) {
4152
+ if (!taken.has(name))
4153
+ return name;
4154
+ const ext = extname(name);
4155
+ const stem = ext ? name.slice(0, -ext.length) : name;
4156
+ for (let i = 1;; i++) {
4157
+ const candidate = `${stem}-${i}${ext}`;
4158
+ if (!taken.has(candidate))
4159
+ return candidate;
4160
+ }
4161
+ }
4162
+ function validateWaitForDownloadArgs(args) {
4163
+ if (args.timeoutMs !== undefined) {
4164
+ if (typeof args.timeoutMs !== "number" || !Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0) {
4165
+ throw new Error(`wait_for_download: 'timeoutMs' must be a positive number (got ${JSON.stringify(args.timeoutMs)})`);
4166
+ }
4167
+ }
4168
+ if (args.arm !== undefined && typeof args.arm !== "boolean") {
4169
+ throw new Error(`wait_for_download: 'arm' must be a boolean (got ${JSON.stringify(args.arm)})`);
4170
+ }
4171
+ }
4172
+ async function waitForDownload(args = {}) {
4173
+ validateWaitForDownloadArgs(args);
4174
+ const t = await resolveTarget(args.target);
4175
+ const target = { id: t.id, url: t.url, title: t.title };
4176
+ const { downloadPath } = await armDownloads();
4177
+ if (args.arm === true)
4178
+ return { armed: true, downloadPath, pending: pendingDownloads().length, target };
4179
+ const rec = await nextCompletedDownload(args.timeoutMs ?? 30000);
4180
+ if (!rec.filePath) {
4181
+ throw new Error(`wait_for_download: download ${rec.guid} completed without a file path (nothing to return)`);
4182
+ }
4183
+ const suggestedFilename = safeFilename(rec.suggestedFilename);
4184
+ const taken = new Set(await readdir3(DOWNLOADS_DIR).catch(() => []));
4185
+ taken.delete(basename(rec.filePath));
4186
+ const finalName = nextFreeName(suggestedFilename, taken);
4187
+ const path = join8(DOWNLOADS_DIR, finalName);
4188
+ await rename(rec.filePath, path);
4189
+ const bytes = (await stat4(path)).size;
4190
+ return { path, suggestedFilename, bytes, ...rec.url ? { url: rec.url } : {}, target };
4191
+ }
4192
+
4193
+ // src/tools/permissions.ts
4194
+ function originOf(url) {
4195
+ try {
4196
+ const o = new URL(url).origin;
4197
+ return o && o !== "null" ? o : undefined;
4198
+ } catch {
4199
+ return;
4200
+ }
4201
+ }
4202
+ function validateGrantPermissionsArgs(args) {
4203
+ const reset = args.reset === true;
4204
+ if (args.reset !== undefined && typeof args.reset !== "boolean") {
4205
+ throw new Error(`grant_permissions: 'reset' must be a boolean (got ${JSON.stringify(args.reset)})`);
4206
+ }
4207
+ if (args.permissions === undefined) {
4208
+ if (!reset)
4209
+ throw new Error("grant_permissions: 'permissions' is required (or pass reset:true to clear previous grants)");
4210
+ return;
4211
+ }
4212
+ if (!Array.isArray(args.permissions)) {
4213
+ throw new Error(`grant_permissions: 'permissions' must be an array of CDP PermissionType strings (got ${JSON.stringify(args.permissions)})`);
4214
+ }
4215
+ if (args.permissions.length === 0 && !reset) {
4216
+ throw new Error("grant_permissions: 'permissions' must not be empty (pass reset:true if the intent is to clear grants)");
4217
+ }
4218
+ for (const p of args.permissions) {
4219
+ if (typeof p !== "string" || p.length === 0) {
4220
+ throw new Error(`grant_permissions: every entry of 'permissions' must be a non-empty string (got ${JSON.stringify(p)})`);
4221
+ }
4222
+ }
4223
+ }
4224
+ async function grantPermissions(args = {}) {
4225
+ validateGrantPermissionsArgs(args);
4226
+ const t = await resolveTarget(args.target);
4227
+ const target = { id: t.id, url: t.url, title: t.title };
4228
+ const reset = args.reset === true;
4229
+ const permissions = args.permissions ?? [];
4230
+ if (reset)
4231
+ await browserSend("Browser.resetPermissions", {});
4232
+ if (permissions.length === 0)
4233
+ return { reset: true, target };
4234
+ const origin = args.origin ?? originOf(t.url);
4235
+ if (!origin) {
4236
+ 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"`);
4237
+ }
4238
+ await browserSend("Browser.grantPermissions", { origin, permissions });
4239
+ return { granted: [...permissions], origin, ...reset ? { reset: true } : {}, target };
4240
+ }
4241
+
3247
4242
  // src/index.ts
3248
4243
  var cdpDriver = createCdpDriver();
3249
4244
  function onCdp(fn) {
@@ -3264,6 +4259,7 @@ var TOOLS = {
3264
4259
  click: onCdp(SHARED_TOOLS.click),
3265
4260
  hover: onCdp(SHARED_TOOLS.hover),
3266
4261
  drag: onCdp(SHARED_TOOLS.drag),
4262
+ scroll: onCdp(SHARED_TOOLS.scroll),
3267
4263
  fill: onCdp(SHARED_TOOLS.fill),
3268
4264
  fill_form: onCdp(SHARED_TOOLS.fill_form),
3269
4265
  type_text: onCdp(SHARED_TOOLS.type_text),
@@ -3288,6 +4284,9 @@ var TOOLS = {
3288
4284
  mock_request: mockRequest,
3289
4285
  list_mocks: listMocks,
3290
4286
  clear_mocks: clearMocks,
4287
+ dispatch_mouse: dispatchMouse,
4288
+ wait_for_download: waitForDownload,
4289
+ grant_permissions: grantPermissions,
3291
4290
  claim_page: onCdp(LEASE_TOOLS.claim_page),
3292
4291
  release_page: onCdp(LEASE_TOOLS.release_page),
3293
4292
  list_leases: onCdp(LEASE_TOOLS.list_leases)
@@ -4048,6 +5047,8 @@ var KEY_VALUES = {
4048
5047
  };
4049
5048
  var MODIFIER_VALUES = { shift: "", control: "", ctrl: "", alt: "", meta: "", cmd: "", command: "" };
4050
5049
  var BUTTON_CODES = { left: 0, middle: 1, right: 2 };
5050
+ 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);});}";
5051
+ var AWAIT_SCROLL_SETTLE_SOURCE = "function(){return window.__cdpScrollSettle;}";
4051
5052
  var BIDI_CAPABILITIES = new Set([
4052
5053
  "emulate.deviceMetrics",
4053
5054
  "screenshot.fullPage",
@@ -4069,6 +5070,7 @@ async function getConnection(port, timeoutMs) {
4069
5070
  connections.set(port, conn);
4070
5071
  return conn;
4071
5072
  }
5073
+ var bidiBeaconScripts = new Map;
4072
5074
  async function safeTitle(conn, contextId) {
4073
5075
  try {
4074
5076
  const res = await conn.send("script.evaluate", { expression: "document.title", target: { context: contextId }, awaitPromise: false });
@@ -4113,6 +5115,22 @@ async function pickContext(conn, contexts, selector) {
4113
5115
  return c;
4114
5116
  throw driverError2("no-such-target", `no context with title containing '${needle}'`);
4115
5117
  }
5118
+ if (selector.startsWith("label:")) {
5119
+ const label = selector.slice(6);
5120
+ const { matchIds, knownLabels } = await resolveLiveLabel("firefox", label, contexts.map((c) => c.context));
5121
+ if (matchIds.length > 1) {
5122
+ throw driverError2("no-such-target", `label 'label:${label}' matches more than one live target: ${matchIds.join(", ")}. Resolve by target id instead.`);
5123
+ }
5124
+ if (matchIds.length === 1) {
5125
+ const hit = contexts.find((c) => c.context === matchIds[0]);
5126
+ if (hit)
5127
+ return hit;
5128
+ }
5129
+ throw driverError2("no-such-target", knownLabels.length ? `no live target with label '${label}' (labels currently in use: ${knownLabels.join(", ")})` : `no live target with label '${label}' (no labels are currently assigned to any open target)`);
5130
+ }
5131
+ if (isWorkerSelector(selector)) {
5132
+ throw driverError2("unsupported", WORKER_SELECTOR_UNSUPPORTED_MESSAGE);
5133
+ }
4116
5134
  const exact = contexts.find((c) => c.context === selector);
4117
5135
  if (exact)
4118
5136
  return exact;
@@ -4132,9 +5150,31 @@ class BidiPageDriver {
4132
5150
  this.info = info;
4133
5151
  this.browser = browser;
4134
5152
  }
5153
+ async urlAndReadyState() {
5154
+ const raw = await this.conn.send("script.evaluate", {
5155
+ expression: "location.href + '\\u0000' + document.readyState",
5156
+ target: { context: this.contextId },
5157
+ awaitPromise: false
5158
+ }).then((r) => r.type === "success" ? String(deserializeRemote(r.result) ?? "") : "").catch(() => "");
5159
+ const [url = "", ready = ""] = raw.split("\x00");
5160
+ return [url, ready];
5161
+ }
5162
+ async settleAfterTraversal(beforeUrl, waitUntil, timeoutMs) {
5163
+ const ready = waitUntil === "domcontentloaded" ? ["interactive", "complete"] : ["complete"];
5164
+ const deadline = Date.now() + timeoutMs;
5165
+ for (;; ) {
5166
+ const [url, state] = await this.urlAndReadyState();
5167
+ if (url && url !== beforeUrl && ready.includes(state))
5168
+ return { waitedFor: waitUntil, url };
5169
+ if (Date.now() >= deadline)
5170
+ return { waitedFor: "timeout", url: url || beforeUrl };
5171
+ await new Promise((r) => setTimeout(r, 50));
5172
+ }
5173
+ }
4135
5174
  async navigate(opts) {
4136
5175
  const reload = opts.reload === true;
4137
- if (!reload && !opts.url)
5176
+ const history = opts.history;
5177
+ if (!reload && !history && !opts.url)
4138
5178
  throw driverError2("page-error", "navigate: 'url' is required unless reload:true");
4139
5179
  const waitUntil = opts.waitUntil ?? "load";
4140
5180
  const wait = waitUntil === "domcontentloaded" ? "interactive" : "complete";
@@ -4144,6 +5184,17 @@ class BidiPageDriver {
4144
5184
  const r2 = await this.conn.send("browsingContext.reload", { context: this.contextId, ignoreCache: opts.ignoreCache === true, wait }, sendOpts);
4145
5185
  return { url: r2.url, contextId: this.contextId, reloaded: true, waitedFor: waitUntil };
4146
5186
  }
5187
+ if (history) {
5188
+ const [beforeUrl] = await this.urlAndReadyState();
5189
+ await this.conn.send("browsingContext.traverseHistory", { context: this.contextId, delta: history === "back" ? -1 : 1 }, sendOpts).catch((e) => {
5190
+ const msg = e instanceof Error ? e.message : String(e);
5191
+ if (/no such history entry|history/i.test(msg))
5192
+ throw driverError2("page-error", `navigate: no history entry to go ${history} to (${msg})`);
5193
+ throw e;
5194
+ });
5195
+ const settled = await this.settleAfterTraversal(beforeUrl, waitUntil, opts.timeoutMs ?? 15000);
5196
+ return { url: settled.url, contextId: this.contextId, traversed: history, waitedFor: settled.waitedFor };
5197
+ }
4147
5198
  const r = await this.conn.send("browsingContext.navigate", { context: this.contextId, url: opts.url, wait }, sendOpts);
4148
5199
  return { url: r.url, contextId: this.contextId, waitedFor: waitUntil };
4149
5200
  } catch (e) {
@@ -4226,6 +5277,7 @@ class BidiPageDriver {
4226
5277
  throw driverError2("stale-uid", `uid does not resolve: ${uid}`);
4227
5278
  }
4228
5279
  async performActions(actions) {
5280
+ recordDispatch("firefox", this.contextId);
4229
5281
  try {
4230
5282
  await this.conn.send("input.performActions", { context: this.contextId, actions });
4231
5283
  } catch (e) {
@@ -4246,6 +5298,9 @@ class BidiPageDriver {
4246
5298
  return { x, y };
4247
5299
  }
4248
5300
  async click(loc, opts) {
5301
+ if (opts?.modifiers && opts.modifiers.length > 0) {
5302
+ throw driverError2("unsupported", "click: 'modifiers' is not supported by the Firefox/BiDi driver; omit 'modifiers' or use --browser chrome for a modifier click.");
5303
+ }
4249
5304
  const ref = await resolveElementLocator2(this.conn, this.contextId, loc);
4250
5305
  const { x, y } = await this.centerOf(ref);
4251
5306
  const button = BUTTON_CODES[opts?.button ?? "left"];
@@ -4255,19 +5310,53 @@ class BidiPageDriver {
4255
5310
  await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
4256
5311
  return { x, y };
4257
5312
  }
4258
- async drag(from, to) {
5313
+ async scroll(anchor, opts) {
5314
+ const { x, y } = await this.resolveScrollPoint(anchor);
5315
+ const deltaX = Math.round(opts.deltaX ?? 0);
5316
+ const deltaY = Math.round(opts.deltaY ?? 0);
5317
+ await this.callFunctionValue(ARM_SCROLL_SETTLE_WATCH_SOURCE, []);
5318
+ const actions = [{ type: "scroll", x: Math.round(x), y: Math.round(y), deltaX, deltaY }];
5319
+ await this.performActions([{ type: "wheel", id: "cdp-wheel", actions }]);
5320
+ await this.callFunctionValue(AWAIT_SCROLL_SETTLE_SOURCE, []).catch(() => {
5321
+ return;
5322
+ });
5323
+ return { x, y };
5324
+ }
5325
+ async resolveScrollPoint(anchor) {
5326
+ if (anchor === undefined) {
5327
+ const v = await this.callFunctionValue("function(){return {x: window.innerWidth / 2, y: window.innerHeight / 2};}", []);
5328
+ if (!v || typeof v !== "object")
5329
+ throw driverError2("page-error", "could not measure viewport for scroll center");
5330
+ return v;
5331
+ }
5332
+ if ("x" in anchor)
5333
+ return anchor;
5334
+ return this.centerOf(await resolveElementLocator2(this.conn, this.contextId, anchor));
5335
+ }
5336
+ async drag(from, to, opts) {
5337
+ if (opts?.mode === "html5") {
5338
+ 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.");
5339
+ }
4259
5340
  const fromPt = await this.centerOf(await resolveElementLocator2(this.conn, this.contextId, from));
4260
- const toPt = await this.centerOf(await resolveElementLocator2(this.conn, this.contextId, to));
5341
+ const toPt = await this.resolveDragDestination(to, fromPt);
5342
+ const steps = Math.max(1, Math.trunc(opts?.steps ?? 2));
4261
5343
  const actions = [
4262
5344
  { type: "pointerMove", x: fromPt.x, y: fromPt.y, duration: 0 },
4263
- { type: "pointerDown", button: 0 },
4264
- { type: "pointerMove", x: (fromPt.x + toPt.x) / 2, y: (fromPt.y + toPt.y) / 2, duration: 50 },
4265
- { type: "pointerMove", x: toPt.x, y: toPt.y, duration: 50 },
4266
- { type: "pointerUp", button: 0 }
5345
+ { type: "pointerDown", button: 0 }
4267
5346
  ];
5347
+ for (const pt of interpolatePoints(fromPt, toPt, steps))
5348
+ actions.push({ type: "pointerMove", x: pt.x, y: pt.y, duration: 50 });
5349
+ actions.push({ type: "pointerUp", button: 0 });
4268
5350
  await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
4269
5351
  return { from: fromPt, to: toPt };
4270
5352
  }
5353
+ async resolveDragDestination(to, fromPt) {
5354
+ if ("dx" in to)
5355
+ return { x: fromPt.x + to.dx, y: fromPt.y + to.dy };
5356
+ if ("x" in to)
5357
+ return to;
5358
+ return this.centerOf(await resolveElementLocator2(this.conn, this.contextId, to));
5359
+ }
4271
5360
  async sendKeys(text) {
4272
5361
  if (!text.length)
4273
5362
  return;
@@ -4637,7 +5726,60 @@ class BidiBrowserDriver {
4637
5726
  const info = await pageInfoOf(conn, ctx);
4638
5727
  return new BidiPageDriver(conn, ctx.context, info, this);
4639
5728
  }
5729
+ async installActivityBeacon(contextId) {
5730
+ const conn = await getConnection(this.port, this.timeoutMs).catch(() => {
5731
+ return;
5732
+ });
5733
+ if (!conn)
5734
+ return false;
5735
+ try {
5736
+ const key = `${this.port}:${contextId}`;
5737
+ if (!bidiBeaconScripts.has(key)) {
5738
+ const registered = await conn.send("script.addPreloadScript", {
5739
+ functionDeclaration: BEACON_FUNCTION_DECLARATION,
5740
+ contexts: [contextId]
5741
+ });
5742
+ bidiBeaconScripts.set(key, registered.script);
5743
+ }
5744
+ await conn.send("script.evaluate", {
5745
+ expression: BEACON_SOURCE,
5746
+ target: { context: contextId },
5747
+ awaitPromise: false
5748
+ });
5749
+ return true;
5750
+ } catch {
5751
+ return false;
5752
+ } finally {
5753
+ conn.release();
5754
+ }
5755
+ }
5756
+ async readActivityBeacon(contextId) {
5757
+ const conn = await getConnection(this.port, this.timeoutMs).catch(() => {
5758
+ return;
5759
+ });
5760
+ if (!conn)
5761
+ return null;
5762
+ try {
5763
+ const res = await conn.send("script.evaluate", {
5764
+ expression: BEACON_READ_EXPRESSION,
5765
+ target: { context: contextId },
5766
+ awaitPromise: false
5767
+ });
5768
+ if (res.type !== "success")
5769
+ return null;
5770
+ const value = deserializeRemote(res.result);
5771
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
5772
+ } catch {
5773
+ return null;
5774
+ } finally {
5775
+ conn.release();
5776
+ }
5777
+ }
4640
5778
  async dispose() {
5779
+ for (const key of [...bidiBeaconScripts.keys()]) {
5780
+ if (key.startsWith(`${this.port}:`))
5781
+ bidiBeaconScripts.delete(key);
5782
+ }
4641
5783
  const conn = connections.get(this.port);
4642
5784
  if (!conn)
4643
5785
  return;
@@ -4654,7 +5796,7 @@ import { spawn as spawn3 } from "node:child_process";
4654
5796
  import { mkdtempSync, rmSync } from "node:fs";
4655
5797
  import { createConnection, createServer } from "node:net";
4656
5798
  import { tmpdir } from "node:os";
4657
- import { join as join7 } from "node:path";
5799
+ import { join as join9 } from "node:path";
4658
5800
  function resolveBinary() {
4659
5801
  const candidates = [
4660
5802
  process.env.FIREFOX_BIN,
@@ -4728,7 +5870,7 @@ async function launchFirefox(opts = {}) {
4728
5870
  const binary = resolveBinary();
4729
5871
  const port = await pickPort(opts.port);
4730
5872
  const isThrowaway = opts.profilePath === undefined;
4731
- const profilePath = opts.profilePath ?? mkdtempSync(join7(tmpdir(), "cdp-toolkit-ff-profile-"));
5873
+ const profilePath = opts.profilePath ?? mkdtempSync(join9(tmpdir(), "cdp-toolkit-ff-profile-"));
4732
5874
  const args = ["--profile", profilePath, "--remote-debugging-port", String(port), "--no-remote"];
4733
5875
  if (opts.headless)
4734
5876
  args.push("--headless");
@@ -5018,7 +6160,9 @@ Backend selection: --browser chrome|firefox, else the CDP_BROWSER env var, else
5018
6160
  zero behavior change for existing users). Firefox is LAUNCHED per invocation (it cannot be attached
5019
6161
  to), used for exactly one tool call, then disposed before the process exits.
5020
6162
 
5021
- Target selector grammar: active | index:N | url:<substr> | title:<substr> | <targetId>
6163
+ Target selector grammar: active | index:N | url:<substr> | title:<substr> | label:<name> | <targetId>
6164
+ plus worker:<substr> for evaluate_script ONLY (Chrome only): an MV3 extension's background service
6165
+ worker, e.g. worker:<extension-id>. An idle-evicted worker is started first unless wake:false.
5022
6166
  Leases: pass --lease <token> to act on a tab another agent claimed with claim_page (MCP only).
5023
6167
  Run list_leases to see who holds what; release_page --lease <token> gives a lease back.
5024
6168
  Run with --list to print every tool name available under the selected backend.
@@ -5146,14 +6290,14 @@ async function main() {
5146
6290
  `);
5147
6291
  return 1;
5148
6292
  }
5149
- const session = await startFirefoxSession();
6293
+ const session2 = await startFirefoxSession();
5150
6294
  try {
5151
- const result = await neutralFn(session.driver, parsed.args);
6295
+ const result = await neutralFn(session2.driver, parsed.args);
5152
6296
  process.stdout.write(`${JSON.stringify(result, null, 2)}
5153
6297
  `);
5154
6298
  return 0;
5155
6299
  } finally {
5156
- await session.dispose();
6300
+ await session2.dispose();
5157
6301
  }
5158
6302
  });
5159
6303
  }