cdp-toolkit 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +403 -0
- package/CONTRACT.md +9 -1
- package/README.md +58 -17
- package/dist/cli.js +1302 -158
- package/dist/index.js +1144 -145
- package/dist/mcp.js +1620 -244
- package/package.json +6 -3
- package/src/activity.ts +533 -0
- package/src/bidi/driver.ts +259 -8
- package/src/capabilities.ts +1 -1
- package/src/cdp/driver.ts +487 -25
- package/src/cdp/workers.ts +177 -0
- package/src/cli.ts +3 -1
- package/src/client.ts +52 -2
- package/src/driver.ts +187 -3
- package/src/index.ts +26 -4
- package/src/leases-tools.ts +127 -6
- package/src/leases.ts +1 -1
- package/src/manifest.ts +279 -46
- package/src/mcp.ts +6 -1
- package/src/origins.ts +41 -1
- package/src/reap.ts +55 -4
- package/src/shared-tools.ts +372 -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/src/types.ts +20 -0
- package/src/workers.ts +159 -0
package/dist/index.js
CHANGED
|
@@ -276,6 +276,169 @@ async function listLeases(opts = {}) {
|
|
|
276
276
|
return out;
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
// src/origins.ts
|
|
280
|
+
import { mkdir as mkdir2, readFile as readFile2, readdir as readdir2, unlink as unlink2, writeFile as writeFile2 } from "node:fs/promises";
|
|
281
|
+
import { join as join2 } from "node:path";
|
|
282
|
+
function safeId2(id) {
|
|
283
|
+
return id.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
284
|
+
}
|
|
285
|
+
function originFile(backend, targetId) {
|
|
286
|
+
return join2(leaseDir(), `origin-${backend}-${safeId2(targetId)}.json`);
|
|
287
|
+
}
|
|
288
|
+
var ORIGIN_FILE_RE = /^origin-(chrome|firefox)-(.+)\.json$/;
|
|
289
|
+
async function recordOrigin(backend, targetId, opts = {}) {
|
|
290
|
+
const record = {
|
|
291
|
+
backend,
|
|
292
|
+
targetId,
|
|
293
|
+
label: typeof opts.label === "string" && opts.label.length ? opts.label : defaultLabel(),
|
|
294
|
+
pid: process.pid,
|
|
295
|
+
createdAt: opts.now ?? Date.now()
|
|
296
|
+
};
|
|
297
|
+
const file = originFile(backend, targetId);
|
|
298
|
+
try {
|
|
299
|
+
await mkdir2(leaseDir(), { recursive: true });
|
|
300
|
+
try {
|
|
301
|
+
await writeFile2(file, JSON.stringify(record), { flag: "wx" });
|
|
302
|
+
return;
|
|
303
|
+
} catch (err) {
|
|
304
|
+
if (err.code !== "EEXIST")
|
|
305
|
+
throw err;
|
|
306
|
+
}
|
|
307
|
+
await unlink2(file).catch(() => {
|
|
308
|
+
return;
|
|
309
|
+
});
|
|
310
|
+
await writeFile2(file, JSON.stringify(record), { flag: "wx" });
|
|
311
|
+
} catch {}
|
|
312
|
+
}
|
|
313
|
+
async function readOrigin(backend, targetId) {
|
|
314
|
+
let raw;
|
|
315
|
+
try {
|
|
316
|
+
raw = await readFile2(originFile(backend, targetId), "utf8");
|
|
317
|
+
} catch {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
const rec = JSON.parse(raw);
|
|
322
|
+
if (typeof rec?.targetId === "string" && typeof rec?.label === "string" && typeof rec?.createdAt === "number")
|
|
323
|
+
return rec;
|
|
324
|
+
} catch {}
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
async function listOrigins(opts = {}) {
|
|
328
|
+
let names = [];
|
|
329
|
+
try {
|
|
330
|
+
names = await readdir2(leaseDir());
|
|
331
|
+
} catch {
|
|
332
|
+
return [];
|
|
333
|
+
}
|
|
334
|
+
const live = opts.liveIds === undefined ? undefined : new Set(opts.liveIds.map((id) => safeId2(id)));
|
|
335
|
+
const out = [];
|
|
336
|
+
for (const name of names) {
|
|
337
|
+
const parts = ORIGIN_FILE_RE.exec(name);
|
|
338
|
+
if (!parts)
|
|
339
|
+
continue;
|
|
340
|
+
const backend = parts[1];
|
|
341
|
+
const nameId = parts[2];
|
|
342
|
+
const scoped = live !== undefined && (opts.liveBackend === undefined || opts.liveBackend === backend);
|
|
343
|
+
if (scoped && !live.has(nameId)) {
|
|
344
|
+
await unlink2(join2(leaseDir(), name)).catch(() => {
|
|
345
|
+
return;
|
|
346
|
+
});
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
const fromName = { backend, targetId: nameId, label: "", pid: 0, createdAt: 0 };
|
|
350
|
+
let rec;
|
|
351
|
+
try {
|
|
352
|
+
rec = JSON.parse(await readFile2(join2(leaseDir(), name), "utf8"));
|
|
353
|
+
} catch (err) {
|
|
354
|
+
const code = err.code;
|
|
355
|
+
if (code === "ENOENT")
|
|
356
|
+
continue;
|
|
357
|
+
out.push({ ...fromName, unreadable: code ?? "unparseable" });
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (typeof rec?.targetId !== "string" || typeof rec?.label !== "string" || typeof rec?.createdAt !== "number") {
|
|
361
|
+
out.push({ ...fromName, unreadable: "unparseable" });
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
out.push({ backend: rec.backend, targetId: rec.targetId, label: rec.label, pid: rec.pid, createdAt: rec.createdAt });
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
async function originIndex(backend, liveIds) {
|
|
369
|
+
const rows = await listOrigins({ liveIds, liveBackend: backend }).catch(() => []);
|
|
370
|
+
const index = new Map;
|
|
371
|
+
for (const row of rows) {
|
|
372
|
+
if (row.backend !== backend)
|
|
373
|
+
continue;
|
|
374
|
+
index.set(row.targetId, row);
|
|
375
|
+
}
|
|
376
|
+
return index;
|
|
377
|
+
}
|
|
378
|
+
async function newTrackedPage(driver, backend, opts = {}) {
|
|
379
|
+
const page = await driver.newPage(opts.url);
|
|
380
|
+
await recordOrigin(backend, page.id, { label: opts.label });
|
|
381
|
+
return page;
|
|
382
|
+
}
|
|
383
|
+
async function resolveLiveLabel(backend, label, liveIds) {
|
|
384
|
+
const live = new Set(liveIds);
|
|
385
|
+
const [origins, leases] = await Promise.all([originIndex(backend), listLeases({ liveBackend: backend })]);
|
|
386
|
+
const knownLabels = new Set;
|
|
387
|
+
const matchIds = new Set;
|
|
388
|
+
for (const rec of origins.values()) {
|
|
389
|
+
if (rec.unreadable !== undefined || !live.has(rec.targetId))
|
|
390
|
+
continue;
|
|
391
|
+
knownLabels.add(rec.label);
|
|
392
|
+
if (rec.label === label)
|
|
393
|
+
matchIds.add(rec.targetId);
|
|
394
|
+
}
|
|
395
|
+
for (const rec of leases) {
|
|
396
|
+
if (rec.backend !== backend || rec.unreadable !== undefined || !live.has(rec.targetId))
|
|
397
|
+
continue;
|
|
398
|
+
knownLabels.add(rec.label);
|
|
399
|
+
if (rec.label === label)
|
|
400
|
+
matchIds.add(rec.targetId);
|
|
401
|
+
}
|
|
402
|
+
return { matchIds: [...matchIds], knownLabels: [...knownLabels].sort() };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// src/workers.ts
|
|
406
|
+
var WORKER_SELECTOR_PREFIX = "worker:";
|
|
407
|
+
var WORKER_TARGET_TYPES = new Set(["service_worker", "shared_worker"]);
|
|
408
|
+
function isWorkerSelector(selector) {
|
|
409
|
+
return typeof selector === "string" && selector.startsWith(WORKER_SELECTOR_PREFIX);
|
|
410
|
+
}
|
|
411
|
+
function workerNeedle(selector) {
|
|
412
|
+
return selector.slice(WORKER_SELECTOR_PREFIX.length);
|
|
413
|
+
}
|
|
414
|
+
function isWorkerTargetType(type) {
|
|
415
|
+
return type !== undefined && WORKER_TARGET_TYPES.has(type);
|
|
416
|
+
}
|
|
417
|
+
function resolveWorkerTargets(targets, needle) {
|
|
418
|
+
const liveWorkers = targets.filter((t) => isWorkerTargetType(t.type));
|
|
419
|
+
return {
|
|
420
|
+
matches: needle === "" ? [] : liveWorkers.filter((t) => t.url.includes(needle)),
|
|
421
|
+
liveWorkers
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
var WORKER_EMPTY_NEEDLE_MESSAGE = "'worker:' needs a substring of the worker's url, e.g. worker:<extension-id> or worker:background.js";
|
|
425
|
+
var WORKER_SELECTOR_UNSUPPORTED_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, which this backend cannot address " + "(WebDriver BiDi has no extension service-worker target; Capability 'worker.targets' is Chrome-only). " + "Use --browser chrome to evaluate in an extension's background worker.";
|
|
426
|
+
var WORKER_SELECTOR_PAGE_ONLY_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, and this tool is page-only " + "(closing, selecting or releasing a worker is meaningless). evaluate_script is the one tool that " + "accepts a worker: target.";
|
|
427
|
+
function urlList(workers) {
|
|
428
|
+
return workers.map((w) => w.url).join(", ");
|
|
429
|
+
}
|
|
430
|
+
function workerAmbiguityMessage(needle, matches) {
|
|
431
|
+
return `'worker:${needle}' matches more than one running worker: ` + matches.map((m) => `${m.url} (${m.id})`).join(", ") + ". Narrow the substring, or pass the worker's target id directly.";
|
|
432
|
+
}
|
|
433
|
+
function workerMissMessage(needle, opts) {
|
|
434
|
+
const running = opts.liveWorkers?.length ? ` (running workers: ${urlList(opts.liveWorkers)})` : " (no workers are running)";
|
|
435
|
+
if (!opts.wakeAttempted) {
|
|
436
|
+
return `no running worker whose url contains '${needle}'${running}. ` + "An MV3 extension service worker is idle-evicted after seconds of inactivity and then appears in NO target " + "listing, so this is usually a sleeping worker rather than a wrong substring. You passed wake:false; " + "wake:true (the default) asks Chrome to start it first.";
|
|
437
|
+
}
|
|
438
|
+
const scopes = opts.registeredScopes?.length ? ` Registered service-worker scopes in this browser: ${opts.registeredScopes.join(", ")}.` : " No service-worker registrations were reported by this browser at all.";
|
|
439
|
+
return `no worker whose url contains '${needle}' could be reached${running}. ` + `A start was requested (wake:true) and no matching worker appeared.${scopes} ` + "If the extension is installed but its worker never starts, check the worker script for a top-level throw.";
|
|
440
|
+
}
|
|
441
|
+
|
|
279
442
|
// src/client.ts
|
|
280
443
|
var BASE = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
|
|
281
444
|
var DEFAULT_TIMEOUT_MS = Number(process.env.CDP_TIMEOUT_MS ?? 15000);
|
|
@@ -431,11 +594,13 @@ async function browserWsUrl() {
|
|
|
431
594
|
}
|
|
432
595
|
async function resolveTarget(selector, opts = {}) {
|
|
433
596
|
const targets = await listTargets();
|
|
434
|
-
const hit = pickTarget(targets, selector);
|
|
597
|
+
const hit = await pickTarget(targets, selector);
|
|
598
|
+
if (isWorkerTargetType(hit.type))
|
|
599
|
+
return hit;
|
|
435
600
|
await assertLeaseOk("chrome", hit.id, { lease: opts.lease, url: hit.url, title: hit.title });
|
|
436
601
|
return hit;
|
|
437
602
|
}
|
|
438
|
-
function pickTarget(targets, selector) {
|
|
603
|
+
async function pickTarget(targets, selector) {
|
|
439
604
|
const pages = targets.filter((t) => t.type === "page");
|
|
440
605
|
if (!selector || selector === "active") {
|
|
441
606
|
if (!pages.length)
|
|
@@ -462,6 +627,30 @@ function pickTarget(targets, selector) {
|
|
|
462
627
|
throw new CdpError(`no page title containing '${needle}'`);
|
|
463
628
|
return hit;
|
|
464
629
|
}
|
|
630
|
+
if (selector.startsWith("label:")) {
|
|
631
|
+
const label = selector.slice(6);
|
|
632
|
+
const { matchIds, knownLabels } = await resolveLiveLabel("chrome", label, pages.map((t) => t.id));
|
|
633
|
+
if (matchIds.length > 1) {
|
|
634
|
+
throw new CdpError(`label 'label:${label}' matches more than one live target: ${matchIds.join(", ")}. Resolve by target id instead.`);
|
|
635
|
+
}
|
|
636
|
+
if (matchIds.length === 1) {
|
|
637
|
+
const hit = pages.find((t) => t.id === matchIds[0]);
|
|
638
|
+
if (hit)
|
|
639
|
+
return hit;
|
|
640
|
+
}
|
|
641
|
+
throw new CdpError(knownLabels.length ? `no live target with label '${label}' (labels currently in use: ${knownLabels.join(", ")})` : `no live target with label '${label}' (no labels are currently assigned to any open target)`);
|
|
642
|
+
}
|
|
643
|
+
if (isWorkerSelector(selector)) {
|
|
644
|
+
const needle = workerNeedle(selector);
|
|
645
|
+
if (needle === "")
|
|
646
|
+
throw new CdpError(WORKER_EMPTY_NEEDLE_MESSAGE);
|
|
647
|
+
const { matches, liveWorkers } = resolveWorkerTargets(targets, needle);
|
|
648
|
+
if (matches.length > 1)
|
|
649
|
+
throw new CdpError(workerAmbiguityMessage(needle, matches));
|
|
650
|
+
if (matches.length === 1)
|
|
651
|
+
return matches[0];
|
|
652
|
+
throw new CdpError(workerMissMessage(needle, { wakeAttempted: false, liveWorkers }));
|
|
653
|
+
}
|
|
465
654
|
const byId = targets.find((t) => t.id === selector);
|
|
466
655
|
if (!byId)
|
|
467
656
|
throw new CdpError(`no target with id '${selector}'`);
|
|
@@ -484,6 +673,141 @@ async function withPage(selector, fn, opts = {}) {
|
|
|
484
673
|
}
|
|
485
674
|
}
|
|
486
675
|
|
|
676
|
+
// src/activity.ts
|
|
677
|
+
var BEACON_DATA_GLOBAL = "__cdpToolkitLastInput";
|
|
678
|
+
var BEACON_INSTALLED_GLOBAL = "__cdpToolkitBeacon";
|
|
679
|
+
var BEACON_EVENTS = ["pointerdown", "mousedown", "keydown", "wheel", "touchstart"];
|
|
680
|
+
var BEACON_BODY = `
|
|
681
|
+
if (!window.${BEACON_INSTALLED_GLOBAL}) {
|
|
682
|
+
window.${BEACON_INSTALLED_GLOBAL} = true;
|
|
683
|
+
var mark = function () { window.${BEACON_DATA_GLOBAL} = Date.now(); };
|
|
684
|
+
var events = ${JSON.stringify([...BEACON_EVENTS])};
|
|
685
|
+
for (var i = 0; i < events.length; i++) {
|
|
686
|
+
window.addEventListener(events[i], mark, { capture: true, passive: true });
|
|
687
|
+
}
|
|
688
|
+
}`;
|
|
689
|
+
var BEACON_SOURCE = `(function () {${BEACON_BODY}
|
|
690
|
+
})();`;
|
|
691
|
+
var BEACON_FUNCTION_DECLARATION = `function () {${BEACON_BODY}
|
|
692
|
+
}`;
|
|
693
|
+
var BEACON_READ_EXPRESSION = `(typeof window.${BEACON_DATA_GLOBAL} === "number" ? window.${BEACON_DATA_GLOBAL} : null)`;
|
|
694
|
+
var DISPATCH_ATTRIBUTION_WINDOW_MS = 1500;
|
|
695
|
+
function dispatchKey(backend, targetId) {
|
|
696
|
+
return `${backend}:${targetId}`;
|
|
697
|
+
}
|
|
698
|
+
var dispatchLog = new Map;
|
|
699
|
+
var DISPATCH_LOG_LIMIT = 512;
|
|
700
|
+
function recordDispatch(backend, targetId, now = Date.now()) {
|
|
701
|
+
const key = dispatchKey(backend, targetId);
|
|
702
|
+
dispatchLog.delete(key);
|
|
703
|
+
dispatchLog.set(key, now);
|
|
704
|
+
while (dispatchLog.size > DISPATCH_LOG_LIMIT) {
|
|
705
|
+
const oldest = dispatchLog.keys().next();
|
|
706
|
+
if (oldest.done)
|
|
707
|
+
break;
|
|
708
|
+
dispatchLog.delete(oldest.value);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
var NON_SYNTHESIZING_INPUT_METHODS = new Set(["Input.setInterceptDrags", "Input.setIgnoreInputEvents"]);
|
|
712
|
+
async function sendInput(conn, backend, targetId, method, params = {}) {
|
|
713
|
+
if (!NON_SYNTHESIZING_INPUT_METHODS.has(method))
|
|
714
|
+
recordDispatch(backend, targetId);
|
|
715
|
+
return conn.send(method, params);
|
|
716
|
+
}
|
|
717
|
+
function lastDispatchAt(backend, targetId) {
|
|
718
|
+
return dispatchLog.get(dispatchKey(backend, targetId));
|
|
719
|
+
}
|
|
720
|
+
function isHumanAttributed(beaconTs, dispatchAt, windowMs = DISPATCH_ATTRIBUTION_WINDOW_MS) {
|
|
721
|
+
if (typeof beaconTs !== "number" || !Number.isFinite(beaconTs))
|
|
722
|
+
return false;
|
|
723
|
+
return beaconTs > (dispatchAt ?? 0) + windowMs;
|
|
724
|
+
}
|
|
725
|
+
function humanActiveMs(beaconTs, dispatchAt, now = Date.now(), windowMs = DISPATCH_ATTRIBUTION_WINDOW_MS) {
|
|
726
|
+
if (!isHumanAttributed(beaconTs, dispatchAt, windowMs))
|
|
727
|
+
return null;
|
|
728
|
+
return Math.max(0, now - beaconTs);
|
|
729
|
+
}
|
|
730
|
+
var CONTENTION_WINDOW_MS = 30000;
|
|
731
|
+
function contentionWarning(ms, opts) {
|
|
732
|
+
if (!opts.takeover)
|
|
733
|
+
return;
|
|
734
|
+
if (ms === null || ms >= CONTENTION_WINDOW_MS)
|
|
735
|
+
return;
|
|
736
|
+
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.`;
|
|
737
|
+
}
|
|
738
|
+
function beaconSupported(driver) {
|
|
739
|
+
return typeof driver.installActivityBeacon === "function" && typeof driver.readActivityBeacon === "function";
|
|
740
|
+
}
|
|
741
|
+
async function installBeacon(driver, targetId) {
|
|
742
|
+
if (typeof driver.installActivityBeacon !== "function")
|
|
743
|
+
return false;
|
|
744
|
+
return driver.installActivityBeacon(targetId).catch(() => false);
|
|
745
|
+
}
|
|
746
|
+
async function readHumanActiveMs(driver, backend, targetId, now = Date.now()) {
|
|
747
|
+
if (typeof driver.readActivityBeacon !== "function")
|
|
748
|
+
return null;
|
|
749
|
+
const beaconTs = await driver.readActivityBeacon(targetId).catch(() => null);
|
|
750
|
+
return humanActiveMs(beaconTs, lastDispatchAt(backend, targetId), now);
|
|
751
|
+
}
|
|
752
|
+
var RENDERER_PROBE_TIMEOUT_MS = 500;
|
|
753
|
+
var RENDERER_PROBE_EXPRESSION = `[1, ${BEACON_READ_EXPRESSION}]`;
|
|
754
|
+
function rendererProbeSupported(driver) {
|
|
755
|
+
return typeof driver.probeRenderer === "function";
|
|
756
|
+
}
|
|
757
|
+
async function probeRendererActivity(driver, backend, targetId, now = Date.now()) {
|
|
758
|
+
if (typeof driver.probeRenderer !== "function")
|
|
759
|
+
return { responsive: false };
|
|
760
|
+
const { responsive, beaconTs } = await driver.probeRenderer(targetId, RENDERER_PROBE_TIMEOUT_MS).catch(() => ({ responsive: false, beaconTs: null }));
|
|
761
|
+
const ms = humanActiveMs(beaconTs, lastDispatchAt(backend, targetId), now);
|
|
762
|
+
return ms === null ? { responsive } : { responsive, humanActiveMs: ms };
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
class BeaconSessions {
|
|
766
|
+
close;
|
|
767
|
+
limit;
|
|
768
|
+
live = new Map;
|
|
769
|
+
constructor(close, limit = 32) {
|
|
770
|
+
this.close = close;
|
|
771
|
+
this.limit = limit;
|
|
772
|
+
}
|
|
773
|
+
get(key) {
|
|
774
|
+
return this.live.get(key);
|
|
775
|
+
}
|
|
776
|
+
set(key, value) {
|
|
777
|
+
const previous = this.live.get(key);
|
|
778
|
+
if (previous !== undefined) {
|
|
779
|
+
this.live.delete(key);
|
|
780
|
+
this.safeClose(previous);
|
|
781
|
+
}
|
|
782
|
+
this.live.set(key, value);
|
|
783
|
+
while (this.live.size > this.limit) {
|
|
784
|
+
const oldest = this.live.keys().next();
|
|
785
|
+
if (oldest.done)
|
|
786
|
+
break;
|
|
787
|
+
this.drop(oldest.value);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
drop(key) {
|
|
791
|
+
const value = this.live.get(key);
|
|
792
|
+
if (value === undefined)
|
|
793
|
+
return;
|
|
794
|
+
this.live.delete(key);
|
|
795
|
+
this.safeClose(value);
|
|
796
|
+
}
|
|
797
|
+
clear() {
|
|
798
|
+
for (const key of [...this.live.keys()])
|
|
799
|
+
this.drop(key);
|
|
800
|
+
}
|
|
801
|
+
get size() {
|
|
802
|
+
return this.live.size;
|
|
803
|
+
}
|
|
804
|
+
safeClose(value) {
|
|
805
|
+
try {
|
|
806
|
+
this.close(value);
|
|
807
|
+
} catch {}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
487
811
|
// src/tools/snapshot.ts
|
|
488
812
|
var INTERACTIVE_ROLES = new Set([
|
|
489
813
|
"button",
|
|
@@ -516,6 +840,91 @@ async function resolveUid(conn, uid) {
|
|
|
516
840
|
return { objectId: object.objectId };
|
|
517
841
|
}
|
|
518
842
|
|
|
843
|
+
// src/cdp/workers.ts
|
|
844
|
+
var INVENTORY_FIRST_EVENT_MS = 1500;
|
|
845
|
+
var INVENTORY_SETTLE_MS = 250;
|
|
846
|
+
var WAKE_POLL_MS = 300;
|
|
847
|
+
var WAKE_POLL_ATTEMPTS = 30;
|
|
848
|
+
var NO_HOST_PAGE_MESSAGE = "cannot start a service worker: the ServiceWorker CDP domain is only reachable over a page session, " + "and this browser has no page target open. Open any tab (new_page) and retry — the tab does not have " + "to belong to the extension.";
|
|
849
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
850
|
+
async function requestWorkerStart(needle) {
|
|
851
|
+
const conn = await openBrowser();
|
|
852
|
+
try {
|
|
853
|
+
const host = (await listTargets()).find((t) => t.type === "page");
|
|
854
|
+
if (!host)
|
|
855
|
+
throw new CdpError(NO_HOST_PAGE_MESSAGE);
|
|
856
|
+
const { sessionId } = await conn.send("Target.attachToTarget", {
|
|
857
|
+
targetId: host.id,
|
|
858
|
+
flatten: true
|
|
859
|
+
});
|
|
860
|
+
if (!sessionId)
|
|
861
|
+
throw new CdpError("could not attach a session to host the ServiceWorker domain");
|
|
862
|
+
const scopeById = new Map;
|
|
863
|
+
const versions = [];
|
|
864
|
+
conn.on("ServiceWorker.workerRegistrationUpdated", (params) => {
|
|
865
|
+
for (const r of params.registrations ?? []) {
|
|
866
|
+
if (r.isDeleted === true)
|
|
867
|
+
continue;
|
|
868
|
+
scopeById.set(String(r.registrationId), String(r.scopeURL ?? ""));
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
conn.on("ServiceWorker.workerVersionUpdated", (params) => {
|
|
872
|
+
for (const v of params.versions ?? []) {
|
|
873
|
+
versions.push({ registrationId: String(v.registrationId), scriptURL: String(v.scriptURL ?? "") });
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
await conn.send("ServiceWorker.enable", {}, { sessionId });
|
|
877
|
+
await conn.waitFor("ServiceWorker.workerVersionUpdated", undefined, INVENTORY_FIRST_EVENT_MS).catch(() => {
|
|
878
|
+
return;
|
|
879
|
+
});
|
|
880
|
+
await sleep(INVENTORY_SETTLE_MS);
|
|
881
|
+
const scopes = [...new Set(scopeById.values())].filter((s) => s.length > 0).sort();
|
|
882
|
+
const matched = new Set;
|
|
883
|
+
for (const v of versions) {
|
|
884
|
+
const scope = scopeById.get(v.registrationId);
|
|
885
|
+
if (scope === undefined)
|
|
886
|
+
continue;
|
|
887
|
+
if (v.scriptURL.includes(needle) || scope.includes(needle))
|
|
888
|
+
matched.add(scope);
|
|
889
|
+
}
|
|
890
|
+
if (matched.size === 0)
|
|
891
|
+
return { startRequested: false, scopes };
|
|
892
|
+
if (matched.size > 1) {
|
|
893
|
+
throw new CdpError(`'worker:${needle}' matches more than one registered service-worker scope: ${[...matched].sort().join(", ")}. ` + "Narrow the substring.");
|
|
894
|
+
}
|
|
895
|
+
await conn.send("ServiceWorker.startWorker", { scopeURL: [...matched][0] }, { sessionId });
|
|
896
|
+
return { startRequested: true, scopes };
|
|
897
|
+
} finally {
|
|
898
|
+
conn.close();
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
async function resolveWorkerSelector(selector, opts) {
|
|
902
|
+
const needle = workerNeedle(selector);
|
|
903
|
+
if (needle === "")
|
|
904
|
+
throw new CdpError(WORKER_EMPTY_NEEDLE_MESSAGE);
|
|
905
|
+
const first = resolveWorkerTargets(await listTargets(), needle);
|
|
906
|
+
if (first.matches.length > 1)
|
|
907
|
+
throw new CdpError(workerAmbiguityMessage(needle, first.matches));
|
|
908
|
+
if (first.matches.length === 1)
|
|
909
|
+
return first.matches[0];
|
|
910
|
+
if (!opts.wake) {
|
|
911
|
+
throw new CdpError(workerMissMessage(needle, { wakeAttempted: false, liveWorkers: first.liveWorkers }));
|
|
912
|
+
}
|
|
913
|
+
const { startRequested, scopes } = await requestWorkerStart(needle);
|
|
914
|
+
if (startRequested) {
|
|
915
|
+
for (let i = 0;i < WAKE_POLL_ATTEMPTS; i++) {
|
|
916
|
+
await sleep(WAKE_POLL_MS);
|
|
917
|
+
const again = resolveWorkerTargets(await listTargets(), needle);
|
|
918
|
+
if (again.matches.length > 1)
|
|
919
|
+
throw new CdpError(workerAmbiguityMessage(needle, again.matches));
|
|
920
|
+
if (again.matches.length === 1)
|
|
921
|
+
return again.matches[0];
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
const final = resolveWorkerTargets(await listTargets(), needle);
|
|
925
|
+
throw new CdpError(workerMissMessage(needle, { wakeAttempted: true, liveWorkers: final.liveWorkers, registeredScopes: scopes }));
|
|
926
|
+
}
|
|
927
|
+
|
|
519
928
|
// src/tools/network_mock.ts
|
|
520
929
|
function escapeRegExpChar(ch) {
|
|
521
930
|
return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
|
|
@@ -707,11 +1116,22 @@ var REQUIRED_CAPABILITIES = {
|
|
|
707
1116
|
stop_screen_recording: ["capture.screencast"],
|
|
708
1117
|
mock_request: ["network.intercept"],
|
|
709
1118
|
list_mocks: ["network.intercept"],
|
|
710
|
-
clear_mocks: ["network.intercept"]
|
|
1119
|
+
clear_mocks: ["network.intercept"],
|
|
1120
|
+
dispatch_mouse: ["input.raw"],
|
|
1121
|
+
wait_for_download: ["browser.downloads"],
|
|
1122
|
+
grant_permissions: ["browser.permissions"]
|
|
711
1123
|
};
|
|
712
1124
|
function isDriverError(e) {
|
|
713
1125
|
return e instanceof Error && typeof e.code === "string";
|
|
714
1126
|
}
|
|
1127
|
+
function interpolatePoints(from, to, steps) {
|
|
1128
|
+
const out = [];
|
|
1129
|
+
for (let i = 1;i < steps; i++) {
|
|
1130
|
+
out.push({ x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps });
|
|
1131
|
+
}
|
|
1132
|
+
out.push({ x: to.x, y: to.y });
|
|
1133
|
+
return out;
|
|
1134
|
+
}
|
|
715
1135
|
|
|
716
1136
|
// src/cdp/driver.ts
|
|
717
1137
|
function driverError(code, message, data) {
|
|
@@ -767,12 +1187,21 @@ async function centerOf(conn, objectId) {
|
|
|
767
1187
|
async function focusElement(conn, objectId) {
|
|
768
1188
|
await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: "function(){this.focus&&this.focus();}", returnByValue: true });
|
|
769
1189
|
}
|
|
770
|
-
|
|
1190
|
+
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);});})()";
|
|
1191
|
+
async function armScrollSettleWatch(conn) {
|
|
1192
|
+
await conn.send("Runtime.evaluate", { expression: ARM_SCROLL_SETTLE_WATCH, returnByValue: true });
|
|
1193
|
+
}
|
|
1194
|
+
async function awaitScrollSettle(conn) {
|
|
1195
|
+
await conn.send("Runtime.evaluate", { expression: "window.__cdpScrollSettle", awaitPromise: true, returnByValue: true }).catch(() => {
|
|
1196
|
+
return;
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
async function setValueOnObject(conn, objectId, value, sendInput2) {
|
|
771
1200
|
await focusElement(conn, objectId);
|
|
772
1201
|
const fn = "function(){if('value'in this){this.value='';this.dispatchEvent(new Event('input',{bubbles:true}));}else if(this.isContentEditable){this.textContent='';}}";
|
|
773
1202
|
await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: fn, returnByValue: true });
|
|
774
1203
|
if (value.length)
|
|
775
|
-
await
|
|
1204
|
+
await sendInput2("Input.insertText", { text: value });
|
|
776
1205
|
}
|
|
777
1206
|
var NAMED_KEYS = {
|
|
778
1207
|
enter: { key: "Enter", code: "Enter", keyCode: 13, text: "\r" },
|
|
@@ -797,6 +1226,18 @@ var NAMED_KEYS = {
|
|
|
797
1226
|
pagedown: { key: "PageDown", code: "PageDown", keyCode: 34 }
|
|
798
1227
|
};
|
|
799
1228
|
var MODIFIER_BITS = { alt: 1, control: 2, ctrl: 2, meta: 4, cmd: 4, command: 4, shift: 8 };
|
|
1229
|
+
var CLICK_MODIFIER_BITS = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
|
|
1230
|
+
function inputModifierBits(mods) {
|
|
1231
|
+
let bits = 0;
|
|
1232
|
+
for (const m of mods ?? []) {
|
|
1233
|
+
const bit = CLICK_MODIFIER_BITS[m];
|
|
1234
|
+
if (bit === undefined)
|
|
1235
|
+
throw driverError("page-error", `unknown modifier '${m}' (use Alt, Control, Meta, or Shift)`);
|
|
1236
|
+
bits |= bit;
|
|
1237
|
+
}
|
|
1238
|
+
return bits;
|
|
1239
|
+
}
|
|
1240
|
+
var HTML5_DRAG_INTERCEPT_TIMEOUT_MS = 5000;
|
|
800
1241
|
function resolveKey(key) {
|
|
801
1242
|
const named = NAMED_KEYS[key.toLowerCase()];
|
|
802
1243
|
if (named)
|
|
@@ -961,7 +1402,12 @@ var CDP_CAPABILITIES = new Set([
|
|
|
961
1402
|
"input.insertTextAtomic",
|
|
962
1403
|
"locate.text",
|
|
963
1404
|
"locate.xpath",
|
|
964
|
-
"capture.screencast"
|
|
1405
|
+
"capture.screencast",
|
|
1406
|
+
"input.raw",
|
|
1407
|
+
"input.html5Drag",
|
|
1408
|
+
"browser.downloads",
|
|
1409
|
+
"browser.permissions",
|
|
1410
|
+
"worker.targets"
|
|
965
1411
|
]);
|
|
966
1412
|
|
|
967
1413
|
class CdpPageDriver {
|
|
@@ -981,6 +1427,9 @@ class CdpPageDriver {
|
|
|
981
1427
|
this.enabledDomains.add(name);
|
|
982
1428
|
await this.conn.send(`${name}.enable`);
|
|
983
1429
|
}
|
|
1430
|
+
async dispatchInput(method, params = {}) {
|
|
1431
|
+
return sendInput(this.conn, "chrome", this.info.id, method, params);
|
|
1432
|
+
}
|
|
984
1433
|
async ensureDomainSoft(name) {
|
|
985
1434
|
if (this.enabledDomains.has(name))
|
|
986
1435
|
return;
|
|
@@ -989,11 +1438,22 @@ class CdpPageDriver {
|
|
|
989
1438
|
return;
|
|
990
1439
|
});
|
|
991
1440
|
}
|
|
1441
|
+
async historyEntry(direction) {
|
|
1442
|
+
const h = await this.conn.send("Page.getNavigationHistory");
|
|
1443
|
+
const entries = h.entries ?? [];
|
|
1444
|
+
const entry = entries[h.currentIndex + (direction === "back" ? -1 : 1)];
|
|
1445
|
+
if (!entry) {
|
|
1446
|
+
throw driverError("page-error", `navigate: no history entry to go ${direction} to (at entry ${h.currentIndex + 1} of ${entries.length})`);
|
|
1447
|
+
}
|
|
1448
|
+
return entry;
|
|
1449
|
+
}
|
|
992
1450
|
async navigate(opts) {
|
|
993
1451
|
await this.ensureDomain("Page");
|
|
994
1452
|
const reload = opts.reload === true;
|
|
995
|
-
|
|
1453
|
+
const history = opts.history;
|
|
1454
|
+
if (!reload && !history && !opts.url)
|
|
996
1455
|
throw driverError("page-error", "navigate: 'url' is required unless reload:true");
|
|
1456
|
+
const entry = history ? await this.historyEntry(history) : undefined;
|
|
997
1457
|
const waitUntil = opts.waitUntil ?? "load";
|
|
998
1458
|
const milestoneMethod = waitUntil === "load" ? "Page.loadEventFired" : "Page.domContentEventFired";
|
|
999
1459
|
const milestone = this.conn.waitFor(milestoneMethod, undefined, opts.timeoutMs).then(() => waitUntil).catch(() => {
|
|
@@ -1011,6 +1471,10 @@ class CdpPageDriver {
|
|
|
1011
1471
|
contextId = tree?.frameTree?.frame?.id ?? "";
|
|
1012
1472
|
url = tree?.frameTree?.frame?.url ?? opts.url ?? "";
|
|
1013
1473
|
reloaded = true;
|
|
1474
|
+
} else if (entry) {
|
|
1475
|
+
await this.conn.send("Page.navigateToHistoryEntry", { entryId: entry.id }, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {});
|
|
1476
|
+
contextId = "";
|
|
1477
|
+
url = entry.url;
|
|
1014
1478
|
} else {
|
|
1015
1479
|
const nav = await this.conn.send("Page.navigate", { url: opts.url }, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {});
|
|
1016
1480
|
if (nav.errorText)
|
|
@@ -1019,7 +1483,20 @@ class CdpPageDriver {
|
|
|
1019
1483
|
url = opts.url;
|
|
1020
1484
|
}
|
|
1021
1485
|
const settled = await Promise.race([milestone, stopped]);
|
|
1022
|
-
|
|
1486
|
+
if (entry) {
|
|
1487
|
+
const tree = await this.conn.send("Page.getFrameTree").catch(() => {
|
|
1488
|
+
return;
|
|
1489
|
+
});
|
|
1490
|
+
contextId = tree?.frameTree?.frame?.id ?? contextId;
|
|
1491
|
+
url = tree?.frameTree?.frame?.url ?? url;
|
|
1492
|
+
}
|
|
1493
|
+
return {
|
|
1494
|
+
url,
|
|
1495
|
+
contextId,
|
|
1496
|
+
...reloaded ? { reloaded } : {},
|
|
1497
|
+
...history ? { traversed: history } : {},
|
|
1498
|
+
waitedFor: settled ?? "timeout"
|
|
1499
|
+
};
|
|
1023
1500
|
}
|
|
1024
1501
|
async waitForText(text, timeoutMs = 15000, pollMs = 250) {
|
|
1025
1502
|
await this.ensureDomain("Runtime");
|
|
@@ -1099,36 +1576,96 @@ class CdpPageDriver {
|
|
|
1099
1576
|
async hover(loc) {
|
|
1100
1577
|
const { objectId } = await resolveElementLocator(this.conn, loc);
|
|
1101
1578
|
const { x, y } = await centerOf(this.conn, objectId);
|
|
1102
|
-
await this.
|
|
1579
|
+
await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
|
|
1103
1580
|
return { x, y };
|
|
1104
1581
|
}
|
|
1105
1582
|
async click(loc, opts) {
|
|
1106
1583
|
const button = opts?.button ?? "left";
|
|
1107
1584
|
const clickCount = opts?.clickCount ?? 1;
|
|
1585
|
+
const modifiers = inputModifierBits(opts?.modifiers);
|
|
1108
1586
|
const { x, y } = await this.hover(loc);
|
|
1109
|
-
await this.
|
|
1110
|
-
await this.
|
|
1587
|
+
await this.dispatchInput("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount, modifiers });
|
|
1588
|
+
await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, clickCount, modifiers });
|
|
1111
1589
|
return { x, y };
|
|
1112
1590
|
}
|
|
1113
|
-
async
|
|
1591
|
+
async scroll(anchor, opts) {
|
|
1592
|
+
await this.ensureDomain("Runtime");
|
|
1593
|
+
const { x, y } = await this.resolveScrollPoint(anchor);
|
|
1594
|
+
await armScrollSettleWatch(this.conn);
|
|
1595
|
+
await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseWheel", x, y, deltaX: opts.deltaX ?? 0, deltaY: opts.deltaY ?? 0 });
|
|
1596
|
+
await awaitScrollSettle(this.conn);
|
|
1597
|
+
return { x, y };
|
|
1598
|
+
}
|
|
1599
|
+
async resolveScrollPoint(anchor) {
|
|
1600
|
+
if (anchor === undefined) {
|
|
1601
|
+
await this.ensureDomain("Runtime");
|
|
1602
|
+
const { result, exceptionDetails } = await this.conn.send("Runtime.evaluate", { expression: "({x: window.innerWidth / 2, y: window.innerHeight / 2})", returnByValue: true });
|
|
1603
|
+
if (exceptionDetails || !result.value)
|
|
1604
|
+
throw driverError("page-error", `could not measure viewport for scroll center: ${exceptionDetails?.text ?? "no value"}`);
|
|
1605
|
+
return result.value;
|
|
1606
|
+
}
|
|
1607
|
+
if ("x" in anchor)
|
|
1608
|
+
return anchor;
|
|
1609
|
+
const { objectId } = await resolveElementLocator(this.conn, anchor);
|
|
1610
|
+
return centerOf(this.conn, objectId);
|
|
1611
|
+
}
|
|
1612
|
+
async drag(from, to, opts) {
|
|
1114
1613
|
const fromPt = await this.hover(from);
|
|
1115
|
-
const
|
|
1116
|
-
const
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1614
|
+
const toPt = await this.resolveDragDestination(to, fromPt);
|
|
1615
|
+
const steps = Math.max(1, Math.trunc(opts?.steps ?? 2));
|
|
1616
|
+
if ((opts?.mode ?? "mouse") === "html5") {
|
|
1617
|
+
await this.html5Drag(fromPt, toPt, steps);
|
|
1618
|
+
return { from: fromPt, to: toPt };
|
|
1619
|
+
}
|
|
1620
|
+
await this.dispatchInput("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
|
|
1621
|
+
for (const pt of interpolatePoints(fromPt, toPt, steps)) {
|
|
1622
|
+
await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x: pt.x, y: pt.y, button: "left" });
|
|
1623
|
+
}
|
|
1624
|
+
await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
|
|
1121
1625
|
return { from: fromPt, to: toPt };
|
|
1122
1626
|
}
|
|
1627
|
+
async resolveDragDestination(to, fromPt) {
|
|
1628
|
+
if ("dx" in to)
|
|
1629
|
+
return { x: fromPt.x + to.dx, y: fromPt.y + to.dy };
|
|
1630
|
+
if ("x" in to)
|
|
1631
|
+
return to;
|
|
1632
|
+
const { objectId } = await resolveElementLocator(this.conn, to);
|
|
1633
|
+
return centerOf(this.conn, objectId);
|
|
1634
|
+
}
|
|
1635
|
+
async html5Drag(fromPt, toPt, steps) {
|
|
1636
|
+
await this.dispatchInput("Input.setInterceptDrags", { enabled: true });
|
|
1637
|
+
try {
|
|
1638
|
+
const intercepted = this.conn.waitFor("Input.dragIntercepted", undefined, HTML5_DRAG_INTERCEPT_TIMEOUT_MS).catch(() => {
|
|
1639
|
+
return;
|
|
1640
|
+
});
|
|
1641
|
+
await this.dispatchInput("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
|
|
1642
|
+
for (const pt of interpolatePoints(fromPt, toPt, steps)) {
|
|
1643
|
+
await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x: pt.x, y: pt.y, button: "left" });
|
|
1644
|
+
}
|
|
1645
|
+
const event = await intercepted;
|
|
1646
|
+
if (!event?.data) {
|
|
1647
|
+
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.');
|
|
1648
|
+
}
|
|
1649
|
+
const data = event.data;
|
|
1650
|
+
for (const type of ["dragEnter", "dragOver", "drop"]) {
|
|
1651
|
+
await this.dispatchInput("Input.dispatchDragEvent", { type, x: toPt.x, y: toPt.y, data });
|
|
1652
|
+
}
|
|
1653
|
+
await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
|
|
1654
|
+
} finally {
|
|
1655
|
+
await this.dispatchInput("Input.setInterceptDrags", { enabled: false }).catch(() => {
|
|
1656
|
+
return;
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1123
1660
|
async setValue(loc, value) {
|
|
1124
1661
|
const { objectId } = await resolveElementLocator(this.conn, loc);
|
|
1125
|
-
await setValueOnObject(this.conn, objectId, value);
|
|
1662
|
+
await setValueOnObject(this.conn, objectId, value, (method, params) => this.dispatchInput(method, params));
|
|
1126
1663
|
}
|
|
1127
1664
|
async typeText(loc, text) {
|
|
1128
1665
|
const { objectId } = await resolveElementLocator(this.conn, loc);
|
|
1129
1666
|
await focusElement(this.conn, objectId);
|
|
1130
1667
|
if (text.length)
|
|
1131
|
-
await this.
|
|
1668
|
+
await this.dispatchInput("Input.insertText", { text });
|
|
1132
1669
|
}
|
|
1133
1670
|
async pressKey(press) {
|
|
1134
1671
|
const spec = resolveKey(press.key);
|
|
@@ -1143,8 +1680,8 @@ class CdpPageDriver {
|
|
|
1143
1680
|
const down = { type: "keyDown", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits };
|
|
1144
1681
|
if (spec.text && !suppressText)
|
|
1145
1682
|
down.text = spec.text;
|
|
1146
|
-
await this.
|
|
1147
|
-
await this.
|
|
1683
|
+
await this.dispatchInput("Input.dispatchKeyEvent", down);
|
|
1684
|
+
await this.dispatchInput("Input.dispatchKeyEvent", { type: "keyUp", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits });
|
|
1148
1685
|
}
|
|
1149
1686
|
async setFiles(loc, files) {
|
|
1150
1687
|
const { objectId } = await resolveElementLocator(this.conn, loc);
|
|
@@ -1360,6 +1897,36 @@ class CdpPageDriver {
|
|
|
1360
1897
|
this.conn.close();
|
|
1361
1898
|
}
|
|
1362
1899
|
}
|
|
1900
|
+
var beaconSessions = new BeaconSessions((conn) => conn.close());
|
|
1901
|
+
async function targetWsUrl(targetId) {
|
|
1902
|
+
const hit = (await listTargets()).find((t) => t.id === targetId);
|
|
1903
|
+
return hit?.webSocketDebuggerUrl;
|
|
1904
|
+
}
|
|
1905
|
+
async function readBeaconOver(conn) {
|
|
1906
|
+
try {
|
|
1907
|
+
const res = await conn.send("Runtime.evaluate", {
|
|
1908
|
+
expression: BEACON_READ_EXPRESSION,
|
|
1909
|
+
returnByValue: true
|
|
1910
|
+
});
|
|
1911
|
+
const value = res.result?.value;
|
|
1912
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
1913
|
+
} catch {
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
async function probeOver(conn, timeoutMs) {
|
|
1918
|
+
try {
|
|
1919
|
+
const res = await conn.send("Runtime.evaluate", { expression: RENDERER_PROBE_EXPRESSION, returnByValue: true }, { timeoutMs });
|
|
1920
|
+
const value = res.result?.value;
|
|
1921
|
+
if (Array.isArray(value) && value[0] === 1) {
|
|
1922
|
+
const ts = value[1];
|
|
1923
|
+
return { responsive: true, beaconTs: typeof ts === "number" && Number.isFinite(ts) ? ts : null };
|
|
1924
|
+
}
|
|
1925
|
+
return { responsive: false, beaconTs: null };
|
|
1926
|
+
} catch {
|
|
1927
|
+
return { responsive: false, beaconTs: null };
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1363
1930
|
async function withBrowserConn(fn) {
|
|
1364
1931
|
const conn = await openBrowser();
|
|
1365
1932
|
try {
|
|
@@ -1398,6 +1965,8 @@ class CdpBrowserDriver {
|
|
|
1398
1965
|
async closePage(id) {
|
|
1399
1966
|
return withBrowserConn(async (conn) => {
|
|
1400
1967
|
const res = await conn.send("Target.closeTarget", { targetId: id });
|
|
1968
|
+
if (res.success !== false)
|
|
1969
|
+
beaconSessions.drop(id);
|
|
1401
1970
|
return { success: res.success ?? true };
|
|
1402
1971
|
});
|
|
1403
1972
|
}
|
|
@@ -1406,119 +1975,94 @@ class CdpBrowserDriver {
|
|
|
1406
1975
|
await withBrowserConn((conn) => conn.send("Target.activateTarget", { targetId: target.id }));
|
|
1407
1976
|
return { id: target.id, url: target.url, title: target.title };
|
|
1408
1977
|
}
|
|
1978
|
+
async resolveWorkerTarget(selector, opts) {
|
|
1979
|
+
const target = await resolveWorkerSelector(selector, opts).catch(noSuchTarget);
|
|
1980
|
+
return { id: target.id, url: target.url, title: target.title, type: target.type };
|
|
1981
|
+
}
|
|
1409
1982
|
async page(selector) {
|
|
1410
1983
|
const { conn, target } = await openPage(selector).catch(noSuchTarget);
|
|
1411
1984
|
return new CdpPageDriver(conn, target, this);
|
|
1412
1985
|
}
|
|
1413
|
-
async
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
import { mkdir as mkdir2, readFile as readFile2, readdir as readdir2, unlink as unlink2, writeFile as writeFile2 } from "node:fs/promises";
|
|
1421
|
-
import { join as join2 } from "node:path";
|
|
1422
|
-
function safeId2(id) {
|
|
1423
|
-
return id.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
1424
|
-
}
|
|
1425
|
-
function originFile(backend, targetId) {
|
|
1426
|
-
return join2(leaseDir(), `origin-${backend}-${safeId2(targetId)}.json`);
|
|
1427
|
-
}
|
|
1428
|
-
var ORIGIN_FILE_RE = /^origin-(chrome|firefox)-(.+)\.json$/;
|
|
1429
|
-
async function recordOrigin(backend, targetId, opts = {}) {
|
|
1430
|
-
const record = {
|
|
1431
|
-
backend,
|
|
1432
|
-
targetId,
|
|
1433
|
-
label: typeof opts.label === "string" && opts.label.length ? opts.label : defaultLabel(),
|
|
1434
|
-
pid: process.pid,
|
|
1435
|
-
createdAt: opts.now ?? Date.now()
|
|
1436
|
-
};
|
|
1437
|
-
const file = originFile(backend, targetId);
|
|
1438
|
-
try {
|
|
1439
|
-
await mkdir2(leaseDir(), { recursive: true });
|
|
1440
|
-
try {
|
|
1441
|
-
await writeFile2(file, JSON.stringify(record), { flag: "wx" });
|
|
1442
|
-
return;
|
|
1443
|
-
} catch (err) {
|
|
1444
|
-
if (err.code !== "EEXIST")
|
|
1445
|
-
throw err;
|
|
1986
|
+
async installActivityBeacon(targetId) {
|
|
1987
|
+
const held = beaconSessions.get(targetId);
|
|
1988
|
+
if (held) {
|
|
1989
|
+
const rearmed = await held.send("Runtime.evaluate", { expression: BEACON_SOURCE, returnByValue: true }).then(() => true).catch(() => false);
|
|
1990
|
+
if (rearmed)
|
|
1991
|
+
return true;
|
|
1992
|
+
beaconSessions.drop(targetId);
|
|
1446
1993
|
}
|
|
1447
|
-
await
|
|
1994
|
+
const wsUrl = await targetWsUrl(targetId).catch(() => {
|
|
1448
1995
|
return;
|
|
1449
1996
|
});
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
return
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
async function listOrigins(opts = {}) {
|
|
1468
|
-
let names = [];
|
|
1469
|
-
try {
|
|
1470
|
-
names = await readdir2(leaseDir());
|
|
1471
|
-
} catch {
|
|
1472
|
-
return [];
|
|
1997
|
+
if (!wsUrl)
|
|
1998
|
+
return false;
|
|
1999
|
+
const conn = await new CdpConnection(wsUrl).connect().catch(() => {
|
|
2000
|
+
return;
|
|
2001
|
+
});
|
|
2002
|
+
if (!conn)
|
|
2003
|
+
return false;
|
|
2004
|
+
try {
|
|
2005
|
+
await conn.send("Page.enable");
|
|
2006
|
+
await conn.send("Page.addScriptToEvaluateOnNewDocument", { source: BEACON_SOURCE });
|
|
2007
|
+
await conn.send("Runtime.evaluate", { expression: BEACON_SOURCE, returnByValue: true });
|
|
2008
|
+
} catch {
|
|
2009
|
+
conn.close();
|
|
2010
|
+
return false;
|
|
2011
|
+
}
|
|
2012
|
+
beaconSessions.set(targetId, conn);
|
|
2013
|
+
return true;
|
|
1473
2014
|
}
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
const nameId = parts[2];
|
|
1482
|
-
const scoped = live !== undefined && (opts.liveBackend === undefined || opts.liveBackend === backend);
|
|
1483
|
-
if (scoped && !live.has(nameId)) {
|
|
1484
|
-
await unlink2(join2(leaseDir(), name)).catch(() => {
|
|
1485
|
-
return;
|
|
1486
|
-
});
|
|
1487
|
-
continue;
|
|
2015
|
+
async readActivityBeacon(targetId) {
|
|
2016
|
+
const held = beaconSessions.get(targetId);
|
|
2017
|
+
if (held) {
|
|
2018
|
+
const value = await readBeaconOver(held);
|
|
2019
|
+
if (value !== undefined)
|
|
2020
|
+
return value;
|
|
2021
|
+
beaconSessions.drop(targetId);
|
|
1488
2022
|
}
|
|
1489
|
-
const
|
|
1490
|
-
|
|
2023
|
+
const wsUrl = await targetWsUrl(targetId).catch(() => {
|
|
2024
|
+
return;
|
|
2025
|
+
});
|
|
2026
|
+
if (!wsUrl)
|
|
2027
|
+
return null;
|
|
2028
|
+
const conn = await new CdpConnection(wsUrl).connect().catch(() => {
|
|
2029
|
+
return;
|
|
2030
|
+
});
|
|
2031
|
+
if (!conn)
|
|
2032
|
+
return null;
|
|
1491
2033
|
try {
|
|
1492
|
-
|
|
1493
|
-
}
|
|
1494
|
-
|
|
1495
|
-
if (code === "ENOENT")
|
|
1496
|
-
continue;
|
|
1497
|
-
out.push({ ...fromName, unreadable: code ?? "unparseable" });
|
|
1498
|
-
continue;
|
|
2034
|
+
return await readBeaconOver(conn) ?? null;
|
|
2035
|
+
} finally {
|
|
2036
|
+
conn.close();
|
|
1499
2037
|
}
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
2038
|
+
}
|
|
2039
|
+
async probeRenderer(targetId, timeoutMs = RENDERER_PROBE_TIMEOUT_MS) {
|
|
2040
|
+
const held = beaconSessions.get(targetId);
|
|
2041
|
+
if (held)
|
|
2042
|
+
return probeOver(held, timeoutMs);
|
|
2043
|
+
const wsUrl = await targetWsUrl(targetId).catch(() => {
|
|
2044
|
+
return;
|
|
2045
|
+
});
|
|
2046
|
+
if (!wsUrl)
|
|
2047
|
+
return { responsive: false, beaconTs: null };
|
|
2048
|
+
const conn = new CdpConnection(wsUrl, { timeoutMs });
|
|
2049
|
+
try {
|
|
2050
|
+
await conn.connect();
|
|
2051
|
+
} catch {
|
|
2052
|
+
return { responsive: false, beaconTs: null };
|
|
2053
|
+
}
|
|
2054
|
+
try {
|
|
2055
|
+
return await probeOver(conn, timeoutMs);
|
|
2056
|
+
} finally {
|
|
2057
|
+
conn.close();
|
|
1503
2058
|
}
|
|
1504
|
-
out.push({ backend: rec.backend, targetId: rec.targetId, label: rec.label, pid: rec.pid, createdAt: rec.createdAt });
|
|
1505
2059
|
}
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
async function originIndex(backend, liveIds) {
|
|
1509
|
-
const rows = await listOrigins({ liveIds, liveBackend: backend }).catch(() => []);
|
|
1510
|
-
const index = new Map;
|
|
1511
|
-
for (const row of rows) {
|
|
1512
|
-
if (row.backend !== backend)
|
|
1513
|
-
continue;
|
|
1514
|
-
index.set(row.targetId, row);
|
|
2060
|
+
async dispose() {
|
|
2061
|
+
beaconSessions.clear();
|
|
1515
2062
|
}
|
|
1516
|
-
return index;
|
|
1517
2063
|
}
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
await recordOrigin(backend, page.id, { label: opts.label });
|
|
1521
|
-
return page;
|
|
2064
|
+
function createCdpDriver() {
|
|
2065
|
+
return new CdpBrowserDriver;
|
|
1522
2066
|
}
|
|
1523
2067
|
|
|
1524
2068
|
// src/reap.ts
|
|
@@ -1532,6 +2076,8 @@ function staleAgentTabs(input) {
|
|
|
1532
2076
|
continue;
|
|
1533
2077
|
if (row.stale !== "dead-pid" && row.stale !== "expired")
|
|
1534
2078
|
continue;
|
|
2079
|
+
if (row.stale === "expired" && input.now - row.lastUsedAt <= row.ttlMs + input.reapGraceMs)
|
|
2080
|
+
continue;
|
|
1535
2081
|
if (!live.has(row.targetId))
|
|
1536
2082
|
continue;
|
|
1537
2083
|
const origin = input.origins.get(row.targetId);
|
|
@@ -1541,13 +2087,19 @@ function staleAgentTabs(input) {
|
|
|
1541
2087
|
}
|
|
1542
2088
|
return out;
|
|
1543
2089
|
}
|
|
2090
|
+
var DEFAULT_REAP_GRACE_MS = 2700000;
|
|
2091
|
+
function reapGraceMs() {
|
|
2092
|
+
const raw = Number(process.env.CDP_REAP_GRACE_MS);
|
|
2093
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_REAP_GRACE_MS;
|
|
2094
|
+
}
|
|
1544
2095
|
async function reapStaleAgentTabs(driver, backend) {
|
|
2096
|
+
const now = Date.now();
|
|
1545
2097
|
const livePageIds = (await driver.listPages()).map((p) => p.id);
|
|
1546
2098
|
const [origins, leases] = await Promise.all([
|
|
1547
2099
|
originIndex(backend, undefined),
|
|
1548
|
-
listLeases({ liveIds: livePageIds, liveBackend: backend }).catch(() => [])
|
|
2100
|
+
listLeases({ now, liveIds: livePageIds, liveBackend: backend }).catch(() => [])
|
|
1549
2101
|
]);
|
|
1550
|
-
const candidates = staleAgentTabs({ backend, livePageIds, origins, leases });
|
|
2102
|
+
const candidates = staleAgentTabs({ backend, livePageIds, origins, leases, now, reapGraceMs: reapGraceMs() });
|
|
1551
2103
|
const closed = [];
|
|
1552
2104
|
for (const c of candidates) {
|
|
1553
2105
|
const res = await driver.closePage(c.targetId).catch(() => ({ success: false }));
|
|
@@ -1613,6 +2165,11 @@ async function pickPage(driver, pages, selector) {
|
|
|
1613
2165
|
throw new SharedToolError(`no page title containing '${needle}'`);
|
|
1614
2166
|
return p;
|
|
1615
2167
|
}
|
|
2168
|
+
if (selector.startsWith("label:"))
|
|
2169
|
+
return pickByLabel(driver, pages, selector.slice(6));
|
|
2170
|
+
if (isWorkerSelector(selector)) {
|
|
2171
|
+
throw new SharedToolError(backendOf(driver) === "firefox" ? WORKER_SELECTOR_UNSUPPORTED_MESSAGE : WORKER_SELECTOR_PAGE_ONLY_MESSAGE);
|
|
2172
|
+
}
|
|
1616
2173
|
const exactPage = pages.find((x) => x.id === selector);
|
|
1617
2174
|
if (exactPage)
|
|
1618
2175
|
return exactPage;
|
|
@@ -1622,6 +2179,18 @@ async function pickPage(driver, pages, selector) {
|
|
|
1622
2179
|
return exact;
|
|
1623
2180
|
throw new SharedToolError(`no target with id '${selector}'`);
|
|
1624
2181
|
}
|
|
2182
|
+
async function pickByLabel(driver, pages, label) {
|
|
2183
|
+
const { matchIds, knownLabels } = await resolveLiveLabel(backendOf(driver), label, pages.map((x) => x.id));
|
|
2184
|
+
if (matchIds.length > 1) {
|
|
2185
|
+
throw new SharedToolError(`label 'label:${label}' matches more than one live target: ${matchIds.join(", ")}. Resolve by target id instead.`);
|
|
2186
|
+
}
|
|
2187
|
+
if (matchIds.length === 1) {
|
|
2188
|
+
const p = pages.find((x) => x.id === matchIds[0]);
|
|
2189
|
+
if (p)
|
|
2190
|
+
return p;
|
|
2191
|
+
}
|
|
2192
|
+
throw new SharedToolError(knownLabels.length ? `no live target with label '${label}' (labels currently in use: ${knownLabels.join(", ")})` : `no live target with label '${label}' (no labels are currently assigned to any open target)`);
|
|
2193
|
+
}
|
|
1625
2194
|
function locatorOf(args) {
|
|
1626
2195
|
const hasUid = args.uid !== undefined && args.uid !== null && args.uid !== "";
|
|
1627
2196
|
const hasSelector = typeof args.selector === "string" && args.selector.length > 0;
|
|
@@ -1639,18 +2208,29 @@ async function reapSet(driver, pages, all) {
|
|
|
1639
2208
|
}
|
|
1640
2209
|
}
|
|
1641
2210
|
async function listPages(driver, args = {}) {
|
|
1642
|
-
const
|
|
2211
|
+
const backend = backendOf(driver);
|
|
2212
|
+
const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
|
|
1643
2213
|
const reapedIds = new Set(reaped.map((r) => r.targetId));
|
|
1644
2214
|
const pages = (await driver.listPages({ all: args.all })).filter((p) => !reapedIds.has(p.id));
|
|
1645
|
-
const ledger = await originIndex(
|
|
1646
|
-
const
|
|
2215
|
+
const ledger = await originIndex(backend, await reapSet(driver, pages, args.all));
|
|
2216
|
+
const now = Date.now();
|
|
2217
|
+
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]));
|
|
2218
|
+
const doProbe = args.probe === true && rendererProbeSupported(driver);
|
|
2219
|
+
const annotated = await Promise.all(pages.map(async (p) => {
|
|
1647
2220
|
const rec = ledger.get(p.id);
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
if (
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
2221
|
+
const base = !rec ? { ...p, origin: "unknown" } : rec.unreadable !== undefined ? { ...p, origin: "unknown", originUnreadable: rec.unreadable } : { ...p, origin: "agent", label: rec.label, createdAt: rec.createdAt };
|
|
2222
|
+
const leaseRow = leaseByTarget.get(p.id);
|
|
2223
|
+
if (leaseRow) {
|
|
2224
|
+
base.lease = { label: leaseRow.label, pid: leaseRow.pid, idleMs: now - leaseRow.lastUsedAt, expiresAt: leaseRow.lastUsedAt + leaseRow.ttlMs, stale: leaseRow.stale };
|
|
2225
|
+
}
|
|
2226
|
+
if (doProbe && (p.type === undefined || p.type === "page")) {
|
|
2227
|
+
const probe = await probeRendererActivity(driver, backend, p.id, now);
|
|
2228
|
+
base.responsive = probe.responsive;
|
|
2229
|
+
if (probe.humanActiveMs !== undefined)
|
|
2230
|
+
base.humanActiveMs = probe.humanActiveMs;
|
|
2231
|
+
}
|
|
2232
|
+
return base;
|
|
2233
|
+
}));
|
|
1654
2234
|
return { pages: annotated, count: annotated.length, ...reaped.length ? { reaped } : {} };
|
|
1655
2235
|
}
|
|
1656
2236
|
async function newPage(driver, args = {}) {
|
|
@@ -1664,6 +2244,7 @@ async function newPage(driver, args = {}) {
|
|
|
1664
2244
|
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
|
|
1665
2245
|
auto: !explicit
|
|
1666
2246
|
});
|
|
2247
|
+
await installBeacon(driver, p.id);
|
|
1667
2248
|
return { targetId: p.id, url: p.url, lease: token, label: record.label, expiresAt: record.lastUsedAt + record.ttlMs };
|
|
1668
2249
|
}
|
|
1669
2250
|
async function closePage(driver, args) {
|
|
@@ -1695,17 +2276,36 @@ function toLegacyWaitedFor(w) {
|
|
|
1695
2276
|
return "navigate-only";
|
|
1696
2277
|
return w;
|
|
1697
2278
|
}
|
|
1698
|
-
|
|
2279
|
+
function resolveNavigateMode(args) {
|
|
1699
2280
|
const reload = args.reload === true;
|
|
1700
|
-
|
|
1701
|
-
|
|
2281
|
+
const hasUrl = typeof args.url === "string" && args.url.length > 0;
|
|
2282
|
+
const hasHistory = args.history !== undefined && args.history !== null;
|
|
2283
|
+
const given = [hasUrl ? "url" : "", reload ? "reload" : "", hasHistory ? "history" : ""].filter(Boolean);
|
|
2284
|
+
if (given.length > 1) {
|
|
2285
|
+
throw new SharedToolError(`navigate_page: 'url', 'reload' and 'history' are mutually exclusive (got ${given.join(" + ")})`);
|
|
2286
|
+
}
|
|
2287
|
+
if (hasHistory) {
|
|
2288
|
+
if (args.history !== "back" && args.history !== "forward") {
|
|
2289
|
+
throw new SharedToolError(`navigate_page: 'history' must be 'back' or 'forward' (got ${JSON.stringify(args.history)})`);
|
|
2290
|
+
}
|
|
2291
|
+
return { mode: "history", history: args.history };
|
|
1702
2292
|
}
|
|
2293
|
+
if (reload)
|
|
2294
|
+
return { mode: "reload" };
|
|
2295
|
+
if (!hasUrl) {
|
|
2296
|
+
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)");
|
|
2297
|
+
}
|
|
2298
|
+
return { mode: "url", url: args.url };
|
|
2299
|
+
}
|
|
2300
|
+
async function navigatePage(driver, args) {
|
|
2301
|
+
const mode = resolveNavigateMode(args);
|
|
1703
2302
|
return withPage2(driver, args.target, async (page) => {
|
|
1704
|
-
const r = await page.navigate(args);
|
|
2303
|
+
const r = await page.navigate(mode.mode === "history" ? { ...args, history: mode.history } : args);
|
|
1705
2304
|
return {
|
|
1706
2305
|
url: r.url,
|
|
1707
2306
|
frameId: r.contextId,
|
|
1708
2307
|
...r.reloaded ? { reloaded: r.reloaded } : {},
|
|
2308
|
+
...r.traversed ? { traversed: r.traversed } : {},
|
|
1709
2309
|
waitedFor: toLegacyWaitedFor(r.waitedFor)
|
|
1710
2310
|
};
|
|
1711
2311
|
});
|
|
@@ -1732,11 +2332,29 @@ async function writeJsonSink(savePath, value) {
|
|
|
1732
2332
|
function sinkRequested(savePath) {
|
|
1733
2333
|
return savePath !== undefined && savePath !== "";
|
|
1734
2334
|
}
|
|
2335
|
+
var EXPRESSION_ALIAS_KEYS = ["function", "code", "js", "script", "fn", "body"];
|
|
2336
|
+
async function resolveWorkerSelectorFor(driver, selector, wake) {
|
|
2337
|
+
if (!driver.capabilities.has("worker.targets") || typeof driver.resolveWorkerTarget !== "function") {
|
|
2338
|
+
throw new SharedToolError(`evaluateScript: ${WORKER_SELECTOR_UNSUPPORTED_MESSAGE}`);
|
|
2339
|
+
}
|
|
2340
|
+
return driver.resolveWorkerTarget(selector, { wake });
|
|
2341
|
+
}
|
|
1735
2342
|
async function evaluateScript(driver, args) {
|
|
1736
2343
|
if (typeof args.expression !== "string" || args.expression.length === 0) {
|
|
2344
|
+
const wrongKey = EXPRESSION_ALIAS_KEYS.find((k) => args[k] !== undefined);
|
|
2345
|
+
if (wrongKey !== undefined) {
|
|
2346
|
+
throw new SharedToolError(`evaluateScript: you passed '${wrongKey}'; the key is 'expression' — function literals go in 'expression', invoked with 'args'.`);
|
|
2347
|
+
}
|
|
1737
2348
|
throw new SharedToolError("evaluateScript: 'expression' must be a non-empty string");
|
|
1738
2349
|
}
|
|
1739
|
-
|
|
2350
|
+
if (args.wake !== undefined && typeof args.wake !== "boolean") {
|
|
2351
|
+
throw new SharedToolError("evaluateScript: 'wake' must be a boolean");
|
|
2352
|
+
}
|
|
2353
|
+
if (args.wake !== undefined && !isWorkerSelector(args.target)) {
|
|
2354
|
+
throw new SharedToolError("evaluateScript: 'wake' only applies to a target of the form 'worker:<substring>'; a page target is never asleep.");
|
|
2355
|
+
}
|
|
2356
|
+
const target = isWorkerSelector(args.target) ? (await resolveWorkerSelectorFor(driver, args.target, args.wake ?? true)).id : args.target;
|
|
2357
|
+
return withPage2(driver, target, async (page) => {
|
|
1740
2358
|
const value = await page.evaluate(args.expression, { args: args.args, awaitPromise: args.awaitPromise ?? true });
|
|
1741
2359
|
if (!sinkRequested(args.savePath))
|
|
1742
2360
|
return value;
|
|
@@ -1851,22 +2469,113 @@ async function takeSnapshot(driver, args = {}) {
|
|
|
1851
2469
|
}
|
|
1852
2470
|
async function click(driver, args) {
|
|
1853
2471
|
return withPage2(driver, args.target, async (page) => {
|
|
1854
|
-
const { x, y } = await page.click(locatorOf(args), { button: args.button ?? "left", clickCount: args.clickCount ?? 1 });
|
|
2472
|
+
const { x, y } = await page.click(locatorOf(args), { button: args.button ?? "left", clickCount: args.clickCount ?? 1, modifiers: args.modifiers });
|
|
1855
2473
|
return { clicked: true, x, y };
|
|
1856
2474
|
});
|
|
1857
2475
|
}
|
|
2476
|
+
function resolveScrollAnchor(args) {
|
|
2477
|
+
const hasUid = args.uid !== undefined && args.uid !== null && args.uid !== "";
|
|
2478
|
+
const hasSelector = typeof args.selector === "string" && args.selector.length > 0;
|
|
2479
|
+
const hasX = args.x !== undefined;
|
|
2480
|
+
const hasY = args.y !== undefined;
|
|
2481
|
+
if (hasX !== hasY)
|
|
2482
|
+
throw new SharedToolError("scroll: 'x' and 'y' must be provided together");
|
|
2483
|
+
const hasXY = hasX && hasY;
|
|
2484
|
+
const anchorCount = [hasUid, hasSelector, hasXY].filter(Boolean).length;
|
|
2485
|
+
if (anchorCount > 1)
|
|
2486
|
+
throw new SharedToolError("scroll: provide at most one of { uid }, { selector }, or { x, y } (omit all three to scroll at the viewport center)");
|
|
2487
|
+
if (hasUid || hasSelector)
|
|
2488
|
+
return locatorOf({ uid: args.uid, selector: args.selector });
|
|
2489
|
+
if (hasXY)
|
|
2490
|
+
return { x: args.x, y: args.y };
|
|
2491
|
+
return;
|
|
2492
|
+
}
|
|
2493
|
+
function resolveScrollDelta(args) {
|
|
2494
|
+
const deltaX = args.deltaX ?? 0;
|
|
2495
|
+
const deltaY = args.deltaY ?? 0;
|
|
2496
|
+
if (deltaX === 0 && deltaY === 0)
|
|
2497
|
+
throw new SharedToolError("scroll requires at least one of { deltaX } or { deltaY }");
|
|
2498
|
+
return { deltaX, deltaY };
|
|
2499
|
+
}
|
|
2500
|
+
async function scroll(driver, args) {
|
|
2501
|
+
const anchor = resolveScrollAnchor(args);
|
|
2502
|
+
const { deltaX, deltaY } = resolveScrollDelta(args);
|
|
2503
|
+
return withPage2(driver, args.target, async (page) => {
|
|
2504
|
+
const { x, y } = await page.scroll(anchor, { deltaX, deltaY });
|
|
2505
|
+
return { x, y, deltaX, deltaY, target: target3(page.info) };
|
|
2506
|
+
});
|
|
2507
|
+
}
|
|
1858
2508
|
async function hover(driver, args) {
|
|
1859
2509
|
return withPage2(driver, args.target, async (page) => {
|
|
1860
2510
|
const { x, y } = await page.hover(locatorOf(args));
|
|
1861
2511
|
return { hovered: true, x, y };
|
|
1862
2512
|
});
|
|
1863
2513
|
}
|
|
2514
|
+
function resolveDragTo(to) {
|
|
2515
|
+
const hasUid = to.uid !== undefined && to.uid !== null && to.uid !== "";
|
|
2516
|
+
const hasSelector = typeof to.selector === "string" && to.selector.length > 0;
|
|
2517
|
+
const hasX = to.x !== undefined;
|
|
2518
|
+
const hasY = to.y !== undefined;
|
|
2519
|
+
if (hasX !== hasY)
|
|
2520
|
+
throw new SharedToolError("drag: 'to.x' and 'to.y' must be provided together");
|
|
2521
|
+
const hasXY = hasX && hasY;
|
|
2522
|
+
const count = [hasUid, hasSelector, hasXY].filter(Boolean).length;
|
|
2523
|
+
if (count !== 1)
|
|
2524
|
+
throw new SharedToolError("drag: 'to' takes exactly one of { uid }, { selector }, or { x, y }");
|
|
2525
|
+
if (hasXY) {
|
|
2526
|
+
if (!Number.isFinite(to.x) || !Number.isFinite(to.y))
|
|
2527
|
+
throw new SharedToolError("drag: 'to.x' and 'to.y' must be finite numbers");
|
|
2528
|
+
return { x: to.x, y: to.y };
|
|
2529
|
+
}
|
|
2530
|
+
return locatorOf({ uid: to.uid, selector: to.selector });
|
|
2531
|
+
}
|
|
2532
|
+
function resolveDragDestination(args) {
|
|
2533
|
+
const hasTo = args.to !== undefined && args.to !== null;
|
|
2534
|
+
const hasBy = args.by !== undefined && args.by !== null;
|
|
2535
|
+
if (hasTo === hasBy)
|
|
2536
|
+
throw new SharedToolError("drag requires exactly one of { to } or { by }");
|
|
2537
|
+
if (hasBy) {
|
|
2538
|
+
const by = args.by;
|
|
2539
|
+
const hasDx = by.dx !== undefined;
|
|
2540
|
+
const hasDy = by.dy !== undefined;
|
|
2541
|
+
if (!hasDx && !hasDy)
|
|
2542
|
+
throw new SharedToolError("drag: 'by' requires at least one of { dx } or { dy }");
|
|
2543
|
+
const dx = by.dx ?? 0;
|
|
2544
|
+
const dy = by.dy ?? 0;
|
|
2545
|
+
if (!Number.isFinite(dx) || !Number.isFinite(dy))
|
|
2546
|
+
throw new SharedToolError("drag: 'by.dx' and 'by.dy' must be finite numbers");
|
|
2547
|
+
return { dx, dy };
|
|
2548
|
+
}
|
|
2549
|
+
return resolveDragTo(args.to);
|
|
2550
|
+
}
|
|
2551
|
+
var MAX_DRAG_STEPS = 500;
|
|
2552
|
+
function resolveDragSteps(steps) {
|
|
2553
|
+
if (steps === undefined)
|
|
2554
|
+
return 2;
|
|
2555
|
+
if (typeof steps !== "number" || !Number.isInteger(steps) || steps < 1 || steps > MAX_DRAG_STEPS) {
|
|
2556
|
+
throw new SharedToolError(`drag: 'steps' must be an integer between 1 and ${MAX_DRAG_STEPS} (default 2)`);
|
|
2557
|
+
}
|
|
2558
|
+
return steps;
|
|
2559
|
+
}
|
|
2560
|
+
function resolveDragMode(mode, capabilities) {
|
|
2561
|
+
if (mode === undefined || mode === "mouse")
|
|
2562
|
+
return "mouse";
|
|
2563
|
+
if (mode !== "html5")
|
|
2564
|
+
throw new SharedToolError(`drag: unknown mode '${mode}' (use 'mouse' or 'html5')`);
|
|
2565
|
+
if (!capabilities.has("input.html5Drag")) {
|
|
2566
|
+
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.");
|
|
2567
|
+
}
|
|
2568
|
+
return "html5";
|
|
2569
|
+
}
|
|
1864
2570
|
async function drag(driver, args) {
|
|
1865
|
-
if (!args.from
|
|
1866
|
-
throw new SharedToolError("drag requires { from }
|
|
2571
|
+
if (!args.from)
|
|
2572
|
+
throw new SharedToolError("drag requires { from }");
|
|
2573
|
+
const destination = resolveDragDestination(args);
|
|
2574
|
+
const steps = resolveDragSteps(args.steps);
|
|
2575
|
+
const mode = resolveDragMode(args.mode, driver.capabilities);
|
|
1867
2576
|
return withPage2(driver, args.target, async (page) => {
|
|
1868
|
-
const { from, to } = await page.drag(locatorOf(args.from),
|
|
1869
|
-
return { dragged: true, from, to };
|
|
2577
|
+
const { from, to } = await page.drag(locatorOf(args.from), destination, { mode, steps });
|
|
2578
|
+
return { dragged: true, mode, steps, from, to };
|
|
1870
2579
|
});
|
|
1871
2580
|
}
|
|
1872
2581
|
async function fill(driver, args) {
|
|
@@ -1987,6 +2696,7 @@ var SHARED_TOOLS = {
|
|
|
1987
2696
|
click,
|
|
1988
2697
|
hover,
|
|
1989
2698
|
drag,
|
|
2699
|
+
scroll,
|
|
1990
2700
|
fill,
|
|
1991
2701
|
fill_form: fillForm,
|
|
1992
2702
|
type_text: typeText,
|
|
@@ -2008,7 +2718,7 @@ async function claimPage(driver, args = {}) {
|
|
|
2008
2718
|
const hasTarget = typeof args.target === "string" && args.target.length > 0;
|
|
2009
2719
|
const hasTargetId = typeof args.targetId === "string" && args.targetId.length > 0;
|
|
2010
2720
|
if (hasTarget && hasTargetId) {
|
|
2011
|
-
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.");
|
|
2721
|
+
throw new LeaseToolError("claim_page takes at most one of 'target' (any selector: active | index:N | url:<substr> | title:<substr> | label:<name> | <targetId>) or 'targetId' (an exact target id, kept for back-compat). Pass 'target' alone to claim an open tab, or neither to open a fresh one.");
|
|
2012
2722
|
}
|
|
2013
2723
|
const backend = backendOf2(driver);
|
|
2014
2724
|
const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
|
|
@@ -2036,12 +2746,15 @@ async function claimPage(driver, args = {}) {
|
|
|
2036
2746
|
url = page.url;
|
|
2037
2747
|
opened = true;
|
|
2038
2748
|
}
|
|
2749
|
+
const humanMs = opened ? null : await readHumanActiveMs(driver, backend, targetId);
|
|
2039
2750
|
const liveIds = [...pages.map((p) => p.id), targetId];
|
|
2040
2751
|
const { record, token } = await claimLease(backend, targetId, {
|
|
2041
2752
|
label,
|
|
2042
2753
|
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
|
|
2043
2754
|
liveIds
|
|
2044
2755
|
});
|
|
2756
|
+
await installBeacon(driver, targetId);
|
|
2757
|
+
const contention = contentionWarning(humanMs, { takeover: !opened });
|
|
2045
2758
|
return {
|
|
2046
2759
|
lease: token,
|
|
2047
2760
|
targetId,
|
|
@@ -2049,7 +2762,9 @@ async function claimPage(driver, args = {}) {
|
|
|
2049
2762
|
label: record.label,
|
|
2050
2763
|
ttlMs: record.ttlMs,
|
|
2051
2764
|
expiresAt: record.lastUsedAt + record.ttlMs,
|
|
2052
|
-
opened
|
|
2765
|
+
opened,
|
|
2766
|
+
...beaconSupported(driver) ? { humanActiveMs: humanMs } : {},
|
|
2767
|
+
...contention !== undefined ? { contention } : {}
|
|
2053
2768
|
};
|
|
2054
2769
|
}
|
|
2055
2770
|
async function releasePage(driver, args = {}) {
|
|
@@ -2084,9 +2799,22 @@ async function listLeasesTool(driver, _args = {}) {
|
|
|
2084
2799
|
const backend = backendOf2(driver);
|
|
2085
2800
|
const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
|
|
2086
2801
|
const reapedIds = new Set(reaped.map((r) => r.targetId));
|
|
2087
|
-
const
|
|
2802
|
+
const livePages = await driver.listPages();
|
|
2803
|
+
const liveIds = livePages.map((p) => p.id);
|
|
2088
2804
|
const all = await listLeases({ liveIds, liveBackend: backend });
|
|
2089
|
-
const
|
|
2805
|
+
const kept = all.filter((l) => !(l.backend === backend && reapedIds.has(l.targetId)));
|
|
2806
|
+
const liveSet = new Set(liveIds);
|
|
2807
|
+
const now = Date.now();
|
|
2808
|
+
const leases = [];
|
|
2809
|
+
for (const row of kept) {
|
|
2810
|
+
const computed = row.unreadable !== undefined ? row : { ...row, idleMs: now - row.lastUsedAt, expiresAt: row.lastUsedAt + row.ttlMs };
|
|
2811
|
+
if (row.backend !== backend || row.unreadable !== undefined || !liveSet.has(row.targetId)) {
|
|
2812
|
+
leases.push(computed);
|
|
2813
|
+
continue;
|
|
2814
|
+
}
|
|
2815
|
+
const humanMs = await readHumanActiveMs(driver, backend, row.targetId, now).catch(() => null);
|
|
2816
|
+
leases.push(humanMs === null ? computed : { ...computed, humanActiveMs: humanMs });
|
|
2817
|
+
}
|
|
2090
2818
|
return { leases, count: leases.length, ...reaped.length ? { reaped } : {} };
|
|
2091
2819
|
}
|
|
2092
2820
|
var LEASE_TOOLS = {
|
|
@@ -3242,6 +3970,273 @@ function truncate(s, max = 600) {
|
|
|
3242
3970
|
return t.length > max ? `${t.slice(0, max)}…` : t;
|
|
3243
3971
|
}
|
|
3244
3972
|
|
|
3973
|
+
// src/tools/dispatch-mouse.ts
|
|
3974
|
+
var CLICK_MODIFIER_BITS2 = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
|
|
3975
|
+
var BUTTONS = new Set(["left", "right", "middle"]);
|
|
3976
|
+
var CDP_EVENT_TYPE = {
|
|
3977
|
+
move: "mouseMoved",
|
|
3978
|
+
down: "mousePressed",
|
|
3979
|
+
up: "mouseReleased"
|
|
3980
|
+
};
|
|
3981
|
+
function modifierBits(mods) {
|
|
3982
|
+
let bits = 0;
|
|
3983
|
+
for (const m of mods ?? []) {
|
|
3984
|
+
const bit = CLICK_MODIFIER_BITS2[m];
|
|
3985
|
+
if (bit === undefined)
|
|
3986
|
+
throw new Error(`dispatch_mouse: unknown modifier '${m}' (use Alt, Control, Meta, or Shift)`);
|
|
3987
|
+
bits |= bit;
|
|
3988
|
+
}
|
|
3989
|
+
return bits;
|
|
3990
|
+
}
|
|
3991
|
+
function validateDispatchMouseArgs(args) {
|
|
3992
|
+
if (args.action !== "move" && args.action !== "down" && args.action !== "up") {
|
|
3993
|
+
throw new Error("dispatch_mouse requires { action } of 'move', 'down', or 'up'");
|
|
3994
|
+
}
|
|
3995
|
+
if (typeof args.x !== "number" || typeof args.y !== "number" || !Number.isFinite(args.x) || !Number.isFinite(args.y)) {
|
|
3996
|
+
throw new Error("dispatch_mouse requires numeric { x } and { y } on every call");
|
|
3997
|
+
}
|
|
3998
|
+
const button = args.button ?? "left";
|
|
3999
|
+
if (!BUTTONS.has(button))
|
|
4000
|
+
throw new Error(`dispatch_mouse: unknown button '${button}' (use left, right, or middle)`);
|
|
4001
|
+
}
|
|
4002
|
+
async function dispatchMouse(args) {
|
|
4003
|
+
validateDispatchMouseArgs(args);
|
|
4004
|
+
const button = args.button ?? "left";
|
|
4005
|
+
const modifiers = modifierBits(args.modifiers);
|
|
4006
|
+
return withPage(args.target, async (conn, target) => {
|
|
4007
|
+
const params = { type: CDP_EVENT_TYPE[args.action], x: args.x, y: args.y, button, modifiers };
|
|
4008
|
+
if (args.action !== "move")
|
|
4009
|
+
params.clickCount = args.clickCount ?? 1;
|
|
4010
|
+
await sendInput(conn, "chrome", target.id, "Input.dispatchMouseEvent", params);
|
|
4011
|
+
return { dispatched: args.action, x: args.x, y: args.y };
|
|
4012
|
+
});
|
|
4013
|
+
}
|
|
4014
|
+
|
|
4015
|
+
// src/tools/downloads.ts
|
|
4016
|
+
import { readdir as readdir3, rename, stat as stat4 } from "node:fs/promises";
|
|
4017
|
+
import { basename, extname, join as join8 } from "node:path";
|
|
4018
|
+
|
|
4019
|
+
// src/tools/browser-session.ts
|
|
4020
|
+
import { mkdir as mkdir9 } from "node:fs/promises";
|
|
4021
|
+
import { join as join7 } from "node:path";
|
|
4022
|
+
var ARTIFACT_DIR7 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
4023
|
+
var DOWNLOADS_DIR = join7(ARTIFACT_DIR7, "downloads");
|
|
4024
|
+
var DOWNLOAD_BUFFER_LIMIT = 50;
|
|
4025
|
+
var session;
|
|
4026
|
+
function isDisconnect(e) {
|
|
4027
|
+
const m = e instanceof Error ? e.message : String(e);
|
|
4028
|
+
return /connection (not open|closed)|closed by caller|websocket|ECONNREFUSED|socket/i.test(m);
|
|
4029
|
+
}
|
|
4030
|
+
function record(s, guid) {
|
|
4031
|
+
let r = s.downloads.find((d) => d.guid === guid);
|
|
4032
|
+
if (!r) {
|
|
4033
|
+
r = { guid, state: "inProgress", consumed: false };
|
|
4034
|
+
s.downloads.push(r);
|
|
4035
|
+
while (s.downloads.length > DOWNLOAD_BUFFER_LIMIT) {
|
|
4036
|
+
const i = s.downloads.findIndex((d) => d.consumed);
|
|
4037
|
+
s.downloads.splice(i >= 0 ? i : 0, 1);
|
|
4038
|
+
}
|
|
4039
|
+
}
|
|
4040
|
+
return r;
|
|
4041
|
+
}
|
|
4042
|
+
function subscribe(s) {
|
|
4043
|
+
s.conn.on("Browser.downloadWillBegin", (p) => {
|
|
4044
|
+
const r = record(s, String(p.guid));
|
|
4045
|
+
if (typeof p.url === "string")
|
|
4046
|
+
r.url = p.url;
|
|
4047
|
+
if (typeof p.suggestedFilename === "string")
|
|
4048
|
+
r.suggestedFilename = p.suggestedFilename;
|
|
4049
|
+
});
|
|
4050
|
+
s.conn.on("Browser.downloadProgress", (p) => {
|
|
4051
|
+
const r = record(s, String(p.guid));
|
|
4052
|
+
if (typeof p.receivedBytes === "number")
|
|
4053
|
+
r.bytes = p.receivedBytes;
|
|
4054
|
+
if (typeof p.filePath === "string")
|
|
4055
|
+
r.filePath = p.filePath;
|
|
4056
|
+
const state = String(p.state);
|
|
4057
|
+
if (state === "completed" || state === "canceled") {
|
|
4058
|
+
r.state = state;
|
|
4059
|
+
if (state === "completed") {
|
|
4060
|
+
const waiter = s.waiters.shift();
|
|
4061
|
+
if (waiter) {
|
|
4062
|
+
r.consumed = true;
|
|
4063
|
+
waiter(r);
|
|
4064
|
+
}
|
|
4065
|
+
}
|
|
4066
|
+
}
|
|
4067
|
+
});
|
|
4068
|
+
}
|
|
4069
|
+
async function getBrowserSession() {
|
|
4070
|
+
if (session && !session.dead)
|
|
4071
|
+
return session;
|
|
4072
|
+
const previousPath = session?.armedPath;
|
|
4073
|
+
const conn = await openBrowser();
|
|
4074
|
+
const s = { conn, downloads: [], waiters: [], dead: false };
|
|
4075
|
+
subscribe(s);
|
|
4076
|
+
session = s;
|
|
4077
|
+
if (previousPath)
|
|
4078
|
+
await armDownloadsAt(previousPath);
|
|
4079
|
+
return s;
|
|
4080
|
+
}
|
|
4081
|
+
async function browserSend(method, params = {}) {
|
|
4082
|
+
const s = await getBrowserSession();
|
|
4083
|
+
try {
|
|
4084
|
+
return await s.conn.send(method, params);
|
|
4085
|
+
} catch (e) {
|
|
4086
|
+
if (isDisconnect(e)) {
|
|
4087
|
+
s.dead = true;
|
|
4088
|
+
const fresh = await getBrowserSession();
|
|
4089
|
+
return await fresh.conn.send(method, params);
|
|
4090
|
+
}
|
|
4091
|
+
throw e;
|
|
4092
|
+
}
|
|
4093
|
+
}
|
|
4094
|
+
async function armDownloadsAt(path) {
|
|
4095
|
+
await mkdir9(path, { recursive: true });
|
|
4096
|
+
const s = await getBrowserSession();
|
|
4097
|
+
await s.conn.send("Browser.setDownloadBehavior", { behavior: "allowAndName", downloadPath: path, eventsEnabled: true });
|
|
4098
|
+
s.armedPath = path;
|
|
4099
|
+
}
|
|
4100
|
+
async function armDownloads() {
|
|
4101
|
+
const s = await getBrowserSession();
|
|
4102
|
+
if (s.armedPath !== DOWNLOADS_DIR)
|
|
4103
|
+
await armDownloadsAt(DOWNLOADS_DIR);
|
|
4104
|
+
return { downloadPath: DOWNLOADS_DIR };
|
|
4105
|
+
}
|
|
4106
|
+
function pendingDownloads() {
|
|
4107
|
+
return (session?.downloads ?? []).filter((d) => d.state === "completed" && !d.consumed);
|
|
4108
|
+
}
|
|
4109
|
+
async function nextCompletedDownload(timeoutMs) {
|
|
4110
|
+
const s = await getBrowserSession();
|
|
4111
|
+
const buffered = s.downloads.find((d) => d.state === "completed" && !d.consumed);
|
|
4112
|
+
if (buffered) {
|
|
4113
|
+
buffered.consumed = true;
|
|
4114
|
+
return buffered;
|
|
4115
|
+
}
|
|
4116
|
+
return new Promise((resolve, reject) => {
|
|
4117
|
+
const waiter = (r) => {
|
|
4118
|
+
clearTimeout(timer);
|
|
4119
|
+
resolve(r);
|
|
4120
|
+
};
|
|
4121
|
+
const timer = setTimeout(() => {
|
|
4122
|
+
const i = s.waiters.indexOf(waiter);
|
|
4123
|
+
if (i >= 0)
|
|
4124
|
+
s.waiters.splice(i, 1);
|
|
4125
|
+
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).`));
|
|
4126
|
+
}, timeoutMs);
|
|
4127
|
+
s.waiters.push(waiter);
|
|
4128
|
+
});
|
|
4129
|
+
}
|
|
4130
|
+
async function disposeBrowserSession() {
|
|
4131
|
+
const s = session;
|
|
4132
|
+
session = undefined;
|
|
4133
|
+
if (!s)
|
|
4134
|
+
return;
|
|
4135
|
+
s.dead = true;
|
|
4136
|
+
try {
|
|
4137
|
+
s.conn.close();
|
|
4138
|
+
} catch {}
|
|
4139
|
+
}
|
|
4140
|
+
|
|
4141
|
+
// src/tools/downloads.ts
|
|
4142
|
+
function safeFilename(suggested) {
|
|
4143
|
+
const raw = (suggested ?? "").replace(/[\u0000-\u001f]/g, "");
|
|
4144
|
+
const name = basename(raw.split("\\").pop() ?? "").trim();
|
|
4145
|
+
if (!name || name === "." || name === "..")
|
|
4146
|
+
return "download";
|
|
4147
|
+
return name;
|
|
4148
|
+
}
|
|
4149
|
+
function nextFreeName(name, taken) {
|
|
4150
|
+
if (!taken.has(name))
|
|
4151
|
+
return name;
|
|
4152
|
+
const ext = extname(name);
|
|
4153
|
+
const stem = ext ? name.slice(0, -ext.length) : name;
|
|
4154
|
+
for (let i = 1;; i++) {
|
|
4155
|
+
const candidate = `${stem}-${i}${ext}`;
|
|
4156
|
+
if (!taken.has(candidate))
|
|
4157
|
+
return candidate;
|
|
4158
|
+
}
|
|
4159
|
+
}
|
|
4160
|
+
function validateWaitForDownloadArgs(args) {
|
|
4161
|
+
if (args.timeoutMs !== undefined) {
|
|
4162
|
+
if (typeof args.timeoutMs !== "number" || !Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0) {
|
|
4163
|
+
throw new Error(`wait_for_download: 'timeoutMs' must be a positive number (got ${JSON.stringify(args.timeoutMs)})`);
|
|
4164
|
+
}
|
|
4165
|
+
}
|
|
4166
|
+
if (args.arm !== undefined && typeof args.arm !== "boolean") {
|
|
4167
|
+
throw new Error(`wait_for_download: 'arm' must be a boolean (got ${JSON.stringify(args.arm)})`);
|
|
4168
|
+
}
|
|
4169
|
+
}
|
|
4170
|
+
async function waitForDownload(args = {}) {
|
|
4171
|
+
validateWaitForDownloadArgs(args);
|
|
4172
|
+
const t = await resolveTarget(args.target);
|
|
4173
|
+
const target = { id: t.id, url: t.url, title: t.title };
|
|
4174
|
+
const { downloadPath } = await armDownloads();
|
|
4175
|
+
if (args.arm === true)
|
|
4176
|
+
return { armed: true, downloadPath, pending: pendingDownloads().length, target };
|
|
4177
|
+
const rec = await nextCompletedDownload(args.timeoutMs ?? 30000);
|
|
4178
|
+
if (!rec.filePath) {
|
|
4179
|
+
throw new Error(`wait_for_download: download ${rec.guid} completed without a file path (nothing to return)`);
|
|
4180
|
+
}
|
|
4181
|
+
const suggestedFilename = safeFilename(rec.suggestedFilename);
|
|
4182
|
+
const taken = new Set(await readdir3(DOWNLOADS_DIR).catch(() => []));
|
|
4183
|
+
taken.delete(basename(rec.filePath));
|
|
4184
|
+
const finalName = nextFreeName(suggestedFilename, taken);
|
|
4185
|
+
const path = join8(DOWNLOADS_DIR, finalName);
|
|
4186
|
+
await rename(rec.filePath, path);
|
|
4187
|
+
const bytes = (await stat4(path)).size;
|
|
4188
|
+
return { path, suggestedFilename, bytes, ...rec.url ? { url: rec.url } : {}, target };
|
|
4189
|
+
}
|
|
4190
|
+
|
|
4191
|
+
// src/tools/permissions.ts
|
|
4192
|
+
function originOf(url) {
|
|
4193
|
+
try {
|
|
4194
|
+
const o = new URL(url).origin;
|
|
4195
|
+
return o && o !== "null" ? o : undefined;
|
|
4196
|
+
} catch {
|
|
4197
|
+
return;
|
|
4198
|
+
}
|
|
4199
|
+
}
|
|
4200
|
+
function validateGrantPermissionsArgs(args) {
|
|
4201
|
+
const reset = args.reset === true;
|
|
4202
|
+
if (args.reset !== undefined && typeof args.reset !== "boolean") {
|
|
4203
|
+
throw new Error(`grant_permissions: 'reset' must be a boolean (got ${JSON.stringify(args.reset)})`);
|
|
4204
|
+
}
|
|
4205
|
+
if (args.permissions === undefined) {
|
|
4206
|
+
if (!reset)
|
|
4207
|
+
throw new Error("grant_permissions: 'permissions' is required (or pass reset:true to clear previous grants)");
|
|
4208
|
+
return;
|
|
4209
|
+
}
|
|
4210
|
+
if (!Array.isArray(args.permissions)) {
|
|
4211
|
+
throw new Error(`grant_permissions: 'permissions' must be an array of CDP PermissionType strings (got ${JSON.stringify(args.permissions)})`);
|
|
4212
|
+
}
|
|
4213
|
+
if (args.permissions.length === 0 && !reset) {
|
|
4214
|
+
throw new Error("grant_permissions: 'permissions' must not be empty (pass reset:true if the intent is to clear grants)");
|
|
4215
|
+
}
|
|
4216
|
+
for (const p of args.permissions) {
|
|
4217
|
+
if (typeof p !== "string" || p.length === 0) {
|
|
4218
|
+
throw new Error(`grant_permissions: every entry of 'permissions' must be a non-empty string (got ${JSON.stringify(p)})`);
|
|
4219
|
+
}
|
|
4220
|
+
}
|
|
4221
|
+
}
|
|
4222
|
+
async function grantPermissions(args = {}) {
|
|
4223
|
+
validateGrantPermissionsArgs(args);
|
|
4224
|
+
const t = await resolveTarget(args.target);
|
|
4225
|
+
const target = { id: t.id, url: t.url, title: t.title };
|
|
4226
|
+
const reset = args.reset === true;
|
|
4227
|
+
const permissions = args.permissions ?? [];
|
|
4228
|
+
if (reset)
|
|
4229
|
+
await browserSend("Browser.resetPermissions", {});
|
|
4230
|
+
if (permissions.length === 0)
|
|
4231
|
+
return { reset: true, target };
|
|
4232
|
+
const origin = args.origin ?? originOf(t.url);
|
|
4233
|
+
if (!origin) {
|
|
4234
|
+
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"`);
|
|
4235
|
+
}
|
|
4236
|
+
await browserSend("Browser.grantPermissions", { origin, permissions });
|
|
4237
|
+
return { granted: [...permissions], origin, ...reset ? { reset: true } : {}, target };
|
|
4238
|
+
}
|
|
4239
|
+
|
|
3245
4240
|
// src/index.ts
|
|
3246
4241
|
var cdpDriver = createCdpDriver();
|
|
3247
4242
|
function onCdp(fn) {
|
|
@@ -3262,6 +4257,7 @@ var TOOLS = {
|
|
|
3262
4257
|
click: onCdp(SHARED_TOOLS.click),
|
|
3263
4258
|
hover: onCdp(SHARED_TOOLS.hover),
|
|
3264
4259
|
drag: onCdp(SHARED_TOOLS.drag),
|
|
4260
|
+
scroll: onCdp(SHARED_TOOLS.scroll),
|
|
3265
4261
|
fill: onCdp(SHARED_TOOLS.fill),
|
|
3266
4262
|
fill_form: onCdp(SHARED_TOOLS.fill_form),
|
|
3267
4263
|
type_text: onCdp(SHARED_TOOLS.type_text),
|
|
@@ -3286,6 +4282,9 @@ var TOOLS = {
|
|
|
3286
4282
|
mock_request: mockRequest,
|
|
3287
4283
|
list_mocks: listMocks,
|
|
3288
4284
|
clear_mocks: clearMocks,
|
|
4285
|
+
dispatch_mouse: dispatchMouse,
|
|
4286
|
+
wait_for_download: waitForDownload,
|
|
4287
|
+
grant_permissions: grantPermissions,
|
|
3289
4288
|
claim_page: onCdp(LEASE_TOOLS.claim_page),
|
|
3290
4289
|
release_page: onCdp(LEASE_TOOLS.release_page),
|
|
3291
4290
|
list_leases: onCdp(LEASE_TOOLS.list_leases)
|