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