cdp-toolkit 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +284 -0
- package/CONTRACT.md +5 -1
- package/README.md +35 -13
- package/dist/cli.js +973 -56
- package/dist/index.js +834 -44
- package/dist/mcp.js +1213 -66
- package/package.json +5 -3
- package/src/activity.ts +533 -0
- package/src/bidi/driver.ts +231 -8
- package/src/capabilities.ts +1 -1
- package/src/cdp/driver.ts +472 -25
- package/src/driver.ts +161 -3
- package/src/index.ts +26 -4
- package/src/leases-tools.ts +125 -4
- package/src/leases.ts +1 -1
- package/src/manifest.ts +239 -10
- package/src/mcp.ts +6 -1
- package/src/reap.ts +55 -4
- package/src/shared-tools.ts +268 -27
- package/src/tools/browser-session.ts +246 -0
- package/src/tools/dispatch-mouse.ts +108 -0
- package/src/tools/downloads.ts +143 -0
- package/src/tools/permissions.ts +130 -0
package/dist/cli.js
CHANGED
|
@@ -486,6 +486,141 @@ async function withPage(selector, fn, opts = {}) {
|
|
|
486
486
|
}
|
|
487
487
|
}
|
|
488
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
|
+
|
|
489
624
|
// src/tools/snapshot.ts
|
|
490
625
|
var INTERACTIVE_ROLES = new Set([
|
|
491
626
|
"button",
|
|
@@ -709,11 +844,22 @@ var REQUIRED_CAPABILITIES = {
|
|
|
709
844
|
stop_screen_recording: ["capture.screencast"],
|
|
710
845
|
mock_request: ["network.intercept"],
|
|
711
846
|
list_mocks: ["network.intercept"],
|
|
712
|
-
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"]
|
|
713
851
|
};
|
|
714
852
|
function isDriverError(e) {
|
|
715
853
|
return e instanceof Error && typeof e.code === "string";
|
|
716
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
|
+
}
|
|
717
863
|
|
|
718
864
|
// src/cdp/driver.ts
|
|
719
865
|
function driverError(code, message, data) {
|
|
@@ -769,12 +915,21 @@ async function centerOf(conn, objectId) {
|
|
|
769
915
|
async function focusElement(conn, objectId) {
|
|
770
916
|
await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: "function(){this.focus&&this.focus();}", returnByValue: true });
|
|
771
917
|
}
|
|
772
|
-
|
|
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) {
|
|
773
928
|
await focusElement(conn, objectId);
|
|
774
929
|
const fn = "function(){if('value'in this){this.value='';this.dispatchEvent(new Event('input',{bubbles:true}));}else if(this.isContentEditable){this.textContent='';}}";
|
|
775
930
|
await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: fn, returnByValue: true });
|
|
776
931
|
if (value.length)
|
|
777
|
-
await
|
|
932
|
+
await sendInput2("Input.insertText", { text: value });
|
|
778
933
|
}
|
|
779
934
|
var NAMED_KEYS = {
|
|
780
935
|
enter: { key: "Enter", code: "Enter", keyCode: 13, text: "\r" },
|
|
@@ -799,6 +954,18 @@ var NAMED_KEYS = {
|
|
|
799
954
|
pagedown: { key: "PageDown", code: "PageDown", keyCode: 34 }
|
|
800
955
|
};
|
|
801
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;
|
|
802
969
|
function resolveKey(key) {
|
|
803
970
|
const named = NAMED_KEYS[key.toLowerCase()];
|
|
804
971
|
if (named)
|
|
@@ -963,7 +1130,11 @@ var CDP_CAPABILITIES = new Set([
|
|
|
963
1130
|
"input.insertTextAtomic",
|
|
964
1131
|
"locate.text",
|
|
965
1132
|
"locate.xpath",
|
|
966
|
-
"capture.screencast"
|
|
1133
|
+
"capture.screencast",
|
|
1134
|
+
"input.raw",
|
|
1135
|
+
"input.html5Drag",
|
|
1136
|
+
"browser.downloads",
|
|
1137
|
+
"browser.permissions"
|
|
967
1138
|
]);
|
|
968
1139
|
|
|
969
1140
|
class CdpPageDriver {
|
|
@@ -983,6 +1154,9 @@ class CdpPageDriver {
|
|
|
983
1154
|
this.enabledDomains.add(name);
|
|
984
1155
|
await this.conn.send(`${name}.enable`);
|
|
985
1156
|
}
|
|
1157
|
+
async dispatchInput(method, params = {}) {
|
|
1158
|
+
return sendInput(this.conn, "chrome", this.info.id, method, params);
|
|
1159
|
+
}
|
|
986
1160
|
async ensureDomainSoft(name) {
|
|
987
1161
|
if (this.enabledDomains.has(name))
|
|
988
1162
|
return;
|
|
@@ -991,11 +1165,22 @@ class CdpPageDriver {
|
|
|
991
1165
|
return;
|
|
992
1166
|
});
|
|
993
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
|
+
}
|
|
994
1177
|
async navigate(opts) {
|
|
995
1178
|
await this.ensureDomain("Page");
|
|
996
1179
|
const reload = opts.reload === true;
|
|
997
|
-
|
|
1180
|
+
const history = opts.history;
|
|
1181
|
+
if (!reload && !history && !opts.url)
|
|
998
1182
|
throw driverError("page-error", "navigate: 'url' is required unless reload:true");
|
|
1183
|
+
const entry = history ? await this.historyEntry(history) : undefined;
|
|
999
1184
|
const waitUntil = opts.waitUntil ?? "load";
|
|
1000
1185
|
const milestoneMethod = waitUntil === "load" ? "Page.loadEventFired" : "Page.domContentEventFired";
|
|
1001
1186
|
const milestone = this.conn.waitFor(milestoneMethod, undefined, opts.timeoutMs).then(() => waitUntil).catch(() => {
|
|
@@ -1013,6 +1198,10 @@ class CdpPageDriver {
|
|
|
1013
1198
|
contextId = tree?.frameTree?.frame?.id ?? "";
|
|
1014
1199
|
url = tree?.frameTree?.frame?.url ?? opts.url ?? "";
|
|
1015
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;
|
|
1016
1205
|
} else {
|
|
1017
1206
|
const nav = await this.conn.send("Page.navigate", { url: opts.url }, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {});
|
|
1018
1207
|
if (nav.errorText)
|
|
@@ -1021,7 +1210,20 @@ class CdpPageDriver {
|
|
|
1021
1210
|
url = opts.url;
|
|
1022
1211
|
}
|
|
1023
1212
|
const settled = await Promise.race([milestone, stopped]);
|
|
1024
|
-
|
|
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
|
+
};
|
|
1025
1227
|
}
|
|
1026
1228
|
async waitForText(text, timeoutMs = 15000, pollMs = 250) {
|
|
1027
1229
|
await this.ensureDomain("Runtime");
|
|
@@ -1101,36 +1303,96 @@ class CdpPageDriver {
|
|
|
1101
1303
|
async hover(loc) {
|
|
1102
1304
|
const { objectId } = await resolveElementLocator(this.conn, loc);
|
|
1103
1305
|
const { x, y } = await centerOf(this.conn, objectId);
|
|
1104
|
-
await this.
|
|
1306
|
+
await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
|
|
1105
1307
|
return { x, y };
|
|
1106
1308
|
}
|
|
1107
1309
|
async click(loc, opts) {
|
|
1108
1310
|
const button = opts?.button ?? "left";
|
|
1109
1311
|
const clickCount = opts?.clickCount ?? 1;
|
|
1312
|
+
const modifiers = inputModifierBits(opts?.modifiers);
|
|
1110
1313
|
const { x, y } = await this.hover(loc);
|
|
1111
|
-
await this.
|
|
1112
|
-
await this.
|
|
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 });
|
|
1113
1316
|
return { x, y };
|
|
1114
1317
|
}
|
|
1115
|
-
async
|
|
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) {
|
|
1116
1340
|
const fromPt = await this.hover(from);
|
|
1117
|
-
const
|
|
1118
|
-
const
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
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 });
|
|
1123
1352
|
return { from: fromPt, to: toPt };
|
|
1124
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
|
+
}
|
|
1125
1387
|
async setValue(loc, value) {
|
|
1126
1388
|
const { objectId } = await resolveElementLocator(this.conn, loc);
|
|
1127
|
-
await setValueOnObject(this.conn, objectId, value);
|
|
1389
|
+
await setValueOnObject(this.conn, objectId, value, (method, params) => this.dispatchInput(method, params));
|
|
1128
1390
|
}
|
|
1129
1391
|
async typeText(loc, text) {
|
|
1130
1392
|
const { objectId } = await resolveElementLocator(this.conn, loc);
|
|
1131
1393
|
await focusElement(this.conn, objectId);
|
|
1132
1394
|
if (text.length)
|
|
1133
|
-
await this.
|
|
1395
|
+
await this.dispatchInput("Input.insertText", { text });
|
|
1134
1396
|
}
|
|
1135
1397
|
async pressKey(press) {
|
|
1136
1398
|
const spec = resolveKey(press.key);
|
|
@@ -1145,8 +1407,8 @@ class CdpPageDriver {
|
|
|
1145
1407
|
const down = { type: "keyDown", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits };
|
|
1146
1408
|
if (spec.text && !suppressText)
|
|
1147
1409
|
down.text = spec.text;
|
|
1148
|
-
await this.
|
|
1149
|
-
await this.
|
|
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 });
|
|
1150
1412
|
}
|
|
1151
1413
|
async setFiles(loc, files) {
|
|
1152
1414
|
const { objectId } = await resolveElementLocator(this.conn, loc);
|
|
@@ -1362,6 +1624,36 @@ class CdpPageDriver {
|
|
|
1362
1624
|
this.conn.close();
|
|
1363
1625
|
}
|
|
1364
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
|
+
}
|
|
1365
1657
|
async function withBrowserConn(fn) {
|
|
1366
1658
|
const conn = await openBrowser();
|
|
1367
1659
|
try {
|
|
@@ -1400,6 +1692,8 @@ class CdpBrowserDriver {
|
|
|
1400
1692
|
async closePage(id) {
|
|
1401
1693
|
return withBrowserConn(async (conn) => {
|
|
1402
1694
|
const res = await conn.send("Target.closeTarget", { targetId: id });
|
|
1695
|
+
if (res.success !== false)
|
|
1696
|
+
beaconSessions.drop(id);
|
|
1403
1697
|
return { success: res.success ?? true };
|
|
1404
1698
|
});
|
|
1405
1699
|
}
|
|
@@ -1412,7 +1706,83 @@ class CdpBrowserDriver {
|
|
|
1412
1706
|
const { conn, target } = await openPage(selector).catch(noSuchTarget);
|
|
1413
1707
|
return new CdpPageDriver(conn, target, this);
|
|
1414
1708
|
}
|
|
1415
|
-
async
|
|
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
|
+
}
|
|
1416
1786
|
}
|
|
1417
1787
|
function createCdpDriver() {
|
|
1418
1788
|
return new CdpBrowserDriver;
|
|
@@ -1534,6 +1904,8 @@ function staleAgentTabs(input) {
|
|
|
1534
1904
|
continue;
|
|
1535
1905
|
if (row.stale !== "dead-pid" && row.stale !== "expired")
|
|
1536
1906
|
continue;
|
|
1907
|
+
if (row.stale === "expired" && input.now - row.lastUsedAt <= row.ttlMs + input.reapGraceMs)
|
|
1908
|
+
continue;
|
|
1537
1909
|
if (!live.has(row.targetId))
|
|
1538
1910
|
continue;
|
|
1539
1911
|
const origin = input.origins.get(row.targetId);
|
|
@@ -1543,13 +1915,19 @@ function staleAgentTabs(input) {
|
|
|
1543
1915
|
}
|
|
1544
1916
|
return out;
|
|
1545
1917
|
}
|
|
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
|
+
}
|
|
1546
1923
|
async function reapStaleAgentTabs(driver, backend) {
|
|
1924
|
+
const now = Date.now();
|
|
1547
1925
|
const livePageIds = (await driver.listPages()).map((p) => p.id);
|
|
1548
1926
|
const [origins, leases] = await Promise.all([
|
|
1549
1927
|
originIndex(backend, undefined),
|
|
1550
|
-
listLeases({ liveIds: livePageIds, liveBackend: backend }).catch(() => [])
|
|
1928
|
+
listLeases({ now, liveIds: livePageIds, liveBackend: backend }).catch(() => [])
|
|
1551
1929
|
]);
|
|
1552
|
-
const candidates = staleAgentTabs({ backend, livePageIds, origins, leases });
|
|
1930
|
+
const candidates = staleAgentTabs({ backend, livePageIds, origins, leases, now, reapGraceMs: reapGraceMs() });
|
|
1553
1931
|
const closed = [];
|
|
1554
1932
|
for (const c of candidates) {
|
|
1555
1933
|
const res = await driver.closePage(c.targetId).catch(() => ({ success: false }));
|
|
@@ -1641,18 +2019,29 @@ async function reapSet(driver, pages, all) {
|
|
|
1641
2019
|
}
|
|
1642
2020
|
}
|
|
1643
2021
|
async function listPages(driver, args = {}) {
|
|
1644
|
-
const
|
|
2022
|
+
const backend = backendOf(driver);
|
|
2023
|
+
const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
|
|
1645
2024
|
const reapedIds = new Set(reaped.map((r) => r.targetId));
|
|
1646
2025
|
const pages = (await driver.listPages({ all: args.all })).filter((p) => !reapedIds.has(p.id));
|
|
1647
|
-
const ledger = await originIndex(
|
|
1648
|
-
const
|
|
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) => {
|
|
1649
2031
|
const rec = ledger.get(p.id);
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
if (
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
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
|
+
}));
|
|
1656
2045
|
return { pages: annotated, count: annotated.length, ...reaped.length ? { reaped } : {} };
|
|
1657
2046
|
}
|
|
1658
2047
|
async function newPage(driver, args = {}) {
|
|
@@ -1666,6 +2055,7 @@ async function newPage(driver, args = {}) {
|
|
|
1666
2055
|
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
|
|
1667
2056
|
auto: !explicit
|
|
1668
2057
|
});
|
|
2058
|
+
await installBeacon(driver, p.id);
|
|
1669
2059
|
return { targetId: p.id, url: p.url, lease: token, label: record.label, expiresAt: record.lastUsedAt + record.ttlMs };
|
|
1670
2060
|
}
|
|
1671
2061
|
async function closePage(driver, args) {
|
|
@@ -1697,17 +2087,36 @@ function toLegacyWaitedFor(w) {
|
|
|
1697
2087
|
return "navigate-only";
|
|
1698
2088
|
return w;
|
|
1699
2089
|
}
|
|
1700
|
-
|
|
2090
|
+
function resolveNavigateMode(args) {
|
|
1701
2091
|
const reload = args.reload === true;
|
|
1702
|
-
|
|
1703
|
-
|
|
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 };
|
|
1704
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);
|
|
1705
2113
|
return withPage2(driver, args.target, async (page) => {
|
|
1706
|
-
const r = await page.navigate(args);
|
|
2114
|
+
const r = await page.navigate(mode.mode === "history" ? { ...args, history: mode.history } : args);
|
|
1707
2115
|
return {
|
|
1708
2116
|
url: r.url,
|
|
1709
2117
|
frameId: r.contextId,
|
|
1710
2118
|
...r.reloaded ? { reloaded: r.reloaded } : {},
|
|
2119
|
+
...r.traversed ? { traversed: r.traversed } : {},
|
|
1711
2120
|
waitedFor: toLegacyWaitedFor(r.waitedFor)
|
|
1712
2121
|
};
|
|
1713
2122
|
});
|
|
@@ -1853,22 +2262,113 @@ async function takeSnapshot(driver, args = {}) {
|
|
|
1853
2262
|
}
|
|
1854
2263
|
async function click(driver, args) {
|
|
1855
2264
|
return withPage2(driver, args.target, async (page) => {
|
|
1856
|
-
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 });
|
|
1857
2266
|
return { clicked: true, x, y };
|
|
1858
2267
|
});
|
|
1859
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
|
+
}
|
|
1860
2301
|
async function hover(driver, args) {
|
|
1861
2302
|
return withPage2(driver, args.target, async (page) => {
|
|
1862
2303
|
const { x, y } = await page.hover(locatorOf(args));
|
|
1863
2304
|
return { hovered: true, x, y };
|
|
1864
2305
|
});
|
|
1865
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
|
+
}
|
|
1866
2363
|
async function drag(driver, args) {
|
|
1867
|
-
if (!args.from
|
|
1868
|
-
throw new SharedToolError("drag requires { from }
|
|
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);
|
|
1869
2369
|
return withPage2(driver, args.target, async (page) => {
|
|
1870
|
-
const { from, to } = await page.drag(locatorOf(args.from),
|
|
1871
|
-
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 };
|
|
1872
2372
|
});
|
|
1873
2373
|
}
|
|
1874
2374
|
async function fill(driver, args) {
|
|
@@ -1989,6 +2489,7 @@ var SHARED_TOOLS = {
|
|
|
1989
2489
|
click,
|
|
1990
2490
|
hover,
|
|
1991
2491
|
drag,
|
|
2492
|
+
scroll,
|
|
1992
2493
|
fill,
|
|
1993
2494
|
fill_form: fillForm,
|
|
1994
2495
|
type_text: typeText,
|
|
@@ -2038,12 +2539,15 @@ async function claimPage(driver, args = {}) {
|
|
|
2038
2539
|
url = page.url;
|
|
2039
2540
|
opened = true;
|
|
2040
2541
|
}
|
|
2542
|
+
const humanMs = opened ? null : await readHumanActiveMs(driver, backend, targetId);
|
|
2041
2543
|
const liveIds = [...pages.map((p) => p.id), targetId];
|
|
2042
2544
|
const { record, token } = await claimLease(backend, targetId, {
|
|
2043
2545
|
label,
|
|
2044
2546
|
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
|
|
2045
2547
|
liveIds
|
|
2046
2548
|
});
|
|
2549
|
+
await installBeacon(driver, targetId);
|
|
2550
|
+
const contention = contentionWarning(humanMs, { takeover: !opened });
|
|
2047
2551
|
return {
|
|
2048
2552
|
lease: token,
|
|
2049
2553
|
targetId,
|
|
@@ -2051,7 +2555,9 @@ async function claimPage(driver, args = {}) {
|
|
|
2051
2555
|
label: record.label,
|
|
2052
2556
|
ttlMs: record.ttlMs,
|
|
2053
2557
|
expiresAt: record.lastUsedAt + record.ttlMs,
|
|
2054
|
-
opened
|
|
2558
|
+
opened,
|
|
2559
|
+
...beaconSupported(driver) ? { humanActiveMs: humanMs } : {},
|
|
2560
|
+
...contention !== undefined ? { contention } : {}
|
|
2055
2561
|
};
|
|
2056
2562
|
}
|
|
2057
2563
|
async function releasePage(driver, args = {}) {
|
|
@@ -2086,9 +2592,22 @@ async function listLeasesTool(driver, _args = {}) {
|
|
|
2086
2592
|
const backend = backendOf2(driver);
|
|
2087
2593
|
const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
|
|
2088
2594
|
const reapedIds = new Set(reaped.map((r) => r.targetId));
|
|
2089
|
-
const
|
|
2595
|
+
const livePages = await driver.listPages();
|
|
2596
|
+
const liveIds = livePages.map((p) => p.id);
|
|
2090
2597
|
const all = await listLeases({ liveIds, liveBackend: backend });
|
|
2091
|
-
const
|
|
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
|
+
}
|
|
2092
2611
|
return { leases, count: leases.length, ...reaped.length ? { reaped } : {} };
|
|
2093
2612
|
}
|
|
2094
2613
|
var LEASE_TOOLS = {
|
|
@@ -3244,6 +3763,273 @@ function truncate(s, max = 600) {
|
|
|
3244
3763
|
return t.length > max ? `${t.slice(0, max)}…` : t;
|
|
3245
3764
|
}
|
|
3246
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
|
+
|
|
3247
4033
|
// src/index.ts
|
|
3248
4034
|
var cdpDriver = createCdpDriver();
|
|
3249
4035
|
function onCdp(fn) {
|
|
@@ -3264,6 +4050,7 @@ var TOOLS = {
|
|
|
3264
4050
|
click: onCdp(SHARED_TOOLS.click),
|
|
3265
4051
|
hover: onCdp(SHARED_TOOLS.hover),
|
|
3266
4052
|
drag: onCdp(SHARED_TOOLS.drag),
|
|
4053
|
+
scroll: onCdp(SHARED_TOOLS.scroll),
|
|
3267
4054
|
fill: onCdp(SHARED_TOOLS.fill),
|
|
3268
4055
|
fill_form: onCdp(SHARED_TOOLS.fill_form),
|
|
3269
4056
|
type_text: onCdp(SHARED_TOOLS.type_text),
|
|
@@ -3288,6 +4075,9 @@ var TOOLS = {
|
|
|
3288
4075
|
mock_request: mockRequest,
|
|
3289
4076
|
list_mocks: listMocks,
|
|
3290
4077
|
clear_mocks: clearMocks,
|
|
4078
|
+
dispatch_mouse: dispatchMouse,
|
|
4079
|
+
wait_for_download: waitForDownload,
|
|
4080
|
+
grant_permissions: grantPermissions,
|
|
3291
4081
|
claim_page: onCdp(LEASE_TOOLS.claim_page),
|
|
3292
4082
|
release_page: onCdp(LEASE_TOOLS.release_page),
|
|
3293
4083
|
list_leases: onCdp(LEASE_TOOLS.list_leases)
|
|
@@ -4048,6 +4838,8 @@ var KEY_VALUES = {
|
|
|
4048
4838
|
};
|
|
4049
4839
|
var MODIFIER_VALUES = { shift: "", control: "", ctrl: "", alt: "", meta: "", cmd: "", command: "" };
|
|
4050
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;}";
|
|
4051
4843
|
var BIDI_CAPABILITIES = new Set([
|
|
4052
4844
|
"emulate.deviceMetrics",
|
|
4053
4845
|
"screenshot.fullPage",
|
|
@@ -4069,6 +4861,7 @@ async function getConnection(port, timeoutMs) {
|
|
|
4069
4861
|
connections.set(port, conn);
|
|
4070
4862
|
return conn;
|
|
4071
4863
|
}
|
|
4864
|
+
var bidiBeaconScripts = new Map;
|
|
4072
4865
|
async function safeTitle(conn, contextId) {
|
|
4073
4866
|
try {
|
|
4074
4867
|
const res = await conn.send("script.evaluate", { expression: "document.title", target: { context: contextId }, awaitPromise: false });
|
|
@@ -4132,9 +4925,31 @@ class BidiPageDriver {
|
|
|
4132
4925
|
this.info = info;
|
|
4133
4926
|
this.browser = browser;
|
|
4134
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
|
+
}
|
|
4135
4949
|
async navigate(opts) {
|
|
4136
4950
|
const reload = opts.reload === true;
|
|
4137
|
-
|
|
4951
|
+
const history = opts.history;
|
|
4952
|
+
if (!reload && !history && !opts.url)
|
|
4138
4953
|
throw driverError2("page-error", "navigate: 'url' is required unless reload:true");
|
|
4139
4954
|
const waitUntil = opts.waitUntil ?? "load";
|
|
4140
4955
|
const wait = waitUntil === "domcontentloaded" ? "interactive" : "complete";
|
|
@@ -4144,6 +4959,17 @@ class BidiPageDriver {
|
|
|
4144
4959
|
const r2 = await this.conn.send("browsingContext.reload", { context: this.contextId, ignoreCache: opts.ignoreCache === true, wait }, sendOpts);
|
|
4145
4960
|
return { url: r2.url, contextId: this.contextId, reloaded: true, waitedFor: waitUntil };
|
|
4146
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
|
+
}
|
|
4147
4973
|
const r = await this.conn.send("browsingContext.navigate", { context: this.contextId, url: opts.url, wait }, sendOpts);
|
|
4148
4974
|
return { url: r.url, contextId: this.contextId, waitedFor: waitUntil };
|
|
4149
4975
|
} catch (e) {
|
|
@@ -4226,6 +5052,7 @@ class BidiPageDriver {
|
|
|
4226
5052
|
throw driverError2("stale-uid", `uid does not resolve: ${uid}`);
|
|
4227
5053
|
}
|
|
4228
5054
|
async performActions(actions) {
|
|
5055
|
+
recordDispatch("firefox", this.contextId);
|
|
4229
5056
|
try {
|
|
4230
5057
|
await this.conn.send("input.performActions", { context: this.contextId, actions });
|
|
4231
5058
|
} catch (e) {
|
|
@@ -4246,6 +5073,9 @@ class BidiPageDriver {
|
|
|
4246
5073
|
return { x, y };
|
|
4247
5074
|
}
|
|
4248
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
|
+
}
|
|
4249
5079
|
const ref = await resolveElementLocator2(this.conn, this.contextId, loc);
|
|
4250
5080
|
const { x, y } = await this.centerOf(ref);
|
|
4251
5081
|
const button = BUTTON_CODES[opts?.button ?? "left"];
|
|
@@ -4255,19 +5085,53 @@ class BidiPageDriver {
|
|
|
4255
5085
|
await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
|
|
4256
5086
|
return { x, y };
|
|
4257
5087
|
}
|
|
4258
|
-
async
|
|
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
|
+
}
|
|
4259
5115
|
const fromPt = await this.centerOf(await resolveElementLocator2(this.conn, this.contextId, from));
|
|
4260
|
-
const toPt = await this.
|
|
5116
|
+
const toPt = await this.resolveDragDestination(to, fromPt);
|
|
5117
|
+
const steps = Math.max(1, Math.trunc(opts?.steps ?? 2));
|
|
4261
5118
|
const actions = [
|
|
4262
5119
|
{ 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 }
|
|
5120
|
+
{ type: "pointerDown", button: 0 }
|
|
4267
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 });
|
|
4268
5125
|
await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
|
|
4269
5126
|
return { from: fromPt, to: toPt };
|
|
4270
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
|
+
}
|
|
4271
5135
|
async sendKeys(text) {
|
|
4272
5136
|
if (!text.length)
|
|
4273
5137
|
return;
|
|
@@ -4637,7 +5501,60 @@ class BidiBrowserDriver {
|
|
|
4637
5501
|
const info = await pageInfoOf(conn, ctx);
|
|
4638
5502
|
return new BidiPageDriver(conn, ctx.context, info, this);
|
|
4639
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
|
+
}
|
|
4640
5553
|
async dispose() {
|
|
5554
|
+
for (const key of [...bidiBeaconScripts.keys()]) {
|
|
5555
|
+
if (key.startsWith(`${this.port}:`))
|
|
5556
|
+
bidiBeaconScripts.delete(key);
|
|
5557
|
+
}
|
|
4641
5558
|
const conn = connections.get(this.port);
|
|
4642
5559
|
if (!conn)
|
|
4643
5560
|
return;
|
|
@@ -4654,7 +5571,7 @@ import { spawn as spawn3 } from "node:child_process";
|
|
|
4654
5571
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
4655
5572
|
import { createConnection, createServer } from "node:net";
|
|
4656
5573
|
import { tmpdir } from "node:os";
|
|
4657
|
-
import { join as
|
|
5574
|
+
import { join as join9 } from "node:path";
|
|
4658
5575
|
function resolveBinary() {
|
|
4659
5576
|
const candidates = [
|
|
4660
5577
|
process.env.FIREFOX_BIN,
|
|
@@ -4728,7 +5645,7 @@ async function launchFirefox(opts = {}) {
|
|
|
4728
5645
|
const binary = resolveBinary();
|
|
4729
5646
|
const port = await pickPort(opts.port);
|
|
4730
5647
|
const isThrowaway = opts.profilePath === undefined;
|
|
4731
|
-
const profilePath = opts.profilePath ?? mkdtempSync(
|
|
5648
|
+
const profilePath = opts.profilePath ?? mkdtempSync(join9(tmpdir(), "cdp-toolkit-ff-profile-"));
|
|
4732
5649
|
const args = ["--profile", profilePath, "--remote-debugging-port", String(port), "--no-remote"];
|
|
4733
5650
|
if (opts.headless)
|
|
4734
5651
|
args.push("--headless");
|
|
@@ -5146,14 +6063,14 @@ async function main() {
|
|
|
5146
6063
|
`);
|
|
5147
6064
|
return 1;
|
|
5148
6065
|
}
|
|
5149
|
-
const
|
|
6066
|
+
const session2 = await startFirefoxSession();
|
|
5150
6067
|
try {
|
|
5151
|
-
const result = await neutralFn(
|
|
6068
|
+
const result = await neutralFn(session2.driver, parsed.args);
|
|
5152
6069
|
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
5153
6070
|
`);
|
|
5154
6071
|
return 0;
|
|
5155
6072
|
} finally {
|
|
5156
|
-
await
|
|
6073
|
+
await session2.dispose();
|
|
5157
6074
|
}
|
|
5158
6075
|
});
|
|
5159
6076
|
}
|