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