cdp-toolkit 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +439 -0
- package/CONTRACT.md +11 -2
- package/README.md +89 -23
- package/dist/cli.js +1150 -111
- package/dist/index.js +1011 -99
- package/dist/mcp.js +1447 -167
- 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 +269 -27
- package/src/leases.ts +84 -4
- package/src/manifest.ts +295 -56
- package/src/mcp.ts +10 -2
- package/src/reap.ts +165 -0
- package/src/shared-tools.ts +303 -30
- 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/index.js
CHANGED
|
@@ -11,6 +11,16 @@ function leaseTtlMs() {
|
|
|
11
11
|
const raw = Number(process.env.CDP_LEASE_TTL_MS);
|
|
12
12
|
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_LEASE_TTL_MS;
|
|
13
13
|
}
|
|
14
|
+
var longLived = false;
|
|
15
|
+
function markLongLivedProcess(value = true) {
|
|
16
|
+
longLived = value;
|
|
17
|
+
}
|
|
18
|
+
function requireLease() {
|
|
19
|
+
if (!longLived)
|
|
20
|
+
return false;
|
|
21
|
+
const raw = (process.env.CDP_REQUIRE_LEASE ?? "").trim().toLowerCase();
|
|
22
|
+
return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
|
|
23
|
+
}
|
|
14
24
|
function defaultLabel() {
|
|
15
25
|
return `pid-${process.pid}`;
|
|
16
26
|
}
|
|
@@ -97,7 +107,8 @@ async function claimLease(backend, targetId, opts) {
|
|
|
97
107
|
label: opts.label,
|
|
98
108
|
createdAt: now,
|
|
99
109
|
lastUsedAt: now,
|
|
100
|
-
ttlMs: opts.ttlMs ?? leaseTtlMs()
|
|
110
|
+
ttlMs: opts.ttlMs ?? leaseTtlMs(),
|
|
111
|
+
auto: opts.auto === true
|
|
101
112
|
};
|
|
102
113
|
try {
|
|
103
114
|
await writeFile(leaseFile(backend, targetId), JSON.stringify(record), { flag: "wx" });
|
|
@@ -173,8 +184,16 @@ async function assertLeaseOk(backend, targetId, ctx = {}) {
|
|
|
173
184
|
const held = rec && !staleReason(rec, { now, liveIds: ctx.liveIds }) ? rec : undefined;
|
|
174
185
|
const where = describeTarget(targetId, ctx);
|
|
175
186
|
if (token === undefined) {
|
|
176
|
-
if (!held)
|
|
187
|
+
if (!held) {
|
|
188
|
+
if (requireLease()) {
|
|
189
|
+
await claimLease(backend, targetId, { label: defaultLabel(), ttlMs: leaseTtlMs(), auto: true, now });
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (held.auto === true && held.pid === process.pid) {
|
|
194
|
+
await touchLease(held, now);
|
|
177
195
|
return;
|
|
196
|
+
}
|
|
178
197
|
throw new LeaseConflictError(`${where} is leased by '${held.label}' (pid ${held.pid}). Pass that lease's token as the 'lease' argument, or call release_page to free it. list_leases shows every active lease.`, targetId, held.label);
|
|
179
198
|
}
|
|
180
199
|
const parts = tokenParts(token);
|
|
@@ -182,8 +201,12 @@ async function assertLeaseOk(backend, targetId, ctx = {}) {
|
|
|
182
201
|
throw new LeaseConflictError(`malformed lease token for ${where}. Pass back the token claim_page returned, never a constructed one.`, targetId);
|
|
183
202
|
}
|
|
184
203
|
if (parts.backend !== backend || parts.targetId !== targetId) {
|
|
185
|
-
if (!held)
|
|
204
|
+
if (!held) {
|
|
205
|
+
if (requireLease()) {
|
|
206
|
+
await claimLease(backend, targetId, { label: defaultLabel(), ttlMs: leaseTtlMs(), auto: true, now });
|
|
207
|
+
}
|
|
186
208
|
return;
|
|
209
|
+
}
|
|
187
210
|
throw new LeaseConflictError(`the lease token you passed is for ${parts.backend} target '${parts.targetId}', not ${where}, which is leased by '${held.label}'. Resolve the tab you actually hold, or claim this one.`, targetId, held.label);
|
|
188
211
|
}
|
|
189
212
|
if (!held) {
|
|
@@ -461,6 +484,141 @@ async function withPage(selector, fn, opts = {}) {
|
|
|
461
484
|
}
|
|
462
485
|
}
|
|
463
486
|
|
|
487
|
+
// src/activity.ts
|
|
488
|
+
var BEACON_DATA_GLOBAL = "__cdpToolkitLastInput";
|
|
489
|
+
var BEACON_INSTALLED_GLOBAL = "__cdpToolkitBeacon";
|
|
490
|
+
var BEACON_EVENTS = ["pointerdown", "mousedown", "keydown", "wheel", "touchstart"];
|
|
491
|
+
var BEACON_BODY = `
|
|
492
|
+
if (!window.${BEACON_INSTALLED_GLOBAL}) {
|
|
493
|
+
window.${BEACON_INSTALLED_GLOBAL} = true;
|
|
494
|
+
var mark = function () { window.${BEACON_DATA_GLOBAL} = Date.now(); };
|
|
495
|
+
var events = ${JSON.stringify([...BEACON_EVENTS])};
|
|
496
|
+
for (var i = 0; i < events.length; i++) {
|
|
497
|
+
window.addEventListener(events[i], mark, { capture: true, passive: true });
|
|
498
|
+
}
|
|
499
|
+
}`;
|
|
500
|
+
var BEACON_SOURCE = `(function () {${BEACON_BODY}
|
|
501
|
+
})();`;
|
|
502
|
+
var BEACON_FUNCTION_DECLARATION = `function () {${BEACON_BODY}
|
|
503
|
+
}`;
|
|
504
|
+
var BEACON_READ_EXPRESSION = `(typeof window.${BEACON_DATA_GLOBAL} === "number" ? window.${BEACON_DATA_GLOBAL} : null)`;
|
|
505
|
+
var DISPATCH_ATTRIBUTION_WINDOW_MS = 1500;
|
|
506
|
+
function dispatchKey(backend, targetId) {
|
|
507
|
+
return `${backend}:${targetId}`;
|
|
508
|
+
}
|
|
509
|
+
var dispatchLog = new Map;
|
|
510
|
+
var DISPATCH_LOG_LIMIT = 512;
|
|
511
|
+
function recordDispatch(backend, targetId, now = Date.now()) {
|
|
512
|
+
const key = dispatchKey(backend, targetId);
|
|
513
|
+
dispatchLog.delete(key);
|
|
514
|
+
dispatchLog.set(key, now);
|
|
515
|
+
while (dispatchLog.size > DISPATCH_LOG_LIMIT) {
|
|
516
|
+
const oldest = dispatchLog.keys().next();
|
|
517
|
+
if (oldest.done)
|
|
518
|
+
break;
|
|
519
|
+
dispatchLog.delete(oldest.value);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
var NON_SYNTHESIZING_INPUT_METHODS = new Set(["Input.setInterceptDrags", "Input.setIgnoreInputEvents"]);
|
|
523
|
+
async function sendInput(conn, backend, targetId, method, params = {}) {
|
|
524
|
+
if (!NON_SYNTHESIZING_INPUT_METHODS.has(method))
|
|
525
|
+
recordDispatch(backend, targetId);
|
|
526
|
+
return conn.send(method, params);
|
|
527
|
+
}
|
|
528
|
+
function lastDispatchAt(backend, targetId) {
|
|
529
|
+
return dispatchLog.get(dispatchKey(backend, targetId));
|
|
530
|
+
}
|
|
531
|
+
function isHumanAttributed(beaconTs, dispatchAt, windowMs = DISPATCH_ATTRIBUTION_WINDOW_MS) {
|
|
532
|
+
if (typeof beaconTs !== "number" || !Number.isFinite(beaconTs))
|
|
533
|
+
return false;
|
|
534
|
+
return beaconTs > (dispatchAt ?? 0) + windowMs;
|
|
535
|
+
}
|
|
536
|
+
function humanActiveMs(beaconTs, dispatchAt, now = Date.now(), windowMs = DISPATCH_ATTRIBUTION_WINDOW_MS) {
|
|
537
|
+
if (!isHumanAttributed(beaconTs, dispatchAt, windowMs))
|
|
538
|
+
return null;
|
|
539
|
+
return Math.max(0, now - beaconTs);
|
|
540
|
+
}
|
|
541
|
+
var CONTENTION_WINDOW_MS = 30000;
|
|
542
|
+
function contentionWarning(ms, opts) {
|
|
543
|
+
if (!opts.takeover)
|
|
544
|
+
return;
|
|
545
|
+
if (ms === null || ms >= CONTENTION_WINDOW_MS)
|
|
546
|
+
return;
|
|
547
|
+
return `a person appears to be using this tab right now: input not dispatched by this server landed ${ms}ms ago. ` + `The claim SUCCEEDED and you hold the lease — this is a warning, not a refusal. ` + `Driving the tab now will fight them for the keyboard and mouse. ` + `Consider claim_page with no target to work in your own tab instead, or ask before continuing.`;
|
|
548
|
+
}
|
|
549
|
+
function beaconSupported(driver) {
|
|
550
|
+
return typeof driver.installActivityBeacon === "function" && typeof driver.readActivityBeacon === "function";
|
|
551
|
+
}
|
|
552
|
+
async function installBeacon(driver, targetId) {
|
|
553
|
+
if (typeof driver.installActivityBeacon !== "function")
|
|
554
|
+
return false;
|
|
555
|
+
return driver.installActivityBeacon(targetId).catch(() => false);
|
|
556
|
+
}
|
|
557
|
+
async function readHumanActiveMs(driver, backend, targetId, now = Date.now()) {
|
|
558
|
+
if (typeof driver.readActivityBeacon !== "function")
|
|
559
|
+
return null;
|
|
560
|
+
const beaconTs = await driver.readActivityBeacon(targetId).catch(() => null);
|
|
561
|
+
return humanActiveMs(beaconTs, lastDispatchAt(backend, targetId), now);
|
|
562
|
+
}
|
|
563
|
+
var RENDERER_PROBE_TIMEOUT_MS = 500;
|
|
564
|
+
var RENDERER_PROBE_EXPRESSION = `[1, ${BEACON_READ_EXPRESSION}]`;
|
|
565
|
+
function rendererProbeSupported(driver) {
|
|
566
|
+
return typeof driver.probeRenderer === "function";
|
|
567
|
+
}
|
|
568
|
+
async function probeRendererActivity(driver, backend, targetId, now = Date.now()) {
|
|
569
|
+
if (typeof driver.probeRenderer !== "function")
|
|
570
|
+
return { responsive: false };
|
|
571
|
+
const { responsive, beaconTs } = await driver.probeRenderer(targetId, RENDERER_PROBE_TIMEOUT_MS).catch(() => ({ responsive: false, beaconTs: null }));
|
|
572
|
+
const ms = humanActiveMs(beaconTs, lastDispatchAt(backend, targetId), now);
|
|
573
|
+
return ms === null ? { responsive } : { responsive, humanActiveMs: ms };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
class BeaconSessions {
|
|
577
|
+
close;
|
|
578
|
+
limit;
|
|
579
|
+
live = new Map;
|
|
580
|
+
constructor(close, limit = 32) {
|
|
581
|
+
this.close = close;
|
|
582
|
+
this.limit = limit;
|
|
583
|
+
}
|
|
584
|
+
get(key) {
|
|
585
|
+
return this.live.get(key);
|
|
586
|
+
}
|
|
587
|
+
set(key, value) {
|
|
588
|
+
const previous = this.live.get(key);
|
|
589
|
+
if (previous !== undefined) {
|
|
590
|
+
this.live.delete(key);
|
|
591
|
+
this.safeClose(previous);
|
|
592
|
+
}
|
|
593
|
+
this.live.set(key, value);
|
|
594
|
+
while (this.live.size > this.limit) {
|
|
595
|
+
const oldest = this.live.keys().next();
|
|
596
|
+
if (oldest.done)
|
|
597
|
+
break;
|
|
598
|
+
this.drop(oldest.value);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
drop(key) {
|
|
602
|
+
const value = this.live.get(key);
|
|
603
|
+
if (value === undefined)
|
|
604
|
+
return;
|
|
605
|
+
this.live.delete(key);
|
|
606
|
+
this.safeClose(value);
|
|
607
|
+
}
|
|
608
|
+
clear() {
|
|
609
|
+
for (const key of [...this.live.keys()])
|
|
610
|
+
this.drop(key);
|
|
611
|
+
}
|
|
612
|
+
get size() {
|
|
613
|
+
return this.live.size;
|
|
614
|
+
}
|
|
615
|
+
safeClose(value) {
|
|
616
|
+
try {
|
|
617
|
+
this.close(value);
|
|
618
|
+
} catch {}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
464
622
|
// src/tools/snapshot.ts
|
|
465
623
|
var INTERACTIVE_ROLES = new Set([
|
|
466
624
|
"button",
|
|
@@ -684,11 +842,22 @@ var REQUIRED_CAPABILITIES = {
|
|
|
684
842
|
stop_screen_recording: ["capture.screencast"],
|
|
685
843
|
mock_request: ["network.intercept"],
|
|
686
844
|
list_mocks: ["network.intercept"],
|
|
687
|
-
clear_mocks: ["network.intercept"]
|
|
845
|
+
clear_mocks: ["network.intercept"],
|
|
846
|
+
dispatch_mouse: ["input.raw"],
|
|
847
|
+
wait_for_download: ["browser.downloads"],
|
|
848
|
+
grant_permissions: ["browser.permissions"]
|
|
688
849
|
};
|
|
689
850
|
function isDriverError(e) {
|
|
690
851
|
return e instanceof Error && typeof e.code === "string";
|
|
691
852
|
}
|
|
853
|
+
function interpolatePoints(from, to, steps) {
|
|
854
|
+
const out = [];
|
|
855
|
+
for (let i = 1;i < steps; i++) {
|
|
856
|
+
out.push({ x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps });
|
|
857
|
+
}
|
|
858
|
+
out.push({ x: to.x, y: to.y });
|
|
859
|
+
return out;
|
|
860
|
+
}
|
|
692
861
|
|
|
693
862
|
// src/cdp/driver.ts
|
|
694
863
|
function driverError(code, message, data) {
|
|
@@ -744,12 +913,21 @@ async function centerOf(conn, objectId) {
|
|
|
744
913
|
async function focusElement(conn, objectId) {
|
|
745
914
|
await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: "function(){this.focus&&this.focus();}", returnByValue: true });
|
|
746
915
|
}
|
|
747
|
-
|
|
916
|
+
var ARM_SCROLL_SETTLE_WATCH = "(function(){window.__cdpScrollSettle=new Promise((resolve)=>{let t;const done=()=>{window.removeEventListener('scroll',on,true);resolve(true);};" + "const on=()=>{clearTimeout(t);t=setTimeout(done,60);};window.addEventListener('scroll',on,true);t=setTimeout(done,60);setTimeout(done,500);});})()";
|
|
917
|
+
async function armScrollSettleWatch(conn) {
|
|
918
|
+
await conn.send("Runtime.evaluate", { expression: ARM_SCROLL_SETTLE_WATCH, returnByValue: true });
|
|
919
|
+
}
|
|
920
|
+
async function awaitScrollSettle(conn) {
|
|
921
|
+
await conn.send("Runtime.evaluate", { expression: "window.__cdpScrollSettle", awaitPromise: true, returnByValue: true }).catch(() => {
|
|
922
|
+
return;
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
async function setValueOnObject(conn, objectId, value, sendInput2) {
|
|
748
926
|
await focusElement(conn, objectId);
|
|
749
927
|
const fn = "function(){if('value'in this){this.value='';this.dispatchEvent(new Event('input',{bubbles:true}));}else if(this.isContentEditable){this.textContent='';}}";
|
|
750
928
|
await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: fn, returnByValue: true });
|
|
751
929
|
if (value.length)
|
|
752
|
-
await
|
|
930
|
+
await sendInput2("Input.insertText", { text: value });
|
|
753
931
|
}
|
|
754
932
|
var NAMED_KEYS = {
|
|
755
933
|
enter: { key: "Enter", code: "Enter", keyCode: 13, text: "\r" },
|
|
@@ -774,6 +952,18 @@ var NAMED_KEYS = {
|
|
|
774
952
|
pagedown: { key: "PageDown", code: "PageDown", keyCode: 34 }
|
|
775
953
|
};
|
|
776
954
|
var MODIFIER_BITS = { alt: 1, control: 2, ctrl: 2, meta: 4, cmd: 4, command: 4, shift: 8 };
|
|
955
|
+
var CLICK_MODIFIER_BITS = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
|
|
956
|
+
function inputModifierBits(mods) {
|
|
957
|
+
let bits = 0;
|
|
958
|
+
for (const m of mods ?? []) {
|
|
959
|
+
const bit = CLICK_MODIFIER_BITS[m];
|
|
960
|
+
if (bit === undefined)
|
|
961
|
+
throw driverError("page-error", `unknown modifier '${m}' (use Alt, Control, Meta, or Shift)`);
|
|
962
|
+
bits |= bit;
|
|
963
|
+
}
|
|
964
|
+
return bits;
|
|
965
|
+
}
|
|
966
|
+
var HTML5_DRAG_INTERCEPT_TIMEOUT_MS = 5000;
|
|
777
967
|
function resolveKey(key) {
|
|
778
968
|
const named = NAMED_KEYS[key.toLowerCase()];
|
|
779
969
|
if (named)
|
|
@@ -938,7 +1128,11 @@ var CDP_CAPABILITIES = new Set([
|
|
|
938
1128
|
"input.insertTextAtomic",
|
|
939
1129
|
"locate.text",
|
|
940
1130
|
"locate.xpath",
|
|
941
|
-
"capture.screencast"
|
|
1131
|
+
"capture.screencast",
|
|
1132
|
+
"input.raw",
|
|
1133
|
+
"input.html5Drag",
|
|
1134
|
+
"browser.downloads",
|
|
1135
|
+
"browser.permissions"
|
|
942
1136
|
]);
|
|
943
1137
|
|
|
944
1138
|
class CdpPageDriver {
|
|
@@ -958,6 +1152,9 @@ class CdpPageDriver {
|
|
|
958
1152
|
this.enabledDomains.add(name);
|
|
959
1153
|
await this.conn.send(`${name}.enable`);
|
|
960
1154
|
}
|
|
1155
|
+
async dispatchInput(method, params = {}) {
|
|
1156
|
+
return sendInput(this.conn, "chrome", this.info.id, method, params);
|
|
1157
|
+
}
|
|
961
1158
|
async ensureDomainSoft(name) {
|
|
962
1159
|
if (this.enabledDomains.has(name))
|
|
963
1160
|
return;
|
|
@@ -966,11 +1163,22 @@ class CdpPageDriver {
|
|
|
966
1163
|
return;
|
|
967
1164
|
});
|
|
968
1165
|
}
|
|
1166
|
+
async historyEntry(direction) {
|
|
1167
|
+
const h = await this.conn.send("Page.getNavigationHistory");
|
|
1168
|
+
const entries = h.entries ?? [];
|
|
1169
|
+
const entry = entries[h.currentIndex + (direction === "back" ? -1 : 1)];
|
|
1170
|
+
if (!entry) {
|
|
1171
|
+
throw driverError("page-error", `navigate: no history entry to go ${direction} to (at entry ${h.currentIndex + 1} of ${entries.length})`);
|
|
1172
|
+
}
|
|
1173
|
+
return entry;
|
|
1174
|
+
}
|
|
969
1175
|
async navigate(opts) {
|
|
970
1176
|
await this.ensureDomain("Page");
|
|
971
1177
|
const reload = opts.reload === true;
|
|
972
|
-
|
|
1178
|
+
const history = opts.history;
|
|
1179
|
+
if (!reload && !history && !opts.url)
|
|
973
1180
|
throw driverError("page-error", "navigate: 'url' is required unless reload:true");
|
|
1181
|
+
const entry = history ? await this.historyEntry(history) : undefined;
|
|
974
1182
|
const waitUntil = opts.waitUntil ?? "load";
|
|
975
1183
|
const milestoneMethod = waitUntil === "load" ? "Page.loadEventFired" : "Page.domContentEventFired";
|
|
976
1184
|
const milestone = this.conn.waitFor(milestoneMethod, undefined, opts.timeoutMs).then(() => waitUntil).catch(() => {
|
|
@@ -988,6 +1196,10 @@ class CdpPageDriver {
|
|
|
988
1196
|
contextId = tree?.frameTree?.frame?.id ?? "";
|
|
989
1197
|
url = tree?.frameTree?.frame?.url ?? opts.url ?? "";
|
|
990
1198
|
reloaded = true;
|
|
1199
|
+
} else if (entry) {
|
|
1200
|
+
await this.conn.send("Page.navigateToHistoryEntry", { entryId: entry.id }, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {});
|
|
1201
|
+
contextId = "";
|
|
1202
|
+
url = entry.url;
|
|
991
1203
|
} else {
|
|
992
1204
|
const nav = await this.conn.send("Page.navigate", { url: opts.url }, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {});
|
|
993
1205
|
if (nav.errorText)
|
|
@@ -996,7 +1208,20 @@ class CdpPageDriver {
|
|
|
996
1208
|
url = opts.url;
|
|
997
1209
|
}
|
|
998
1210
|
const settled = await Promise.race([milestone, stopped]);
|
|
999
|
-
|
|
1211
|
+
if (entry) {
|
|
1212
|
+
const tree = await this.conn.send("Page.getFrameTree").catch(() => {
|
|
1213
|
+
return;
|
|
1214
|
+
});
|
|
1215
|
+
contextId = tree?.frameTree?.frame?.id ?? contextId;
|
|
1216
|
+
url = tree?.frameTree?.frame?.url ?? url;
|
|
1217
|
+
}
|
|
1218
|
+
return {
|
|
1219
|
+
url,
|
|
1220
|
+
contextId,
|
|
1221
|
+
...reloaded ? { reloaded } : {},
|
|
1222
|
+
...history ? { traversed: history } : {},
|
|
1223
|
+
waitedFor: settled ?? "timeout"
|
|
1224
|
+
};
|
|
1000
1225
|
}
|
|
1001
1226
|
async waitForText(text, timeoutMs = 15000, pollMs = 250) {
|
|
1002
1227
|
await this.ensureDomain("Runtime");
|
|
@@ -1076,36 +1301,96 @@ class CdpPageDriver {
|
|
|
1076
1301
|
async hover(loc) {
|
|
1077
1302
|
const { objectId } = await resolveElementLocator(this.conn, loc);
|
|
1078
1303
|
const { x, y } = await centerOf(this.conn, objectId);
|
|
1079
|
-
await this.
|
|
1304
|
+
await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
|
|
1080
1305
|
return { x, y };
|
|
1081
1306
|
}
|
|
1082
1307
|
async click(loc, opts) {
|
|
1083
1308
|
const button = opts?.button ?? "left";
|
|
1084
1309
|
const clickCount = opts?.clickCount ?? 1;
|
|
1310
|
+
const modifiers = inputModifierBits(opts?.modifiers);
|
|
1085
1311
|
const { x, y } = await this.hover(loc);
|
|
1086
|
-
await this.
|
|
1087
|
-
await this.
|
|
1312
|
+
await this.dispatchInput("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount, modifiers });
|
|
1313
|
+
await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, clickCount, modifiers });
|
|
1088
1314
|
return { x, y };
|
|
1089
1315
|
}
|
|
1090
|
-
async
|
|
1316
|
+
async scroll(anchor, opts) {
|
|
1317
|
+
await this.ensureDomain("Runtime");
|
|
1318
|
+
const { x, y } = await this.resolveScrollPoint(anchor);
|
|
1319
|
+
await armScrollSettleWatch(this.conn);
|
|
1320
|
+
await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseWheel", x, y, deltaX: opts.deltaX ?? 0, deltaY: opts.deltaY ?? 0 });
|
|
1321
|
+
await awaitScrollSettle(this.conn);
|
|
1322
|
+
return { x, y };
|
|
1323
|
+
}
|
|
1324
|
+
async resolveScrollPoint(anchor) {
|
|
1325
|
+
if (anchor === undefined) {
|
|
1326
|
+
await this.ensureDomain("Runtime");
|
|
1327
|
+
const { result, exceptionDetails } = await this.conn.send("Runtime.evaluate", { expression: "({x: window.innerWidth / 2, y: window.innerHeight / 2})", returnByValue: true });
|
|
1328
|
+
if (exceptionDetails || !result.value)
|
|
1329
|
+
throw driverError("page-error", `could not measure viewport for scroll center: ${exceptionDetails?.text ?? "no value"}`);
|
|
1330
|
+
return result.value;
|
|
1331
|
+
}
|
|
1332
|
+
if ("x" in anchor)
|
|
1333
|
+
return anchor;
|
|
1334
|
+
const { objectId } = await resolveElementLocator(this.conn, anchor);
|
|
1335
|
+
return centerOf(this.conn, objectId);
|
|
1336
|
+
}
|
|
1337
|
+
async drag(from, to, opts) {
|
|
1091
1338
|
const fromPt = await this.hover(from);
|
|
1092
|
-
const
|
|
1093
|
-
const
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1339
|
+
const toPt = await this.resolveDragDestination(to, fromPt);
|
|
1340
|
+
const steps = Math.max(1, Math.trunc(opts?.steps ?? 2));
|
|
1341
|
+
if ((opts?.mode ?? "mouse") === "html5") {
|
|
1342
|
+
await this.html5Drag(fromPt, toPt, steps);
|
|
1343
|
+
return { from: fromPt, to: toPt };
|
|
1344
|
+
}
|
|
1345
|
+
await this.dispatchInput("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
|
|
1346
|
+
for (const pt of interpolatePoints(fromPt, toPt, steps)) {
|
|
1347
|
+
await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x: pt.x, y: pt.y, button: "left" });
|
|
1348
|
+
}
|
|
1349
|
+
await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
|
|
1098
1350
|
return { from: fromPt, to: toPt };
|
|
1099
1351
|
}
|
|
1352
|
+
async resolveDragDestination(to, fromPt) {
|
|
1353
|
+
if ("dx" in to)
|
|
1354
|
+
return { x: fromPt.x + to.dx, y: fromPt.y + to.dy };
|
|
1355
|
+
if ("x" in to)
|
|
1356
|
+
return to;
|
|
1357
|
+
const { objectId } = await resolveElementLocator(this.conn, to);
|
|
1358
|
+
return centerOf(this.conn, objectId);
|
|
1359
|
+
}
|
|
1360
|
+
async html5Drag(fromPt, toPt, steps) {
|
|
1361
|
+
await this.dispatchInput("Input.setInterceptDrags", { enabled: true });
|
|
1362
|
+
try {
|
|
1363
|
+
const intercepted = this.conn.waitFor("Input.dragIntercepted", undefined, HTML5_DRAG_INTERCEPT_TIMEOUT_MS).catch(() => {
|
|
1364
|
+
return;
|
|
1365
|
+
});
|
|
1366
|
+
await this.dispatchInput("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
|
|
1367
|
+
for (const pt of interpolatePoints(fromPt, toPt, steps)) {
|
|
1368
|
+
await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x: pt.x, y: pt.y, button: "left" });
|
|
1369
|
+
}
|
|
1370
|
+
const event = await intercepted;
|
|
1371
|
+
if (!event?.data) {
|
|
1372
|
+
throw driverError("page-error", `drag mode:"html5": the page never started a drag (no Input.dragIntercepted within ${HTML5_DRAG_INTERCEPT_TIMEOUT_MS}ms). ` + 'The source element is probably not draggable — HTML5 drag-and-drop needs draggable="true" (or a native draggable element) ' + 'plus a dragstart handler. Use the default mode:"mouse" for widgets built on raw pointer events.');
|
|
1373
|
+
}
|
|
1374
|
+
const data = event.data;
|
|
1375
|
+
for (const type of ["dragEnter", "dragOver", "drop"]) {
|
|
1376
|
+
await this.dispatchInput("Input.dispatchDragEvent", { type, x: toPt.x, y: toPt.y, data });
|
|
1377
|
+
}
|
|
1378
|
+
await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
|
|
1379
|
+
} finally {
|
|
1380
|
+
await this.dispatchInput("Input.setInterceptDrags", { enabled: false }).catch(() => {
|
|
1381
|
+
return;
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1100
1385
|
async setValue(loc, value) {
|
|
1101
1386
|
const { objectId } = await resolveElementLocator(this.conn, loc);
|
|
1102
|
-
await setValueOnObject(this.conn, objectId, value);
|
|
1387
|
+
await setValueOnObject(this.conn, objectId, value, (method, params) => this.dispatchInput(method, params));
|
|
1103
1388
|
}
|
|
1104
1389
|
async typeText(loc, text) {
|
|
1105
1390
|
const { objectId } = await resolveElementLocator(this.conn, loc);
|
|
1106
1391
|
await focusElement(this.conn, objectId);
|
|
1107
1392
|
if (text.length)
|
|
1108
|
-
await this.
|
|
1393
|
+
await this.dispatchInput("Input.insertText", { text });
|
|
1109
1394
|
}
|
|
1110
1395
|
async pressKey(press) {
|
|
1111
1396
|
const spec = resolveKey(press.key);
|
|
@@ -1120,8 +1405,8 @@ class CdpPageDriver {
|
|
|
1120
1405
|
const down = { type: "keyDown", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits };
|
|
1121
1406
|
if (spec.text && !suppressText)
|
|
1122
1407
|
down.text = spec.text;
|
|
1123
|
-
await this.
|
|
1124
|
-
await this.
|
|
1408
|
+
await this.dispatchInput("Input.dispatchKeyEvent", down);
|
|
1409
|
+
await this.dispatchInput("Input.dispatchKeyEvent", { type: "keyUp", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits });
|
|
1125
1410
|
}
|
|
1126
1411
|
async setFiles(loc, files) {
|
|
1127
1412
|
const { objectId } = await resolveElementLocator(this.conn, loc);
|
|
@@ -1337,6 +1622,36 @@ class CdpPageDriver {
|
|
|
1337
1622
|
this.conn.close();
|
|
1338
1623
|
}
|
|
1339
1624
|
}
|
|
1625
|
+
var beaconSessions = new BeaconSessions((conn) => conn.close());
|
|
1626
|
+
async function targetWsUrl(targetId) {
|
|
1627
|
+
const hit = (await listTargets()).find((t) => t.id === targetId);
|
|
1628
|
+
return hit?.webSocketDebuggerUrl;
|
|
1629
|
+
}
|
|
1630
|
+
async function readBeaconOver(conn) {
|
|
1631
|
+
try {
|
|
1632
|
+
const res = await conn.send("Runtime.evaluate", {
|
|
1633
|
+
expression: BEACON_READ_EXPRESSION,
|
|
1634
|
+
returnByValue: true
|
|
1635
|
+
});
|
|
1636
|
+
const value = res.result?.value;
|
|
1637
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
1638
|
+
} catch {
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
async function probeOver(conn, timeoutMs) {
|
|
1643
|
+
try {
|
|
1644
|
+
const res = await conn.send("Runtime.evaluate", { expression: RENDERER_PROBE_EXPRESSION, returnByValue: true }, { timeoutMs });
|
|
1645
|
+
const value = res.result?.value;
|
|
1646
|
+
if (Array.isArray(value) && value[0] === 1) {
|
|
1647
|
+
const ts = value[1];
|
|
1648
|
+
return { responsive: true, beaconTs: typeof ts === "number" && Number.isFinite(ts) ? ts : null };
|
|
1649
|
+
}
|
|
1650
|
+
return { responsive: false, beaconTs: null };
|
|
1651
|
+
} catch {
|
|
1652
|
+
return { responsive: false, beaconTs: null };
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1340
1655
|
async function withBrowserConn(fn) {
|
|
1341
1656
|
const conn = await openBrowser();
|
|
1342
1657
|
try {
|
|
@@ -1375,6 +1690,8 @@ class CdpBrowserDriver {
|
|
|
1375
1690
|
async closePage(id) {
|
|
1376
1691
|
return withBrowserConn(async (conn) => {
|
|
1377
1692
|
const res = await conn.send("Target.closeTarget", { targetId: id });
|
|
1693
|
+
if (res.success !== false)
|
|
1694
|
+
beaconSessions.drop(id);
|
|
1378
1695
|
return { success: res.success ?? true };
|
|
1379
1696
|
});
|
|
1380
1697
|
}
|
|
@@ -1387,7 +1704,83 @@ class CdpBrowserDriver {
|
|
|
1387
1704
|
const { conn, target } = await openPage(selector).catch(noSuchTarget);
|
|
1388
1705
|
return new CdpPageDriver(conn, target, this);
|
|
1389
1706
|
}
|
|
1390
|
-
async
|
|
1707
|
+
async installActivityBeacon(targetId) {
|
|
1708
|
+
const held = beaconSessions.get(targetId);
|
|
1709
|
+
if (held) {
|
|
1710
|
+
const rearmed = await held.send("Runtime.evaluate", { expression: BEACON_SOURCE, returnByValue: true }).then(() => true).catch(() => false);
|
|
1711
|
+
if (rearmed)
|
|
1712
|
+
return true;
|
|
1713
|
+
beaconSessions.drop(targetId);
|
|
1714
|
+
}
|
|
1715
|
+
const wsUrl = await targetWsUrl(targetId).catch(() => {
|
|
1716
|
+
return;
|
|
1717
|
+
});
|
|
1718
|
+
if (!wsUrl)
|
|
1719
|
+
return false;
|
|
1720
|
+
const conn = await new CdpConnection(wsUrl).connect().catch(() => {
|
|
1721
|
+
return;
|
|
1722
|
+
});
|
|
1723
|
+
if (!conn)
|
|
1724
|
+
return false;
|
|
1725
|
+
try {
|
|
1726
|
+
await conn.send("Page.enable");
|
|
1727
|
+
await conn.send("Page.addScriptToEvaluateOnNewDocument", { source: BEACON_SOURCE });
|
|
1728
|
+
await conn.send("Runtime.evaluate", { expression: BEACON_SOURCE, returnByValue: true });
|
|
1729
|
+
} catch {
|
|
1730
|
+
conn.close();
|
|
1731
|
+
return false;
|
|
1732
|
+
}
|
|
1733
|
+
beaconSessions.set(targetId, conn);
|
|
1734
|
+
return true;
|
|
1735
|
+
}
|
|
1736
|
+
async readActivityBeacon(targetId) {
|
|
1737
|
+
const held = beaconSessions.get(targetId);
|
|
1738
|
+
if (held) {
|
|
1739
|
+
const value = await readBeaconOver(held);
|
|
1740
|
+
if (value !== undefined)
|
|
1741
|
+
return value;
|
|
1742
|
+
beaconSessions.drop(targetId);
|
|
1743
|
+
}
|
|
1744
|
+
const wsUrl = await targetWsUrl(targetId).catch(() => {
|
|
1745
|
+
return;
|
|
1746
|
+
});
|
|
1747
|
+
if (!wsUrl)
|
|
1748
|
+
return null;
|
|
1749
|
+
const conn = await new CdpConnection(wsUrl).connect().catch(() => {
|
|
1750
|
+
return;
|
|
1751
|
+
});
|
|
1752
|
+
if (!conn)
|
|
1753
|
+
return null;
|
|
1754
|
+
try {
|
|
1755
|
+
return await readBeaconOver(conn) ?? null;
|
|
1756
|
+
} finally {
|
|
1757
|
+
conn.close();
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
async probeRenderer(targetId, timeoutMs = RENDERER_PROBE_TIMEOUT_MS) {
|
|
1761
|
+
const held = beaconSessions.get(targetId);
|
|
1762
|
+
if (held)
|
|
1763
|
+
return probeOver(held, timeoutMs);
|
|
1764
|
+
const wsUrl = await targetWsUrl(targetId).catch(() => {
|
|
1765
|
+
return;
|
|
1766
|
+
});
|
|
1767
|
+
if (!wsUrl)
|
|
1768
|
+
return { responsive: false, beaconTs: null };
|
|
1769
|
+
const conn = new CdpConnection(wsUrl, { timeoutMs });
|
|
1770
|
+
try {
|
|
1771
|
+
await conn.connect();
|
|
1772
|
+
} catch {
|
|
1773
|
+
return { responsive: false, beaconTs: null };
|
|
1774
|
+
}
|
|
1775
|
+
try {
|
|
1776
|
+
return await probeOver(conn, timeoutMs);
|
|
1777
|
+
} finally {
|
|
1778
|
+
conn.close();
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
async dispose() {
|
|
1782
|
+
beaconSessions.clear();
|
|
1783
|
+
}
|
|
1391
1784
|
}
|
|
1392
1785
|
function createCdpDriver() {
|
|
1393
1786
|
return new CdpBrowserDriver;
|
|
@@ -1427,6 +1820,20 @@ async function recordOrigin(backend, targetId, opts = {}) {
|
|
|
1427
1820
|
await writeFile2(file, JSON.stringify(record), { flag: "wx" });
|
|
1428
1821
|
} catch {}
|
|
1429
1822
|
}
|
|
1823
|
+
async function readOrigin(backend, targetId) {
|
|
1824
|
+
let raw;
|
|
1825
|
+
try {
|
|
1826
|
+
raw = await readFile2(originFile(backend, targetId), "utf8");
|
|
1827
|
+
} catch {
|
|
1828
|
+
return;
|
|
1829
|
+
}
|
|
1830
|
+
try {
|
|
1831
|
+
const rec = JSON.parse(raw);
|
|
1832
|
+
if (typeof rec?.targetId === "string" && typeof rec?.label === "string" && typeof rec?.createdAt === "number")
|
|
1833
|
+
return rec;
|
|
1834
|
+
} catch {}
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1430
1837
|
async function listOrigins(opts = {}) {
|
|
1431
1838
|
let names = [];
|
|
1432
1839
|
try {
|
|
@@ -1484,59 +1891,51 @@ async function newTrackedPage(driver, backend, opts = {}) {
|
|
|
1484
1891
|
return page;
|
|
1485
1892
|
}
|
|
1486
1893
|
|
|
1487
|
-
// src/
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
throw new LeaseToolError(`claim_page: no page target with id '${targetId}'`);
|
|
1507
|
-
url = existing.url;
|
|
1894
|
+
// src/reap.ts
|
|
1895
|
+
function staleAgentTabs(input) {
|
|
1896
|
+
const live = new Set(input.livePageIds);
|
|
1897
|
+
const out = [];
|
|
1898
|
+
for (const row of input.leases) {
|
|
1899
|
+
if (row.backend !== input.backend)
|
|
1900
|
+
continue;
|
|
1901
|
+
if (row.unreadable !== undefined)
|
|
1902
|
+
continue;
|
|
1903
|
+
if (row.stale !== "dead-pid" && row.stale !== "expired")
|
|
1904
|
+
continue;
|
|
1905
|
+
if (row.stale === "expired" && input.now - row.lastUsedAt <= row.ttlMs + input.reapGraceMs)
|
|
1906
|
+
continue;
|
|
1907
|
+
if (!live.has(row.targetId))
|
|
1908
|
+
continue;
|
|
1909
|
+
const origin = input.origins.get(row.targetId);
|
|
1910
|
+
if (origin === undefined || origin.unreadable !== undefined)
|
|
1911
|
+
continue;
|
|
1912
|
+
out.push({ targetId: row.targetId, label: row.label, reason: row.stale });
|
|
1508
1913
|
}
|
|
1509
|
-
|
|
1510
|
-
const { record, token } = await claimLease(backend, targetId, {
|
|
1511
|
-
label,
|
|
1512
|
-
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
|
|
1513
|
-
liveIds
|
|
1514
|
-
});
|
|
1515
|
-
return {
|
|
1516
|
-
lease: token,
|
|
1517
|
-
targetId,
|
|
1518
|
-
url,
|
|
1519
|
-
label: record.label,
|
|
1520
|
-
ttlMs: record.ttlMs,
|
|
1521
|
-
expiresAt: record.lastUsedAt + record.ttlMs
|
|
1522
|
-
};
|
|
1914
|
+
return out;
|
|
1523
1915
|
}
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1916
|
+
var DEFAULT_REAP_GRACE_MS = 2700000;
|
|
1917
|
+
function reapGraceMs() {
|
|
1918
|
+
const raw = Number(process.env.CDP_REAP_GRACE_MS);
|
|
1919
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_REAP_GRACE_MS;
|
|
1920
|
+
}
|
|
1921
|
+
async function reapStaleAgentTabs(driver, backend) {
|
|
1922
|
+
const now = Date.now();
|
|
1923
|
+
const livePageIds = (await driver.listPages()).map((p) => p.id);
|
|
1924
|
+
const [origins, leases] = await Promise.all([
|
|
1925
|
+
originIndex(backend, undefined),
|
|
1926
|
+
listLeases({ now, liveIds: livePageIds, liveBackend: backend }).catch(() => [])
|
|
1927
|
+
]);
|
|
1928
|
+
const candidates = staleAgentTabs({ backend, livePageIds, origins, leases, now, reapGraceMs: reapGraceMs() });
|
|
1929
|
+
const closed = [];
|
|
1930
|
+
for (const c of candidates) {
|
|
1931
|
+
const res = await driver.closePage(c.targetId).catch(() => ({ success: false }));
|
|
1932
|
+
if (res.success !== true)
|
|
1933
|
+
continue;
|
|
1934
|
+
await releaseLeaseFor(backend, c.targetId);
|
|
1935
|
+
closed.push(c);
|
|
1527
1936
|
}
|
|
1528
|
-
return
|
|
1529
|
-
}
|
|
1530
|
-
async function listLeasesTool(driver, _args = {}) {
|
|
1531
|
-
const liveIds = (await driver.listPages()).map((p) => p.id);
|
|
1532
|
-
const leases = await listLeases({ liveIds, liveBackend: backendOf(driver) });
|
|
1533
|
-
return { leases, count: leases.length };
|
|
1937
|
+
return closed;
|
|
1534
1938
|
}
|
|
1535
|
-
var LEASE_TOOLS = {
|
|
1536
|
-
claim_page: claimPage,
|
|
1537
|
-
release_page: releasePage,
|
|
1538
|
-
list_leases: listLeasesTool
|
|
1539
|
-
};
|
|
1540
1939
|
|
|
1541
1940
|
// src/shared-tools.ts
|
|
1542
1941
|
import { mkdir as mkdir3, writeFile as writeFile3 } from "node:fs/promises";
|
|
@@ -1549,7 +1948,7 @@ class SharedToolError extends Error {
|
|
|
1549
1948
|
function target3(p) {
|
|
1550
1949
|
return { id: p.id, url: p.url, title: p.title };
|
|
1551
1950
|
}
|
|
1552
|
-
function
|
|
1951
|
+
function backendOf(driver) {
|
|
1553
1952
|
return driver.scheme === "bidi" ? "firefox" : "chrome";
|
|
1554
1953
|
}
|
|
1555
1954
|
async function withPage2(driver, target, fn) {
|
|
@@ -1563,7 +1962,7 @@ async function withPage2(driver, target, fn) {
|
|
|
1563
1962
|
async function resolvePage(driver, selector) {
|
|
1564
1963
|
const pages = await driver.listPages();
|
|
1565
1964
|
const hit = await pickPage(driver, pages, selector);
|
|
1566
|
-
await assertLeaseOk(
|
|
1965
|
+
await assertLeaseOk(backendOf(driver), hit.id, { url: hit.url, title: hit.title });
|
|
1567
1966
|
return hit;
|
|
1568
1967
|
}
|
|
1569
1968
|
async function pickPage(driver, pages, selector) {
|
|
@@ -1618,27 +2017,43 @@ async function reapSet(driver, pages, all) {
|
|
|
1618
2017
|
}
|
|
1619
2018
|
}
|
|
1620
2019
|
async function listPages(driver, args = {}) {
|
|
1621
|
-
const
|
|
1622
|
-
const
|
|
1623
|
-
const
|
|
2020
|
+
const backend = backendOf(driver);
|
|
2021
|
+
const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
|
|
2022
|
+
const reapedIds = new Set(reaped.map((r) => r.targetId));
|
|
2023
|
+
const pages = (await driver.listPages({ all: args.all })).filter((p) => !reapedIds.has(p.id));
|
|
2024
|
+
const ledger = await originIndex(backend, await reapSet(driver, pages, args.all));
|
|
2025
|
+
const now = Date.now();
|
|
2026
|
+
const leaseByTarget = new Map((await listLeases({ now, liveIds: pages.map((p) => p.id), liveBackend: backend })).filter((l) => l.backend === backend && l.unreadable === undefined).map((l) => [l.targetId, l]));
|
|
2027
|
+
const doProbe = args.probe === true && rendererProbeSupported(driver);
|
|
2028
|
+
const annotated = await Promise.all(pages.map(async (p) => {
|
|
1624
2029
|
const rec = ledger.get(p.id);
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
if (
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
2030
|
+
const base = !rec ? { ...p, origin: "unknown" } : rec.unreadable !== undefined ? { ...p, origin: "unknown", originUnreadable: rec.unreadable } : { ...p, origin: "agent", label: rec.label, createdAt: rec.createdAt };
|
|
2031
|
+
const leaseRow = leaseByTarget.get(p.id);
|
|
2032
|
+
if (leaseRow) {
|
|
2033
|
+
base.lease = { label: leaseRow.label, pid: leaseRow.pid, idleMs: now - leaseRow.lastUsedAt, expiresAt: leaseRow.lastUsedAt + leaseRow.ttlMs, stale: leaseRow.stale };
|
|
2034
|
+
}
|
|
2035
|
+
if (doProbe && (p.type === undefined || p.type === "page")) {
|
|
2036
|
+
const probe = await probeRendererActivity(driver, backend, p.id, now);
|
|
2037
|
+
base.responsive = probe.responsive;
|
|
2038
|
+
if (probe.humanActiveMs !== undefined)
|
|
2039
|
+
base.humanActiveMs = probe.humanActiveMs;
|
|
2040
|
+
}
|
|
2041
|
+
return base;
|
|
2042
|
+
}));
|
|
2043
|
+
return { pages: annotated, count: annotated.length, ...reaped.length ? { reaped } : {} };
|
|
1632
2044
|
}
|
|
1633
2045
|
async function newPage(driver, args = {}) {
|
|
1634
2046
|
const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
|
|
1635
|
-
const p = await newTrackedPage(driver,
|
|
1636
|
-
|
|
2047
|
+
const p = await newTrackedPage(driver, backendOf(driver), { url: args.url, label });
|
|
2048
|
+
const explicit = args.claim === true;
|
|
2049
|
+
if (!explicit && !requireLease())
|
|
1637
2050
|
return { targetId: p.id, url: p.url };
|
|
1638
|
-
const { record, token } = await claimLease(
|
|
2051
|
+
const { record, token } = await claimLease(backendOf(driver), p.id, {
|
|
1639
2052
|
label,
|
|
1640
|
-
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs()
|
|
2053
|
+
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
|
|
2054
|
+
auto: !explicit
|
|
1641
2055
|
});
|
|
2056
|
+
await installBeacon(driver, p.id);
|
|
1642
2057
|
return { targetId: p.id, url: p.url, lease: token, label: record.label, expiresAt: record.lastUsedAt + record.ttlMs };
|
|
1643
2058
|
}
|
|
1644
2059
|
async function closePage(driver, args) {
|
|
@@ -1648,7 +2063,7 @@ async function closePage(driver, args) {
|
|
|
1648
2063
|
const res = await driver.closePage(p.id);
|
|
1649
2064
|
if (!res.success)
|
|
1650
2065
|
return { closed: p.id, success: false, leaseReleased: false };
|
|
1651
|
-
const released = await releaseLeaseFor(
|
|
2066
|
+
const released = await releaseLeaseFor(backendOf(driver), p.id);
|
|
1652
2067
|
return { closed: p.id, success: true, ...released.released ? { leaseReleased: true } : {} };
|
|
1653
2068
|
}
|
|
1654
2069
|
async function writeSelectedFile(id) {
|
|
@@ -1670,17 +2085,36 @@ function toLegacyWaitedFor(w) {
|
|
|
1670
2085
|
return "navigate-only";
|
|
1671
2086
|
return w;
|
|
1672
2087
|
}
|
|
1673
|
-
|
|
2088
|
+
function resolveNavigateMode(args) {
|
|
1674
2089
|
const reload = args.reload === true;
|
|
1675
|
-
|
|
1676
|
-
|
|
2090
|
+
const hasUrl = typeof args.url === "string" && args.url.length > 0;
|
|
2091
|
+
const hasHistory = args.history !== undefined && args.history !== null;
|
|
2092
|
+
const given = [hasUrl ? "url" : "", reload ? "reload" : "", hasHistory ? "history" : ""].filter(Boolean);
|
|
2093
|
+
if (given.length > 1) {
|
|
2094
|
+
throw new SharedToolError(`navigate_page: 'url', 'reload' and 'history' are mutually exclusive (got ${given.join(" + ")})`);
|
|
2095
|
+
}
|
|
2096
|
+
if (hasHistory) {
|
|
2097
|
+
if (args.history !== "back" && args.history !== "forward") {
|
|
2098
|
+
throw new SharedToolError(`navigate_page: 'history' must be 'back' or 'forward' (got ${JSON.stringify(args.history)})`);
|
|
2099
|
+
}
|
|
2100
|
+
return { mode: "history", history: args.history };
|
|
2101
|
+
}
|
|
2102
|
+
if (reload)
|
|
2103
|
+
return { mode: "reload" };
|
|
2104
|
+
if (!hasUrl) {
|
|
2105
|
+
throw new SharedToolError("navigate_page: 'url' is required (or pass reload:true to reload the current page, or history:'back'|'forward' to go back/forward in this tab's session history)");
|
|
1677
2106
|
}
|
|
2107
|
+
return { mode: "url", url: args.url };
|
|
2108
|
+
}
|
|
2109
|
+
async function navigatePage(driver, args) {
|
|
2110
|
+
const mode = resolveNavigateMode(args);
|
|
1678
2111
|
return withPage2(driver, args.target, async (page) => {
|
|
1679
|
-
const r = await page.navigate(args);
|
|
2112
|
+
const r = await page.navigate(mode.mode === "history" ? { ...args, history: mode.history } : args);
|
|
1680
2113
|
return {
|
|
1681
2114
|
url: r.url,
|
|
1682
2115
|
frameId: r.contextId,
|
|
1683
2116
|
...r.reloaded ? { reloaded: r.reloaded } : {},
|
|
2117
|
+
...r.traversed ? { traversed: r.traversed } : {},
|
|
1684
2118
|
waitedFor: toLegacyWaitedFor(r.waitedFor)
|
|
1685
2119
|
};
|
|
1686
2120
|
});
|
|
@@ -1826,22 +2260,113 @@ async function takeSnapshot(driver, args = {}) {
|
|
|
1826
2260
|
}
|
|
1827
2261
|
async function click(driver, args) {
|
|
1828
2262
|
return withPage2(driver, args.target, async (page) => {
|
|
1829
|
-
const { x, y } = await page.click(locatorOf(args), { button: args.button ?? "left", clickCount: args.clickCount ?? 1 });
|
|
2263
|
+
const { x, y } = await page.click(locatorOf(args), { button: args.button ?? "left", clickCount: args.clickCount ?? 1, modifiers: args.modifiers });
|
|
1830
2264
|
return { clicked: true, x, y };
|
|
1831
2265
|
});
|
|
1832
2266
|
}
|
|
2267
|
+
function resolveScrollAnchor(args) {
|
|
2268
|
+
const hasUid = args.uid !== undefined && args.uid !== null && args.uid !== "";
|
|
2269
|
+
const hasSelector = typeof args.selector === "string" && args.selector.length > 0;
|
|
2270
|
+
const hasX = args.x !== undefined;
|
|
2271
|
+
const hasY = args.y !== undefined;
|
|
2272
|
+
if (hasX !== hasY)
|
|
2273
|
+
throw new SharedToolError("scroll: 'x' and 'y' must be provided together");
|
|
2274
|
+
const hasXY = hasX && hasY;
|
|
2275
|
+
const anchorCount = [hasUid, hasSelector, hasXY].filter(Boolean).length;
|
|
2276
|
+
if (anchorCount > 1)
|
|
2277
|
+
throw new SharedToolError("scroll: provide at most one of { uid }, { selector }, or { x, y } (omit all three to scroll at the viewport center)");
|
|
2278
|
+
if (hasUid || hasSelector)
|
|
2279
|
+
return locatorOf({ uid: args.uid, selector: args.selector });
|
|
2280
|
+
if (hasXY)
|
|
2281
|
+
return { x: args.x, y: args.y };
|
|
2282
|
+
return;
|
|
2283
|
+
}
|
|
2284
|
+
function resolveScrollDelta(args) {
|
|
2285
|
+
const deltaX = args.deltaX ?? 0;
|
|
2286
|
+
const deltaY = args.deltaY ?? 0;
|
|
2287
|
+
if (deltaX === 0 && deltaY === 0)
|
|
2288
|
+
throw new SharedToolError("scroll requires at least one of { deltaX } or { deltaY }");
|
|
2289
|
+
return { deltaX, deltaY };
|
|
2290
|
+
}
|
|
2291
|
+
async function scroll(driver, args) {
|
|
2292
|
+
const anchor = resolveScrollAnchor(args);
|
|
2293
|
+
const { deltaX, deltaY } = resolveScrollDelta(args);
|
|
2294
|
+
return withPage2(driver, args.target, async (page) => {
|
|
2295
|
+
const { x, y } = await page.scroll(anchor, { deltaX, deltaY });
|
|
2296
|
+
return { x, y, deltaX, deltaY, target: target3(page.info) };
|
|
2297
|
+
});
|
|
2298
|
+
}
|
|
1833
2299
|
async function hover(driver, args) {
|
|
1834
2300
|
return withPage2(driver, args.target, async (page) => {
|
|
1835
2301
|
const { x, y } = await page.hover(locatorOf(args));
|
|
1836
2302
|
return { hovered: true, x, y };
|
|
1837
2303
|
});
|
|
1838
2304
|
}
|
|
2305
|
+
function resolveDragTo(to) {
|
|
2306
|
+
const hasUid = to.uid !== undefined && to.uid !== null && to.uid !== "";
|
|
2307
|
+
const hasSelector = typeof to.selector === "string" && to.selector.length > 0;
|
|
2308
|
+
const hasX = to.x !== undefined;
|
|
2309
|
+
const hasY = to.y !== undefined;
|
|
2310
|
+
if (hasX !== hasY)
|
|
2311
|
+
throw new SharedToolError("drag: 'to.x' and 'to.y' must be provided together");
|
|
2312
|
+
const hasXY = hasX && hasY;
|
|
2313
|
+
const count = [hasUid, hasSelector, hasXY].filter(Boolean).length;
|
|
2314
|
+
if (count !== 1)
|
|
2315
|
+
throw new SharedToolError("drag: 'to' takes exactly one of { uid }, { selector }, or { x, y }");
|
|
2316
|
+
if (hasXY) {
|
|
2317
|
+
if (!Number.isFinite(to.x) || !Number.isFinite(to.y))
|
|
2318
|
+
throw new SharedToolError("drag: 'to.x' and 'to.y' must be finite numbers");
|
|
2319
|
+
return { x: to.x, y: to.y };
|
|
2320
|
+
}
|
|
2321
|
+
return locatorOf({ uid: to.uid, selector: to.selector });
|
|
2322
|
+
}
|
|
2323
|
+
function resolveDragDestination(args) {
|
|
2324
|
+
const hasTo = args.to !== undefined && args.to !== null;
|
|
2325
|
+
const hasBy = args.by !== undefined && args.by !== null;
|
|
2326
|
+
if (hasTo === hasBy)
|
|
2327
|
+
throw new SharedToolError("drag requires exactly one of { to } or { by }");
|
|
2328
|
+
if (hasBy) {
|
|
2329
|
+
const by = args.by;
|
|
2330
|
+
const hasDx = by.dx !== undefined;
|
|
2331
|
+
const hasDy = by.dy !== undefined;
|
|
2332
|
+
if (!hasDx && !hasDy)
|
|
2333
|
+
throw new SharedToolError("drag: 'by' requires at least one of { dx } or { dy }");
|
|
2334
|
+
const dx = by.dx ?? 0;
|
|
2335
|
+
const dy = by.dy ?? 0;
|
|
2336
|
+
if (!Number.isFinite(dx) || !Number.isFinite(dy))
|
|
2337
|
+
throw new SharedToolError("drag: 'by.dx' and 'by.dy' must be finite numbers");
|
|
2338
|
+
return { dx, dy };
|
|
2339
|
+
}
|
|
2340
|
+
return resolveDragTo(args.to);
|
|
2341
|
+
}
|
|
2342
|
+
var MAX_DRAG_STEPS = 500;
|
|
2343
|
+
function resolveDragSteps(steps) {
|
|
2344
|
+
if (steps === undefined)
|
|
2345
|
+
return 2;
|
|
2346
|
+
if (typeof steps !== "number" || !Number.isInteger(steps) || steps < 1 || steps > MAX_DRAG_STEPS) {
|
|
2347
|
+
throw new SharedToolError(`drag: 'steps' must be an integer between 1 and ${MAX_DRAG_STEPS} (default 2)`);
|
|
2348
|
+
}
|
|
2349
|
+
return steps;
|
|
2350
|
+
}
|
|
2351
|
+
function resolveDragMode(mode, capabilities) {
|
|
2352
|
+
if (mode === undefined || mode === "mouse")
|
|
2353
|
+
return "mouse";
|
|
2354
|
+
if (mode !== "html5")
|
|
2355
|
+
throw new SharedToolError(`drag: unknown mode '${mode}' (use 'mouse' or 'html5')`);
|
|
2356
|
+
if (!capabilities.has("input.html5Drag")) {
|
|
2357
|
+
throw new SharedToolError("drag: mode:'html5' is not supported by this backend (WebDriver BiDi has no drag-interception primitive). " + "Omit 'mode' for the synthetic-mouse drag, or use --browser chrome for real HTML5 drag events.");
|
|
2358
|
+
}
|
|
2359
|
+
return "html5";
|
|
2360
|
+
}
|
|
1839
2361
|
async function drag(driver, args) {
|
|
1840
|
-
if (!args.from
|
|
1841
|
-
throw new SharedToolError("drag requires { from }
|
|
2362
|
+
if (!args.from)
|
|
2363
|
+
throw new SharedToolError("drag requires { from }");
|
|
2364
|
+
const destination = resolveDragDestination(args);
|
|
2365
|
+
const steps = resolveDragSteps(args.steps);
|
|
2366
|
+
const mode = resolveDragMode(args.mode, driver.capabilities);
|
|
1842
2367
|
return withPage2(driver, args.target, async (page) => {
|
|
1843
|
-
const { from, to } = await page.drag(locatorOf(args.from),
|
|
1844
|
-
return { dragged: true, from, to };
|
|
2368
|
+
const { from, to } = await page.drag(locatorOf(args.from), destination, { mode, steps });
|
|
2369
|
+
return { dragged: true, mode, steps, from, to };
|
|
1845
2370
|
});
|
|
1846
2371
|
}
|
|
1847
2372
|
async function fill(driver, args) {
|
|
@@ -1962,6 +2487,7 @@ var SHARED_TOOLS = {
|
|
|
1962
2487
|
click,
|
|
1963
2488
|
hover,
|
|
1964
2489
|
drag,
|
|
2490
|
+
scroll,
|
|
1965
2491
|
fill,
|
|
1966
2492
|
fill_form: fillForm,
|
|
1967
2493
|
type_text: typeText,
|
|
@@ -1973,6 +2499,121 @@ var SHARED_TOOLS = {
|
|
|
1973
2499
|
handle_dialog: handleDialog
|
|
1974
2500
|
};
|
|
1975
2501
|
|
|
2502
|
+
// src/leases-tools.ts
|
|
2503
|
+
class LeaseToolError extends Error {
|
|
2504
|
+
}
|
|
2505
|
+
function backendOf2(driver) {
|
|
2506
|
+
return driver.scheme === "bidi" ? "firefox" : "chrome";
|
|
2507
|
+
}
|
|
2508
|
+
async function claimPage(driver, args = {}) {
|
|
2509
|
+
const hasTarget = typeof args.target === "string" && args.target.length > 0;
|
|
2510
|
+
const hasTargetId = typeof args.targetId === "string" && args.targetId.length > 0;
|
|
2511
|
+
if (hasTarget && hasTargetId) {
|
|
2512
|
+
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.");
|
|
2513
|
+
}
|
|
2514
|
+
const backend = backendOf2(driver);
|
|
2515
|
+
const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
|
|
2516
|
+
const pages = await driver.listPages();
|
|
2517
|
+
let targetId;
|
|
2518
|
+
let url;
|
|
2519
|
+
let opened;
|
|
2520
|
+
if (hasTarget) {
|
|
2521
|
+
const hit = await pickPage(driver, pages, args.target).catch((err) => {
|
|
2522
|
+
throw new LeaseToolError(`claim_page: no open tab matches target '${args.target}' (${err.message}). No tab was opened: claim_page never creates one when 'target' is given.`);
|
|
2523
|
+
});
|
|
2524
|
+
targetId = hit.id;
|
|
2525
|
+
url = hit.url;
|
|
2526
|
+
opened = false;
|
|
2527
|
+
} else if (hasTargetId) {
|
|
2528
|
+
const existing = pages.find((p) => p.id === args.targetId);
|
|
2529
|
+
if (!existing)
|
|
2530
|
+
throw new LeaseToolError(`claim_page: no page target with id '${args.targetId}'`);
|
|
2531
|
+
targetId = existing.id;
|
|
2532
|
+
url = existing.url;
|
|
2533
|
+
opened = false;
|
|
2534
|
+
} else {
|
|
2535
|
+
const page = await newTrackedPage(driver, backend, { url: args.url, label });
|
|
2536
|
+
targetId = page.id;
|
|
2537
|
+
url = page.url;
|
|
2538
|
+
opened = true;
|
|
2539
|
+
}
|
|
2540
|
+
const humanMs = opened ? null : await readHumanActiveMs(driver, backend, targetId);
|
|
2541
|
+
const liveIds = [...pages.map((p) => p.id), targetId];
|
|
2542
|
+
const { record, token } = await claimLease(backend, targetId, {
|
|
2543
|
+
label,
|
|
2544
|
+
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
|
|
2545
|
+
liveIds
|
|
2546
|
+
});
|
|
2547
|
+
await installBeacon(driver, targetId);
|
|
2548
|
+
const contention = contentionWarning(humanMs, { takeover: !opened });
|
|
2549
|
+
return {
|
|
2550
|
+
lease: token,
|
|
2551
|
+
targetId,
|
|
2552
|
+
url,
|
|
2553
|
+
label: record.label,
|
|
2554
|
+
ttlMs: record.ttlMs,
|
|
2555
|
+
expiresAt: record.lastUsedAt + record.ttlMs,
|
|
2556
|
+
opened,
|
|
2557
|
+
...beaconSupported(driver) ? { humanActiveMs: humanMs } : {},
|
|
2558
|
+
...contention !== undefined ? { contention } : {}
|
|
2559
|
+
};
|
|
2560
|
+
}
|
|
2561
|
+
async function releasePage(driver, args = {}) {
|
|
2562
|
+
const hasLease = typeof args.lease === "string" && args.lease.length > 0;
|
|
2563
|
+
const hasTarget = typeof args.target === "string" && args.target.length > 0;
|
|
2564
|
+
if (hasLease === hasTarget) {
|
|
2565
|
+
throw new LeaseToolError("release_page takes exactly one of 'lease' (the token claim_page returned) or 'target' (a selector for a tab this process holds)");
|
|
2566
|
+
}
|
|
2567
|
+
const backend = backendOf2(driver);
|
|
2568
|
+
let targetId;
|
|
2569
|
+
let released;
|
|
2570
|
+
if (hasLease) {
|
|
2571
|
+
const parts = tokenParts(args.lease);
|
|
2572
|
+
if (!parts)
|
|
2573
|
+
return { released: false, closed: false };
|
|
2574
|
+
targetId = parts.targetId;
|
|
2575
|
+
released = (await releaseLease(args.lease)).released;
|
|
2576
|
+
} else {
|
|
2577
|
+
const page = await resolvePage(driver, args.target);
|
|
2578
|
+
targetId = page.id;
|
|
2579
|
+
released = (await releaseLeaseFor(backend, targetId)).released;
|
|
2580
|
+
}
|
|
2581
|
+
if (!released)
|
|
2582
|
+
return { released: false, closed: false, ...hasTarget ? { targetId } : {} };
|
|
2583
|
+
const shouldClose = args.close === false ? false : args.close === true ? true : await readOrigin(backend, targetId) !== undefined;
|
|
2584
|
+
if (!shouldClose)
|
|
2585
|
+
return { released: true, closed: false, targetId };
|
|
2586
|
+
const res = await driver.closePage(targetId).catch(() => ({ success: false }));
|
|
2587
|
+
return { released: true, closed: res.success === true, targetId };
|
|
2588
|
+
}
|
|
2589
|
+
async function listLeasesTool(driver, _args = {}) {
|
|
2590
|
+
const backend = backendOf2(driver);
|
|
2591
|
+
const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
|
|
2592
|
+
const reapedIds = new Set(reaped.map((r) => r.targetId));
|
|
2593
|
+
const livePages = await driver.listPages();
|
|
2594
|
+
const liveIds = livePages.map((p) => p.id);
|
|
2595
|
+
const all = await listLeases({ liveIds, liveBackend: backend });
|
|
2596
|
+
const kept = all.filter((l) => !(l.backend === backend && reapedIds.has(l.targetId)));
|
|
2597
|
+
const liveSet = new Set(liveIds);
|
|
2598
|
+
const now = Date.now();
|
|
2599
|
+
const leases = [];
|
|
2600
|
+
for (const row of kept) {
|
|
2601
|
+
const computed = row.unreadable !== undefined ? row : { ...row, idleMs: now - row.lastUsedAt, expiresAt: row.lastUsedAt + row.ttlMs };
|
|
2602
|
+
if (row.backend !== backend || row.unreadable !== undefined || !liveSet.has(row.targetId)) {
|
|
2603
|
+
leases.push(computed);
|
|
2604
|
+
continue;
|
|
2605
|
+
}
|
|
2606
|
+
const humanMs = await readHumanActiveMs(driver, backend, row.targetId, now).catch(() => null);
|
|
2607
|
+
leases.push(humanMs === null ? computed : { ...computed, humanActiveMs: humanMs });
|
|
2608
|
+
}
|
|
2609
|
+
return { leases, count: leases.length, ...reaped.length ? { reaped } : {} };
|
|
2610
|
+
}
|
|
2611
|
+
var LEASE_TOOLS = {
|
|
2612
|
+
claim_page: claimPage,
|
|
2613
|
+
release_page: releasePage,
|
|
2614
|
+
list_leases: listLeasesTool
|
|
2615
|
+
};
|
|
2616
|
+
|
|
1976
2617
|
// src/tools/console.ts
|
|
1977
2618
|
import { readFile as readFile3 } from "node:fs/promises";
|
|
1978
2619
|
|
|
@@ -3120,6 +3761,273 @@ function truncate(s, max = 600) {
|
|
|
3120
3761
|
return t.length > max ? `${t.slice(0, max)}…` : t;
|
|
3121
3762
|
}
|
|
3122
3763
|
|
|
3764
|
+
// src/tools/dispatch-mouse.ts
|
|
3765
|
+
var CLICK_MODIFIER_BITS2 = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
|
|
3766
|
+
var BUTTONS = new Set(["left", "right", "middle"]);
|
|
3767
|
+
var CDP_EVENT_TYPE = {
|
|
3768
|
+
move: "mouseMoved",
|
|
3769
|
+
down: "mousePressed",
|
|
3770
|
+
up: "mouseReleased"
|
|
3771
|
+
};
|
|
3772
|
+
function modifierBits(mods) {
|
|
3773
|
+
let bits = 0;
|
|
3774
|
+
for (const m of mods ?? []) {
|
|
3775
|
+
const bit = CLICK_MODIFIER_BITS2[m];
|
|
3776
|
+
if (bit === undefined)
|
|
3777
|
+
throw new Error(`dispatch_mouse: unknown modifier '${m}' (use Alt, Control, Meta, or Shift)`);
|
|
3778
|
+
bits |= bit;
|
|
3779
|
+
}
|
|
3780
|
+
return bits;
|
|
3781
|
+
}
|
|
3782
|
+
function validateDispatchMouseArgs(args) {
|
|
3783
|
+
if (args.action !== "move" && args.action !== "down" && args.action !== "up") {
|
|
3784
|
+
throw new Error("dispatch_mouse requires { action } of 'move', 'down', or 'up'");
|
|
3785
|
+
}
|
|
3786
|
+
if (typeof args.x !== "number" || typeof args.y !== "number" || !Number.isFinite(args.x) || !Number.isFinite(args.y)) {
|
|
3787
|
+
throw new Error("dispatch_mouse requires numeric { x } and { y } on every call");
|
|
3788
|
+
}
|
|
3789
|
+
const button = args.button ?? "left";
|
|
3790
|
+
if (!BUTTONS.has(button))
|
|
3791
|
+
throw new Error(`dispatch_mouse: unknown button '${button}' (use left, right, or middle)`);
|
|
3792
|
+
}
|
|
3793
|
+
async function dispatchMouse(args) {
|
|
3794
|
+
validateDispatchMouseArgs(args);
|
|
3795
|
+
const button = args.button ?? "left";
|
|
3796
|
+
const modifiers = modifierBits(args.modifiers);
|
|
3797
|
+
return withPage(args.target, async (conn, target) => {
|
|
3798
|
+
const params = { type: CDP_EVENT_TYPE[args.action], x: args.x, y: args.y, button, modifiers };
|
|
3799
|
+
if (args.action !== "move")
|
|
3800
|
+
params.clickCount = args.clickCount ?? 1;
|
|
3801
|
+
await sendInput(conn, "chrome", target.id, "Input.dispatchMouseEvent", params);
|
|
3802
|
+
return { dispatched: args.action, x: args.x, y: args.y };
|
|
3803
|
+
});
|
|
3804
|
+
}
|
|
3805
|
+
|
|
3806
|
+
// src/tools/downloads.ts
|
|
3807
|
+
import { readdir as readdir3, rename, stat as stat4 } from "node:fs/promises";
|
|
3808
|
+
import { basename, extname, join as join8 } from "node:path";
|
|
3809
|
+
|
|
3810
|
+
// src/tools/browser-session.ts
|
|
3811
|
+
import { mkdir as mkdir9 } from "node:fs/promises";
|
|
3812
|
+
import { join as join7 } from "node:path";
|
|
3813
|
+
var ARTIFACT_DIR7 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
3814
|
+
var DOWNLOADS_DIR = join7(ARTIFACT_DIR7, "downloads");
|
|
3815
|
+
var DOWNLOAD_BUFFER_LIMIT = 50;
|
|
3816
|
+
var session;
|
|
3817
|
+
function isDisconnect(e) {
|
|
3818
|
+
const m = e instanceof Error ? e.message : String(e);
|
|
3819
|
+
return /connection (not open|closed)|closed by caller|websocket|ECONNREFUSED|socket/i.test(m);
|
|
3820
|
+
}
|
|
3821
|
+
function record(s, guid) {
|
|
3822
|
+
let r = s.downloads.find((d) => d.guid === guid);
|
|
3823
|
+
if (!r) {
|
|
3824
|
+
r = { guid, state: "inProgress", consumed: false };
|
|
3825
|
+
s.downloads.push(r);
|
|
3826
|
+
while (s.downloads.length > DOWNLOAD_BUFFER_LIMIT) {
|
|
3827
|
+
const i = s.downloads.findIndex((d) => d.consumed);
|
|
3828
|
+
s.downloads.splice(i >= 0 ? i : 0, 1);
|
|
3829
|
+
}
|
|
3830
|
+
}
|
|
3831
|
+
return r;
|
|
3832
|
+
}
|
|
3833
|
+
function subscribe(s) {
|
|
3834
|
+
s.conn.on("Browser.downloadWillBegin", (p) => {
|
|
3835
|
+
const r = record(s, String(p.guid));
|
|
3836
|
+
if (typeof p.url === "string")
|
|
3837
|
+
r.url = p.url;
|
|
3838
|
+
if (typeof p.suggestedFilename === "string")
|
|
3839
|
+
r.suggestedFilename = p.suggestedFilename;
|
|
3840
|
+
});
|
|
3841
|
+
s.conn.on("Browser.downloadProgress", (p) => {
|
|
3842
|
+
const r = record(s, String(p.guid));
|
|
3843
|
+
if (typeof p.receivedBytes === "number")
|
|
3844
|
+
r.bytes = p.receivedBytes;
|
|
3845
|
+
if (typeof p.filePath === "string")
|
|
3846
|
+
r.filePath = p.filePath;
|
|
3847
|
+
const state = String(p.state);
|
|
3848
|
+
if (state === "completed" || state === "canceled") {
|
|
3849
|
+
r.state = state;
|
|
3850
|
+
if (state === "completed") {
|
|
3851
|
+
const waiter = s.waiters.shift();
|
|
3852
|
+
if (waiter) {
|
|
3853
|
+
r.consumed = true;
|
|
3854
|
+
waiter(r);
|
|
3855
|
+
}
|
|
3856
|
+
}
|
|
3857
|
+
}
|
|
3858
|
+
});
|
|
3859
|
+
}
|
|
3860
|
+
async function getBrowserSession() {
|
|
3861
|
+
if (session && !session.dead)
|
|
3862
|
+
return session;
|
|
3863
|
+
const previousPath = session?.armedPath;
|
|
3864
|
+
const conn = await openBrowser();
|
|
3865
|
+
const s = { conn, downloads: [], waiters: [], dead: false };
|
|
3866
|
+
subscribe(s);
|
|
3867
|
+
session = s;
|
|
3868
|
+
if (previousPath)
|
|
3869
|
+
await armDownloadsAt(previousPath);
|
|
3870
|
+
return s;
|
|
3871
|
+
}
|
|
3872
|
+
async function browserSend(method, params = {}) {
|
|
3873
|
+
const s = await getBrowserSession();
|
|
3874
|
+
try {
|
|
3875
|
+
return await s.conn.send(method, params);
|
|
3876
|
+
} catch (e) {
|
|
3877
|
+
if (isDisconnect(e)) {
|
|
3878
|
+
s.dead = true;
|
|
3879
|
+
const fresh = await getBrowserSession();
|
|
3880
|
+
return await fresh.conn.send(method, params);
|
|
3881
|
+
}
|
|
3882
|
+
throw e;
|
|
3883
|
+
}
|
|
3884
|
+
}
|
|
3885
|
+
async function armDownloadsAt(path) {
|
|
3886
|
+
await mkdir9(path, { recursive: true });
|
|
3887
|
+
const s = await getBrowserSession();
|
|
3888
|
+
await s.conn.send("Browser.setDownloadBehavior", { behavior: "allowAndName", downloadPath: path, eventsEnabled: true });
|
|
3889
|
+
s.armedPath = path;
|
|
3890
|
+
}
|
|
3891
|
+
async function armDownloads() {
|
|
3892
|
+
const s = await getBrowserSession();
|
|
3893
|
+
if (s.armedPath !== DOWNLOADS_DIR)
|
|
3894
|
+
await armDownloadsAt(DOWNLOADS_DIR);
|
|
3895
|
+
return { downloadPath: DOWNLOADS_DIR };
|
|
3896
|
+
}
|
|
3897
|
+
function pendingDownloads() {
|
|
3898
|
+
return (session?.downloads ?? []).filter((d) => d.state === "completed" && !d.consumed);
|
|
3899
|
+
}
|
|
3900
|
+
async function nextCompletedDownload(timeoutMs) {
|
|
3901
|
+
const s = await getBrowserSession();
|
|
3902
|
+
const buffered = s.downloads.find((d) => d.state === "completed" && !d.consumed);
|
|
3903
|
+
if (buffered) {
|
|
3904
|
+
buffered.consumed = true;
|
|
3905
|
+
return buffered;
|
|
3906
|
+
}
|
|
3907
|
+
return new Promise((resolve, reject) => {
|
|
3908
|
+
const waiter = (r) => {
|
|
3909
|
+
clearTimeout(timer);
|
|
3910
|
+
resolve(r);
|
|
3911
|
+
};
|
|
3912
|
+
const timer = setTimeout(() => {
|
|
3913
|
+
const i = s.waiters.indexOf(waiter);
|
|
3914
|
+
if (i >= 0)
|
|
3915
|
+
s.waiters.splice(i, 1);
|
|
3916
|
+
reject(new Error(`wait_for_download: no download completed within ${timeoutMs}ms. Downloads are only captured while this server holds the arming connection, so the triggering click must come AFTER a wait_for_download call (use arm:true to arm without waiting).`));
|
|
3917
|
+
}, timeoutMs);
|
|
3918
|
+
s.waiters.push(waiter);
|
|
3919
|
+
});
|
|
3920
|
+
}
|
|
3921
|
+
async function disposeBrowserSession() {
|
|
3922
|
+
const s = session;
|
|
3923
|
+
session = undefined;
|
|
3924
|
+
if (!s)
|
|
3925
|
+
return;
|
|
3926
|
+
s.dead = true;
|
|
3927
|
+
try {
|
|
3928
|
+
s.conn.close();
|
|
3929
|
+
} catch {}
|
|
3930
|
+
}
|
|
3931
|
+
|
|
3932
|
+
// src/tools/downloads.ts
|
|
3933
|
+
function safeFilename(suggested) {
|
|
3934
|
+
const raw = (suggested ?? "").replace(/[\u0000-\u001f]/g, "");
|
|
3935
|
+
const name = basename(raw.split("\\").pop() ?? "").trim();
|
|
3936
|
+
if (!name || name === "." || name === "..")
|
|
3937
|
+
return "download";
|
|
3938
|
+
return name;
|
|
3939
|
+
}
|
|
3940
|
+
function nextFreeName(name, taken) {
|
|
3941
|
+
if (!taken.has(name))
|
|
3942
|
+
return name;
|
|
3943
|
+
const ext = extname(name);
|
|
3944
|
+
const stem = ext ? name.slice(0, -ext.length) : name;
|
|
3945
|
+
for (let i = 1;; i++) {
|
|
3946
|
+
const candidate = `${stem}-${i}${ext}`;
|
|
3947
|
+
if (!taken.has(candidate))
|
|
3948
|
+
return candidate;
|
|
3949
|
+
}
|
|
3950
|
+
}
|
|
3951
|
+
function validateWaitForDownloadArgs(args) {
|
|
3952
|
+
if (args.timeoutMs !== undefined) {
|
|
3953
|
+
if (typeof args.timeoutMs !== "number" || !Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0) {
|
|
3954
|
+
throw new Error(`wait_for_download: 'timeoutMs' must be a positive number (got ${JSON.stringify(args.timeoutMs)})`);
|
|
3955
|
+
}
|
|
3956
|
+
}
|
|
3957
|
+
if (args.arm !== undefined && typeof args.arm !== "boolean") {
|
|
3958
|
+
throw new Error(`wait_for_download: 'arm' must be a boolean (got ${JSON.stringify(args.arm)})`);
|
|
3959
|
+
}
|
|
3960
|
+
}
|
|
3961
|
+
async function waitForDownload(args = {}) {
|
|
3962
|
+
validateWaitForDownloadArgs(args);
|
|
3963
|
+
const t = await resolveTarget(args.target);
|
|
3964
|
+
const target = { id: t.id, url: t.url, title: t.title };
|
|
3965
|
+
const { downloadPath } = await armDownloads();
|
|
3966
|
+
if (args.arm === true)
|
|
3967
|
+
return { armed: true, downloadPath, pending: pendingDownloads().length, target };
|
|
3968
|
+
const rec = await nextCompletedDownload(args.timeoutMs ?? 30000);
|
|
3969
|
+
if (!rec.filePath) {
|
|
3970
|
+
throw new Error(`wait_for_download: download ${rec.guid} completed without a file path (nothing to return)`);
|
|
3971
|
+
}
|
|
3972
|
+
const suggestedFilename = safeFilename(rec.suggestedFilename);
|
|
3973
|
+
const taken = new Set(await readdir3(DOWNLOADS_DIR).catch(() => []));
|
|
3974
|
+
taken.delete(basename(rec.filePath));
|
|
3975
|
+
const finalName = nextFreeName(suggestedFilename, taken);
|
|
3976
|
+
const path = join8(DOWNLOADS_DIR, finalName);
|
|
3977
|
+
await rename(rec.filePath, path);
|
|
3978
|
+
const bytes = (await stat4(path)).size;
|
|
3979
|
+
return { path, suggestedFilename, bytes, ...rec.url ? { url: rec.url } : {}, target };
|
|
3980
|
+
}
|
|
3981
|
+
|
|
3982
|
+
// src/tools/permissions.ts
|
|
3983
|
+
function originOf(url) {
|
|
3984
|
+
try {
|
|
3985
|
+
const o = new URL(url).origin;
|
|
3986
|
+
return o && o !== "null" ? o : undefined;
|
|
3987
|
+
} catch {
|
|
3988
|
+
return;
|
|
3989
|
+
}
|
|
3990
|
+
}
|
|
3991
|
+
function validateGrantPermissionsArgs(args) {
|
|
3992
|
+
const reset = args.reset === true;
|
|
3993
|
+
if (args.reset !== undefined && typeof args.reset !== "boolean") {
|
|
3994
|
+
throw new Error(`grant_permissions: 'reset' must be a boolean (got ${JSON.stringify(args.reset)})`);
|
|
3995
|
+
}
|
|
3996
|
+
if (args.permissions === undefined) {
|
|
3997
|
+
if (!reset)
|
|
3998
|
+
throw new Error("grant_permissions: 'permissions' is required (or pass reset:true to clear previous grants)");
|
|
3999
|
+
return;
|
|
4000
|
+
}
|
|
4001
|
+
if (!Array.isArray(args.permissions)) {
|
|
4002
|
+
throw new Error(`grant_permissions: 'permissions' must be an array of CDP PermissionType strings (got ${JSON.stringify(args.permissions)})`);
|
|
4003
|
+
}
|
|
4004
|
+
if (args.permissions.length === 0 && !reset) {
|
|
4005
|
+
throw new Error("grant_permissions: 'permissions' must not be empty (pass reset:true if the intent is to clear grants)");
|
|
4006
|
+
}
|
|
4007
|
+
for (const p of args.permissions) {
|
|
4008
|
+
if (typeof p !== "string" || p.length === 0) {
|
|
4009
|
+
throw new Error(`grant_permissions: every entry of 'permissions' must be a non-empty string (got ${JSON.stringify(p)})`);
|
|
4010
|
+
}
|
|
4011
|
+
}
|
|
4012
|
+
}
|
|
4013
|
+
async function grantPermissions(args = {}) {
|
|
4014
|
+
validateGrantPermissionsArgs(args);
|
|
4015
|
+
const t = await resolveTarget(args.target);
|
|
4016
|
+
const target = { id: t.id, url: t.url, title: t.title };
|
|
4017
|
+
const reset = args.reset === true;
|
|
4018
|
+
const permissions = args.permissions ?? [];
|
|
4019
|
+
if (reset)
|
|
4020
|
+
await browserSend("Browser.resetPermissions", {});
|
|
4021
|
+
if (permissions.length === 0)
|
|
4022
|
+
return { reset: true, target };
|
|
4023
|
+
const origin = args.origin ?? originOf(t.url);
|
|
4024
|
+
if (!origin) {
|
|
4025
|
+
throw new Error(`grant_permissions: cannot derive an origin from the target tab's url (${JSON.stringify(t.url)}); pass 'origin' explicitly, e.g. origin:"https://example.com"`);
|
|
4026
|
+
}
|
|
4027
|
+
await browserSend("Browser.grantPermissions", { origin, permissions });
|
|
4028
|
+
return { granted: [...permissions], origin, ...reset ? { reset: true } : {}, target };
|
|
4029
|
+
}
|
|
4030
|
+
|
|
3123
4031
|
// src/index.ts
|
|
3124
4032
|
var cdpDriver = createCdpDriver();
|
|
3125
4033
|
function onCdp(fn) {
|
|
@@ -3140,6 +4048,7 @@ var TOOLS = {
|
|
|
3140
4048
|
click: onCdp(SHARED_TOOLS.click),
|
|
3141
4049
|
hover: onCdp(SHARED_TOOLS.hover),
|
|
3142
4050
|
drag: onCdp(SHARED_TOOLS.drag),
|
|
4051
|
+
scroll: onCdp(SHARED_TOOLS.scroll),
|
|
3143
4052
|
fill: onCdp(SHARED_TOOLS.fill),
|
|
3144
4053
|
fill_form: onCdp(SHARED_TOOLS.fill_form),
|
|
3145
4054
|
type_text: onCdp(SHARED_TOOLS.type_text),
|
|
@@ -3164,6 +4073,9 @@ var TOOLS = {
|
|
|
3164
4073
|
mock_request: mockRequest,
|
|
3165
4074
|
list_mocks: listMocks,
|
|
3166
4075
|
clear_mocks: clearMocks,
|
|
4076
|
+
dispatch_mouse: dispatchMouse,
|
|
4077
|
+
wait_for_download: waitForDownload,
|
|
4078
|
+
grant_permissions: grantPermissions,
|
|
3167
4079
|
claim_page: onCdp(LEASE_TOOLS.claim_page),
|
|
3168
4080
|
release_page: onCdp(LEASE_TOOLS.release_page),
|
|
3169
4081
|
list_leases: onCdp(LEASE_TOOLS.list_leases)
|