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/mcp.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(" + ")})`);
|
|
1704
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)");
|
|
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 = {
|
|
@@ -3190,58 +3918,325 @@ async function lighthouseAudit(args) {
|
|
|
3190
3918
|
if (Array.isArray(args.categories) && args.categories.length > 0) {
|
|
3191
3919
|
lhArgs.push(`--only-categories=${args.categories.join(",")}`);
|
|
3192
3920
|
}
|
|
3193
|
-
const res = await runProcess("npx", lhArgs, { timeoutMs });
|
|
3194
|
-
if (res.spawnError) {
|
|
3195
|
-
const code = res.spawnError.code;
|
|
3196
|
-
if (code === "ENOENT") {
|
|
3197
|
-
throw new CdpError("lighthouse_audit requires 'npx' (Node.js) on PATH to fetch and run the Lighthouse CLI; 'npx' was not found.");
|
|
3921
|
+
const res = await runProcess("npx", lhArgs, { timeoutMs });
|
|
3922
|
+
if (res.spawnError) {
|
|
3923
|
+
const code = res.spawnError.code;
|
|
3924
|
+
if (code === "ENOENT") {
|
|
3925
|
+
throw new CdpError("lighthouse_audit requires 'npx' (Node.js) on PATH to fetch and run the Lighthouse CLI; 'npx' was not found.");
|
|
3926
|
+
}
|
|
3927
|
+
throw new CdpError(`lighthouse_audit failed to spawn npx/lighthouse: ${res.spawnError.message}`);
|
|
3928
|
+
}
|
|
3929
|
+
let raw;
|
|
3930
|
+
try {
|
|
3931
|
+
raw = await readFile6(outPath, "utf8");
|
|
3932
|
+
} catch {
|
|
3933
|
+
raw = undefined;
|
|
3934
|
+
}
|
|
3935
|
+
if (raw === undefined) {
|
|
3936
|
+
if (res.signal === "SIGKILL") {
|
|
3937
|
+
throw new CdpError(`lighthouse_audit timed out after ${timeoutMs}ms (no report written). stderr: ${truncate(res.stderr)}`);
|
|
3938
|
+
}
|
|
3939
|
+
const hint = /not found|could not determine executable|npm ERR|E404|ERR_MODULE/i.test(res.stderr) ? " The Lighthouse CLI could not be fetched/installed via npx; ensure network access and that 'lighthouse' is installable." : "";
|
|
3940
|
+
throw new CdpError(`lighthouse_audit produced no report (exit code ${res.code}).${hint} stderr: ${truncate(res.stderr)}`);
|
|
3941
|
+
}
|
|
3942
|
+
let report;
|
|
3943
|
+
try {
|
|
3944
|
+
report = JSON.parse(raw);
|
|
3945
|
+
} catch (e) {
|
|
3946
|
+
throw new CdpError(`lighthouse_audit wrote a report that is not valid JSON (${e.message})`);
|
|
3947
|
+
}
|
|
3948
|
+
if (report.runtimeError?.code && report.runtimeError.code !== "NO_ERROR") {
|
|
3949
|
+
throw new CdpError(`lighthouse_audit runtime error [${report.runtimeError.code}]: ${report.runtimeError.message ?? "unknown"}`);
|
|
3950
|
+
}
|
|
3951
|
+
const categories = {};
|
|
3952
|
+
for (const [key, cat] of Object.entries(report.categories ?? {})) {
|
|
3953
|
+
categories[key] = cat?.score ?? null;
|
|
3954
|
+
}
|
|
3955
|
+
if (Object.keys(categories).length === 0) {
|
|
3956
|
+
throw new CdpError(`lighthouse_audit report contained no category scores (exit code ${res.code}). stderr: ${truncate(res.stderr)}`);
|
|
3957
|
+
}
|
|
3958
|
+
const { size } = await stat3(outPath);
|
|
3959
|
+
return {
|
|
3960
|
+
path: outPath,
|
|
3961
|
+
bytes: size,
|
|
3962
|
+
url,
|
|
3963
|
+
finalUrl: report.finalDisplayedUrl ?? report.finalUrl,
|
|
3964
|
+
lighthouseVersion: report.lighthouseVersion,
|
|
3965
|
+
fetchTime: report.fetchTime,
|
|
3966
|
+
categories,
|
|
3967
|
+
warnings: Array.isArray(report.runWarnings) && report.runWarnings.length > 0 ? report.runWarnings : undefined
|
|
3968
|
+
};
|
|
3969
|
+
}
|
|
3970
|
+
function truncate(s, max = 600) {
|
|
3971
|
+
const t = s.trim();
|
|
3972
|
+
return t.length > max ? `${t.slice(0, max)}…` : t;
|
|
3973
|
+
}
|
|
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)})`);
|
|
3198
4166
|
}
|
|
3199
|
-
throw new CdpError(`lighthouse_audit failed to spawn npx/lighthouse: ${res.spawnError.message}`);
|
|
3200
4167
|
}
|
|
3201
|
-
|
|
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) {
|
|
3202
4195
|
try {
|
|
3203
|
-
|
|
4196
|
+
const o = new URL(url).origin;
|
|
4197
|
+
return o && o !== "null" ? o : undefined;
|
|
3204
4198
|
} catch {
|
|
3205
|
-
|
|
4199
|
+
return;
|
|
3206
4200
|
}
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
throw new CdpError(`lighthouse_audit produced no report (exit code ${res.code}).${hint} stderr: ${truncate(res.stderr)}`);
|
|
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)})`);
|
|
3213
4206
|
}
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
throw new CdpError(`lighthouse_audit wrote a report that is not valid JSON (${e.message})`);
|
|
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;
|
|
3219
4211
|
}
|
|
3220
|
-
if (
|
|
3221
|
-
throw new
|
|
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)})`);
|
|
3222
4214
|
}
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
categories[key] = cat?.score ?? null;
|
|
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)");
|
|
3226
4217
|
}
|
|
3227
|
-
|
|
3228
|
-
|
|
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
|
+
}
|
|
3229
4222
|
}
|
|
3230
|
-
const { size } = await stat3(outPath);
|
|
3231
|
-
return {
|
|
3232
|
-
path: outPath,
|
|
3233
|
-
bytes: size,
|
|
3234
|
-
url,
|
|
3235
|
-
finalUrl: report.finalDisplayedUrl ?? report.finalUrl,
|
|
3236
|
-
lighthouseVersion: report.lighthouseVersion,
|
|
3237
|
-
fetchTime: report.fetchTime,
|
|
3238
|
-
categories,
|
|
3239
|
-
warnings: Array.isArray(report.runWarnings) && report.runWarnings.length > 0 ? report.runWarnings : undefined
|
|
3240
|
-
};
|
|
3241
4223
|
}
|
|
3242
|
-
function
|
|
3243
|
-
|
|
3244
|
-
|
|
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 };
|
|
3245
4240
|
}
|
|
3246
4241
|
|
|
3247
4242
|
// src/index.ts
|
|
@@ -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)
|
|
@@ -3303,7 +4302,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
|
|
|
3303
4302
|
var MANIFEST = [
|
|
3304
4303
|
{
|
|
3305
4304
|
name: "list_pages",
|
|
3306
|
-
description: "Enumerate browser page targets via the CDP browser endpoint (GET /json/list). By default returns only page-type tabs; set 'all' to also include workers and background pages. Each entry carries the targetId used as a target selector elsewhere, plus an 'origin' field recording where the tab came from: 'agent' means this toolkit created it (the entry then also carries the creating 'label' and 'createdAt'), and this stays true after the creating agent releases its lease or dies, which is what makes a stray agent tab findable later. 'unknown' means there is no creation record. It never says 'human': the toolkit cannot prove a person opened a tab, so 'unknown' is the honest word for a tab it did not create, one opened before the toolkit ran, or one whose record could not be written. A tab whose record exists but could not be read reports origin 'unknown' plus 'originUnreadable', so a broken record is never mistaken for no record. Under CDP_REQUIRE_LEASE this call also reaps
|
|
4305
|
+
description: "Enumerate browser page targets via the CDP browser endpoint (GET /json/list). By default returns only page-type tabs; set 'all' to also include workers and background pages. Each entry carries the targetId used as a target selector elsewhere, plus an 'origin' field recording where the tab came from: 'agent' means this toolkit created it (the entry then also carries the creating 'label' and 'createdAt'), and this stays true after the creating agent releases its lease or dies, which is what makes a stray agent tab findable later. 'unknown' means there is no creation record. It never says 'human': the toolkit cannot prove a person opened a tab, so 'unknown' is the honest word for a tab it did not create, one opened before the toolkit ran, or one whose record could not be written. A tab whose record exists but could not be read reports origin 'unknown' plus 'originUnreadable', so a broken record is never mistaken for no record. Under CDP_REQUIRE_LEASE this call also reaps abandoned agent tabs, but reap-CLOSE and lease-RECLAIMABLE (list_leases' 'stale') now fire at different times: a lease reads reclaimable the moment its TTL elapses, cheap and reversible since another agent may simply take it, while this call only CLOSES the tab, destructively, after an ADDITIONAL grace period past that TTL (CDP_REAP_GRACE_MS, default 2700000ms/45 minutes, so 60 minutes total after last use) — a tab an agent is mid-build between calls on is not destroyed out from under it. A dead-pid lease is reaped immediately regardless of grace: that process is never coming back. Closed tabs are reported in an additive 'reaped' array ({targetId,label,reason}) present only when something was actually closed; a tab with no lease is never reaped. Every entry under an active, readable lease of this backend also carries 'lease': {label, pid, idleMs, expiresAt, stale} — idleMs and expiresAt are computed fresh on every call (now-lastUsedAt and lastUsedAt+ttlMs), never read off disk, and this is unconditional on 'probe'. Set 'probe' to true to also ping each page-type target's renderer: one bounded (500ms) check per target, never more, so a single wedged tab cannot stall the listing beyond its own budget. This adds 'responsive' (true iff the ping answered in time; false on a timeout, a page-side exception, or an unreachable target — never an error for the whole call) and, where that same round trip found human-attributed input, 'humanActiveMs' (see claim_page and list_leases for what that field means and cannot prove). 'responsive' and 'humanActiveMs' are both ABSENT — never false/null — when 'probe' is not set, and 'responsive' is likewise absent on a backend that cannot answer this ping at all.",
|
|
3307
4306
|
inputSchema: {
|
|
3308
4307
|
type: "object",
|
|
3309
4308
|
properties: {
|
|
@@ -3314,6 +4313,10 @@ var MANIFEST = [
|
|
|
3314
4313
|
all: {
|
|
3315
4314
|
type: "boolean",
|
|
3316
4315
|
description: "Include non-page targets (service/shared workers, background pages) when true; otherwise only page-type tabs are listed."
|
|
4316
|
+
},
|
|
4317
|
+
probe: {
|
|
4318
|
+
type: "boolean",
|
|
4319
|
+
description: "Ping each page-type target's renderer before returning: one bounded (500ms) Runtime.evaluate per target, never more. Adds 'responsive' to every page-type entry and, where the same round trip finds human-attributed input, 'humanActiveMs'. A wedged or unreachable tab reports responsive:false rather than failing the call. Defaults to false, which is byte-identical to the pre-1.8.0 shape."
|
|
3317
4320
|
}
|
|
3318
4321
|
},
|
|
3319
4322
|
required: [],
|
|
@@ -3363,7 +4366,7 @@ var MANIFEST = [
|
|
|
3363
4366
|
},
|
|
3364
4367
|
target: {
|
|
3365
4368
|
type: "string",
|
|
3366
|
-
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
|
|
4369
|
+
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
|
|
3367
4370
|
}
|
|
3368
4371
|
},
|
|
3369
4372
|
required: [
|
|
@@ -3384,7 +4387,7 @@ var MANIFEST = [
|
|
|
3384
4387
|
},
|
|
3385
4388
|
target: {
|
|
3386
4389
|
type: "string",
|
|
3387
|
-
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
|
|
4390
|
+
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
|
|
3388
4391
|
}
|
|
3389
4392
|
},
|
|
3390
4393
|
required: [
|
|
@@ -3395,7 +4398,7 @@ var MANIFEST = [
|
|
|
3395
4398
|
},
|
|
3396
4399
|
{
|
|
3397
4400
|
name: "navigate_page",
|
|
3398
|
-
description: "Navigate a target page to a URL (Page.navigate)
|
|
4401
|
+
description: "Navigate a target page to a URL (Page.navigate), reload it (reload:true), OR go back/forward in its session history (history:'back'|'forward'), then wait for the load milestone with a bounded timeout so a wedged renderer can't hang. Exactly one of url / reload / history per call. Returns {url,frameId,waitedFor} (plus reloaded:true on a reload, traversed:'back'|'forward' on a history move); waitUntil supports 'load'|'domcontentloaded' only (no 'networkidle'), and there is no auto-snapshot of the new page. Pass reload:true with ignoreCache:true for a hard reload that refetches every subresource (e.g. to pick up a freshly-deployed, non-content-hashed bundle the HTTP cache would serve stale).",
|
|
3399
4402
|
inputSchema: {
|
|
3400
4403
|
type: "object",
|
|
3401
4404
|
properties: {
|
|
@@ -3405,7 +4408,7 @@ var MANIFEST = [
|
|
|
3405
4408
|
},
|
|
3406
4409
|
target: {
|
|
3407
4410
|
type: "string",
|
|
3408
|
-
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
|
|
4411
|
+
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
|
|
3409
4412
|
},
|
|
3410
4413
|
url: {
|
|
3411
4414
|
type: "string",
|
|
@@ -3415,6 +4418,14 @@ var MANIFEST = [
|
|
|
3415
4418
|
type: "boolean",
|
|
3416
4419
|
description: "Reload the current page (Page.reload) instead of navigating to url. Default false."
|
|
3417
4420
|
},
|
|
4421
|
+
history: {
|
|
4422
|
+
type: "string",
|
|
4423
|
+
enum: [
|
|
4424
|
+
"back",
|
|
4425
|
+
"forward"
|
|
4426
|
+
],
|
|
4427
|
+
description: "Traverse this tab's session history instead of loading a url: 'back' and 'forward' are the browser's Back and Forward buttons. Mutually exclusive with 'url' and 'reload' — passing two of the three is refused by name rather than resolved by precedence. Works on BOTH backends (Chrome: Page.getNavigationHistory + Page.navigateToHistoryEntry; Firefox: browsingContext.traverseHistory) and waits for the same load milestone as an ordinary navigation. Going back from the first entry, or forward from the last, is an ERROR naming the direction — never a silent success that navigated nowhere. The result carries traversed:'back'|'forward' alongside the resulting url."
|
|
4428
|
+
},
|
|
3418
4429
|
ignoreCache: {
|
|
3419
4430
|
type: "boolean",
|
|
3420
4431
|
description: "On reload, bypass the HTTP cache (hard reload) so subresources are refetched. Ignored unless reload:true. Default false."
|
|
@@ -3447,7 +4458,7 @@ var MANIFEST = [
|
|
|
3447
4458
|
},
|
|
3448
4459
|
target: {
|
|
3449
4460
|
type: "string",
|
|
3450
|
-
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
|
|
4461
|
+
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
|
|
3451
4462
|
},
|
|
3452
4463
|
text: {
|
|
3453
4464
|
type: "string",
|
|
@@ -3470,7 +4481,7 @@ var MANIFEST = [
|
|
|
3470
4481
|
},
|
|
3471
4482
|
{
|
|
3472
4483
|
name: "evaluate_script",
|
|
3473
|
-
description: "Run arbitrary JavaScript in the target page's main-world context over raw CDP and return the evaluated value (returnByValue). With no 'args' the 'expression' is evaluated as a raw expression; when 'args' is provided 'expression' must be a function literal (arrow or classic) invoked on globalThis with the args passed positionally. A thrown exception surfaces as an error; non-serializable returns (DOM nodes, functions) come back as their CDP description string. Pass 'savePath' to write the evaluated value to a JSON file instead: the response then carries only {path,bytes,type,target} and the value itself never appears in it, in any form. That is the way to read a credential (a JWT or session token out of localStorage, for example) without putting the secret into the caller's transcript.",
|
|
4484
|
+
description: "Run arbitrary JavaScript in the target page's main-world context over raw CDP and return the evaluated value (returnByValue). With no 'args' the 'expression' is evaluated as a raw expression; when 'args' is provided 'expression' must be a function literal (arrow or classic) invoked on globalThis with the args passed positionally. A thrown exception surfaces as an error; non-serializable returns (DOM nodes, functions) come back as their CDP description string. Pass 'savePath' to write the evaluated value to a JSON file instead: the response then carries only {path,bytes,type,target} and the value itself never appears in it, in any form. That is the way to read a credential (a JWT or session token out of localStorage, for example) without putting the secret into the caller's transcript. This is also the one tool that can evaluate inside an MV3 extension's background SERVICE WORKER rather than a page: pass target 'worker:<extension-id>' (Chrome only) and read chrome.storage.local, chrome.runtime and the worker's own globals directly, with an idle-evicted worker started for you first (see 'wake').",
|
|
3474
4485
|
inputSchema: {
|
|
3475
4486
|
type: "object",
|
|
3476
4487
|
properties: {
|
|
@@ -3480,11 +4491,15 @@ var MANIFEST = [
|
|
|
3480
4491
|
},
|
|
3481
4492
|
target: {
|
|
3482
4493
|
type: "string",
|
|
3483
|
-
description: "Page selector: 'active' (or omit) -> first page-type target | '<32-hex targetId>' -> exact target by id | 'index:N' -> Nth page-type target (0-based) | 'url:<substring>' -> first page whose url contains substring | 'title:<substring>' -> first page whose title contains substring."
|
|
4494
|
+
description: "Page selector: 'active' (or omit) -> first page-type target | '<32-hex targetId>' -> exact target by id | 'index:N' -> Nth page-type target (0-based) | 'url:<substring>' -> first page whose url contains substring | 'title:<substring>' -> first page whose title contains substring | 'label:<name>' -> the page-type target with exactly this label (checked against the origin ledger and live leases) | 'worker:<substring>' -> a service/shared worker whose url contains the substring, which is how you reach an MV3 extension's background service worker (worker:<extension-id> or worker:background.js) and read chrome.storage.local from it. The worker: arm is CHROME-ONLY (Capability 'worker.targets') and is accepted by this tool only; an MV3 worker that has been idle-evicted is started automatically first, see 'wake'."
|
|
4495
|
+
},
|
|
4496
|
+
wake: {
|
|
4497
|
+
type: "boolean",
|
|
4498
|
+
description: "Only valid with a 'worker:<substring>' target, and rejected on a page target rather than ignored. Default true: an MV3 extension service worker is idle-evicted after seconds of inactivity and then exists in NO target listing, so a wake asks Chrome to start it (ServiceWorker.startWorker over a page session) and then re-reads the target list to confirm it actually came up. Pass false to fail fast instead of starting anything; the error then says the worker may simply be asleep."
|
|
3484
4499
|
},
|
|
3485
4500
|
expression: {
|
|
3486
4501
|
type: "string",
|
|
3487
|
-
description: "JavaScript to run. Evaluated as an expression when 'args' is omitted; must be a function literal (arrow or classic) whose parameters receive 'args' when 'args' is provided."
|
|
4502
|
+
description: "JavaScript to run. Evaluated as an expression when 'args' is omitted; must be a function literal (arrow or classic) whose parameters receive 'args' when 'args' is provided. REQUIRED — the code always goes in this key, never in 'function', 'code', 'js', 'script', 'fn' or 'body'; a call using one of those instead is rejected with an error naming the wrong key."
|
|
3488
4503
|
},
|
|
3489
4504
|
awaitPromise: {
|
|
3490
4505
|
type: "boolean",
|
|
@@ -3517,7 +4532,7 @@ var MANIFEST = [
|
|
|
3517
4532
|
},
|
|
3518
4533
|
target: {
|
|
3519
4534
|
type: "string",
|
|
3520
|
-
description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>'."
|
|
4535
|
+
description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>' | 'label:<name>'."
|
|
3521
4536
|
},
|
|
3522
4537
|
domain: {
|
|
3523
4538
|
type: "string",
|
|
@@ -3548,7 +4563,7 @@ var MANIFEST = [
|
|
|
3548
4563
|
},
|
|
3549
4564
|
target: {
|
|
3550
4565
|
type: "string",
|
|
3551
|
-
description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>'."
|
|
4566
|
+
description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>' | 'label:<name>'."
|
|
3552
4567
|
},
|
|
3553
4568
|
name: {
|
|
3554
4569
|
type: "string",
|
|
@@ -3604,7 +4619,7 @@ var MANIFEST = [
|
|
|
3604
4619
|
},
|
|
3605
4620
|
target: {
|
|
3606
4621
|
type: "string",
|
|
3607
|
-
description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>'."
|
|
4622
|
+
description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>' | 'label:<name>'."
|
|
3608
4623
|
},
|
|
3609
4624
|
name: {
|
|
3610
4625
|
type: "string",
|
|
@@ -3639,7 +4654,7 @@ var MANIFEST = [
|
|
|
3639
4654
|
},
|
|
3640
4655
|
target: {
|
|
3641
4656
|
type: "string",
|
|
3642
|
-
description: "Target page selector: 'active' (default, = first page-type target) | 'index:N' (Nth page-type target, 0-based) | 'url:<substring>' | 'title:<substring>' | '<32-hex targetId>' (exact target by id)."
|
|
4657
|
+
description: "Target page selector: 'active' (default, = first page-type target) | 'index:N' (Nth page-type target, 0-based) | 'url:<substring>' | 'title:<substring>' | 'label:<name>' (exact label match, origin ledger or live lease) | '<32-hex targetId>' (exact target by id)."
|
|
3643
4658
|
},
|
|
3644
4659
|
interactiveOnly: {
|
|
3645
4660
|
type: "boolean",
|
|
@@ -3652,7 +4667,7 @@ var MANIFEST = [
|
|
|
3652
4667
|
},
|
|
3653
4668
|
{
|
|
3654
4669
|
name: "click",
|
|
3655
|
-
description: "Click an element via a synthetic mouse press/release at the element's scrolled-into-view bounding-rect center. Target the element with exactly one of 'uid' (a CDP backendDOMNodeId from take_snapshot) or 'selector' (a CSS selector).",
|
|
4670
|
+
description: "Click an element via a synthetic mouse press/release at the element's scrolled-into-view bounding-rect center. Target the element with exactly one of 'uid' (a CDP backendDOMNodeId from take_snapshot) or 'selector' (a CSS selector). clickCount:3 triple-clicks (selects a paragraph/line in most editors). 'modifiers' holds Alt/Control/Meta/Shift for the press and release, like a real modifier-click; on the Firefox backend a non-empty 'modifiers' throws (not yet supported over BiDi), so a modifier click there needs --browser chrome.",
|
|
3656
4671
|
inputSchema: {
|
|
3657
4672
|
type: "object",
|
|
3658
4673
|
properties: {
|
|
@@ -3662,7 +4677,7 @@ var MANIFEST = [
|
|
|
3662
4677
|
},
|
|
3663
4678
|
target: {
|
|
3664
4679
|
type: "string",
|
|
3665
|
-
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
|
|
4680
|
+
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
|
|
3666
4681
|
},
|
|
3667
4682
|
uid: {
|
|
3668
4683
|
type: "number",
|
|
@@ -3683,7 +4698,20 @@ var MANIFEST = [
|
|
|
3683
4698
|
},
|
|
3684
4699
|
clickCount: {
|
|
3685
4700
|
type: "number",
|
|
3686
|
-
description: "Number of clicks: 1 = single (default), 2 = double-click."
|
|
4701
|
+
description: "Number of clicks: 1 = single (default), 2 = double-click, 3 = triple-click."
|
|
4702
|
+
},
|
|
4703
|
+
modifiers: {
|
|
4704
|
+
type: "array",
|
|
4705
|
+
items: {
|
|
4706
|
+
type: "string",
|
|
4707
|
+
enum: [
|
|
4708
|
+
"Alt",
|
|
4709
|
+
"Control",
|
|
4710
|
+
"Meta",
|
|
4711
|
+
"Shift"
|
|
4712
|
+
]
|
|
4713
|
+
},
|
|
4714
|
+
description: "Modifier keys held for the click's press and release, e.g. ['Shift'] for a shift-click. Not supported on the Firefox backend: passing a non-empty array there throws."
|
|
3687
4715
|
}
|
|
3688
4716
|
},
|
|
3689
4717
|
required: [],
|
|
@@ -3702,7 +4730,7 @@ var MANIFEST = [
|
|
|
3702
4730
|
},
|
|
3703
4731
|
target: {
|
|
3704
4732
|
type: "string",
|
|
3705
|
-
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
|
|
4733
|
+
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
|
|
3706
4734
|
},
|
|
3707
4735
|
uid: {
|
|
3708
4736
|
type: "number",
|
|
@@ -3719,7 +4747,7 @@ var MANIFEST = [
|
|
|
3719
4747
|
},
|
|
3720
4748
|
{
|
|
3721
4749
|
name: "drag",
|
|
3722
|
-
description:
|
|
4750
|
+
description: `Drag from a source element to a destination. 'from' takes exactly one of uid (a CDP backendDOMNodeId from take_snapshot) or selector. The destination is exactly one of 'to' (an element via uid/selector, or an absolute viewport point via x+y) or 'by' ({dx,dy} offset from the source point — sliders, map panning, resize handles). mode:'mouse' (default) dispatches synthetic mouse press/move/release: right for widgets built on raw pointer events, and Chrome does turn it into a real drag, but WHICH drag events reach the page depends on where the interpolated pointer path happens to land — measured on Chrome 151, the default steps:2 delivers ZERO dragover events, so an HTML5 drop zone written the standard way (preventDefault inside dragover) refuses the drop entirely. mode:'html5' performs the drag deterministically instead: Chrome's drag interception hands back the DragData the page's own dragstart built, and the toolkit replays it as dragEnter/dragOver/drop exactly at the destination, so a draggable="true" / dataTransfer drop zone works regardless of the pointer path. mode:'html5' is CHROME-ONLY: it requires capability 'input.html5Drag' and is rejected with a clear error under the Firefox backend, where the tool itself remains available for mouse-mode drags. 'steps' (default 2) sets how many interpolated mouse-move events are dispatched between source and destination; raise it for DnD libraries with a movement threshold or per-frame sampling.`,
|
|
3723
4751
|
inputSchema: {
|
|
3724
4752
|
type: "object",
|
|
3725
4753
|
properties: {
|
|
@@ -3729,7 +4757,7 @@ var MANIFEST = [
|
|
|
3729
4757
|
},
|
|
3730
4758
|
target: {
|
|
3731
4759
|
type: "string",
|
|
3732
|
-
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
|
|
4760
|
+
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
|
|
3733
4761
|
},
|
|
3734
4762
|
from: {
|
|
3735
4763
|
type: "object",
|
|
@@ -3748,7 +4776,7 @@ var MANIFEST = [
|
|
|
3748
4776
|
},
|
|
3749
4777
|
to: {
|
|
3750
4778
|
type: "object",
|
|
3751
|
-
description: "
|
|
4779
|
+
description: "Where the drag ends: exactly one of uid, selector, or x+y. Mutually exclusive with 'by'; exactly one of to/by is required.",
|
|
3752
4780
|
properties: {
|
|
3753
4781
|
uid: {
|
|
3754
4782
|
type: "number",
|
|
@@ -3757,18 +4785,222 @@ var MANIFEST = [
|
|
|
3757
4785
|
selector: {
|
|
3758
4786
|
type: "string",
|
|
3759
4787
|
description: "CSS selector for the destination element (resolved via document.querySelector)."
|
|
4788
|
+
},
|
|
4789
|
+
x: {
|
|
4790
|
+
type: "number",
|
|
4791
|
+
description: "Absolute viewport x-coordinate to drop at. Must be given together with 'y'. Use this when there is no droppable element to name (a canvas, a slider track)."
|
|
4792
|
+
},
|
|
4793
|
+
y: {
|
|
4794
|
+
type: "number",
|
|
4795
|
+
description: "Absolute viewport y-coordinate to drop at. Must be given together with 'x'."
|
|
4796
|
+
}
|
|
4797
|
+
},
|
|
4798
|
+
additionalProperties: false
|
|
4799
|
+
},
|
|
4800
|
+
by: {
|
|
4801
|
+
type: "object",
|
|
4802
|
+
description: "Drag by an offset from the source element's center instead of to a destination: at least one of dx/dy, the other defaults to 0. Mutually exclusive with 'to'; exactly one of to/by is required. Use for sliders (by:{dx:40}), map panning, and resize handles.",
|
|
4803
|
+
properties: {
|
|
4804
|
+
dx: {
|
|
4805
|
+
type: "number",
|
|
4806
|
+
description: "Horizontal offset in CSS pixels; positive drags RIGHT."
|
|
4807
|
+
},
|
|
4808
|
+
dy: {
|
|
4809
|
+
type: "number",
|
|
4810
|
+
description: "Vertical offset in CSS pixels; positive drags DOWN."
|
|
3760
4811
|
}
|
|
3761
4812
|
},
|
|
3762
4813
|
additionalProperties: false
|
|
4814
|
+
},
|
|
4815
|
+
mode: {
|
|
4816
|
+
type: "string",
|
|
4817
|
+
enum: [
|
|
4818
|
+
"mouse",
|
|
4819
|
+
"html5"
|
|
4820
|
+
],
|
|
4821
|
+
description: "'mouse' (default): synthetic mouse press/move/release, right for widgets built on raw pointer events. Chrome does turn this into a real HTML5 drag too, but WHICH drag events reach the page depends on the interpolated pointer path: at the default steps:2, an HTML5 drop zone written the standard way (preventDefault inside dragover) sees zero dragover events and refuses the drop. 'html5': real HTML5 drag-and-drop (dragstart/dragEnter/dragOver/drop with the page's own dataTransfer), deterministic regardless of pointer path. 'html5' is CHROME-ONLY and is rejected with a clear error under the Firefox backend."
|
|
4822
|
+
},
|
|
4823
|
+
steps: {
|
|
4824
|
+
type: "number",
|
|
4825
|
+
description: "Number of interpolated mouse-move events dispatched between the source and destination points, evenly spaced, the last landing exactly on the destination. Integer 1-500, default 2 (midpoint then destination). Raise it for DnD libraries with a movement threshold or per-frame sampling."
|
|
4826
|
+
}
|
|
4827
|
+
},
|
|
4828
|
+
required: [
|
|
4829
|
+
"from"
|
|
4830
|
+
],
|
|
4831
|
+
additionalProperties: false
|
|
4832
|
+
}
|
|
4833
|
+
},
|
|
4834
|
+
{
|
|
4835
|
+
name: "scroll",
|
|
4836
|
+
description: "Dispatch a wheel/scroll event at an anchor point: provide at most one of 'uid', 'selector', or 'x'+'y'; omit all three to scroll at the viewport center. An element anchor ('uid' or 'selector') is scrolled into view first, the same as click/hover. At least one of 'deltaX'/'deltaY' is required; positive 'deltaY' scrolls DOWN and positive 'deltaX' scrolls RIGHT (wheel-event convention). Chrome dispatches Input.dispatchMouseEvent{type:'mouseWheel'}; Firefox dispatches WebDriver BiDi's 'wheel' input source. Returns the resolved anchor point ({x,y}) plus the delta actually dispatched.",
|
|
4837
|
+
inputSchema: {
|
|
4838
|
+
type: "object",
|
|
4839
|
+
properties: {
|
|
4840
|
+
lease: {
|
|
4841
|
+
type: "string",
|
|
4842
|
+
description: "Opaque lease token from claim_page. Omit it for a tab this process already holds: under CDP_REQUIRE_LEASE the gate acquires a lease automatically and one acquired that way passes for any later call from the same process. Required when the tab is held by ANOTHER process, and required for a tab claimed explicitly via claim_page or new_page{claim:true}, which always demands its own token. Without CDP_REQUIRE_LEASE an unleased tab needs no token at all."
|
|
4843
|
+
},
|
|
4844
|
+
target: {
|
|
4845
|
+
type: "string",
|
|
4846
|
+
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
|
|
4847
|
+
},
|
|
4848
|
+
uid: {
|
|
4849
|
+
type: "number",
|
|
4850
|
+
description: "CDP backendDOMNodeId of the element to scroll into view and anchor at, obtained from take_snapshot. Provide at most one of uid, selector, or x+y; omit all three to scroll at the viewport center."
|
|
4851
|
+
},
|
|
4852
|
+
selector: {
|
|
4853
|
+
type: "string",
|
|
4854
|
+
description: "CSS selector for the element to scroll into view and anchor at (resolved via document.querySelector). Provide at most one of uid, selector, or x+y; omit all three to scroll at the viewport center."
|
|
4855
|
+
},
|
|
4856
|
+
x: {
|
|
4857
|
+
type: "number",
|
|
4858
|
+
description: "Absolute viewport x-coordinate to anchor the scroll at. Must be given together with 'y'. Provide at most one of uid, selector, or x+y."
|
|
4859
|
+
},
|
|
4860
|
+
y: {
|
|
4861
|
+
type: "number",
|
|
4862
|
+
description: "Absolute viewport y-coordinate to anchor the scroll at. Must be given together with 'x'. Provide at most one of uid, selector, or x+y."
|
|
4863
|
+
},
|
|
4864
|
+
deltaX: {
|
|
4865
|
+
type: "number",
|
|
4866
|
+
description: "Horizontal scroll delta; positive scrolls RIGHT. At least one of deltaX/deltaY is required."
|
|
4867
|
+
},
|
|
4868
|
+
deltaY: {
|
|
4869
|
+
type: "number",
|
|
4870
|
+
description: "Vertical scroll delta; positive scrolls DOWN. At least one of deltaX/deltaY is required."
|
|
4871
|
+
}
|
|
4872
|
+
},
|
|
4873
|
+
required: [],
|
|
4874
|
+
additionalProperties: false
|
|
4875
|
+
}
|
|
4876
|
+
},
|
|
4877
|
+
{
|
|
4878
|
+
name: "dispatch_mouse",
|
|
4879
|
+
description: "Dispatch exactly one raw mouse event ('move', 'down', or 'up') at absolute viewport coordinates: the toolkit's lowest-level input primitive. Compose move/down/move/up calls yourself to reach anything a physical mouse can do that click/drag's fixed sequences cannot — canvas drag-painting, marquee/rubber-band selection, a custom widget with its own hit-testing. Chrome-only (capability 'input.raw'): absent from tools/list under the Firefox backend, never present-and-throwing.",
|
|
4880
|
+
inputSchema: {
|
|
4881
|
+
type: "object",
|
|
4882
|
+
properties: {
|
|
4883
|
+
lease: {
|
|
4884
|
+
type: "string",
|
|
4885
|
+
description: "Opaque lease token from claim_page. Omit it for a tab this process already holds: under CDP_REQUIRE_LEASE the gate acquires a lease automatically and one acquired that way passes for any later call from the same process. Required when the tab is held by ANOTHER process, and required for a tab claimed explicitly via claim_page or new_page{claim:true}, which always demands its own token. Without CDP_REQUIRE_LEASE an unleased tab needs no token at all."
|
|
4886
|
+
},
|
|
4887
|
+
target: {
|
|
4888
|
+
type: "string",
|
|
4889
|
+
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
|
|
4890
|
+
},
|
|
4891
|
+
action: {
|
|
4892
|
+
type: "string",
|
|
4893
|
+
enum: [
|
|
4894
|
+
"move",
|
|
4895
|
+
"down",
|
|
4896
|
+
"up"
|
|
4897
|
+
],
|
|
4898
|
+
description: "Which raw event to dispatch: 'move' (mouseMoved), 'down' (mousePressed), or 'up' (mouseReleased)."
|
|
4899
|
+
},
|
|
4900
|
+
x: {
|
|
4901
|
+
type: "number",
|
|
4902
|
+
description: "Viewport x-coordinate. Required on every call: CDP has no notion of a 'current pointer position' to default from."
|
|
4903
|
+
},
|
|
4904
|
+
y: {
|
|
4905
|
+
type: "number",
|
|
4906
|
+
description: "Viewport y-coordinate. Required on every call."
|
|
4907
|
+
},
|
|
4908
|
+
button: {
|
|
4909
|
+
type: "string",
|
|
4910
|
+
enum: [
|
|
4911
|
+
"left",
|
|
4912
|
+
"right",
|
|
4913
|
+
"middle"
|
|
4914
|
+
],
|
|
4915
|
+
description: "Mouse button: 'left' (default), 'right', or 'middle'."
|
|
4916
|
+
},
|
|
4917
|
+
clickCount: {
|
|
4918
|
+
type: "number",
|
|
4919
|
+
description: "Click-run length for a 'down' or 'up' event (2 = the second half of a double-click, 3 = triple-click). Ignored for 'move'; defaults to 1."
|
|
4920
|
+
},
|
|
4921
|
+
modifiers: {
|
|
4922
|
+
type: "array",
|
|
4923
|
+
items: {
|
|
4924
|
+
type: "string",
|
|
4925
|
+
enum: [
|
|
4926
|
+
"Alt",
|
|
4927
|
+
"Control",
|
|
4928
|
+
"Meta",
|
|
4929
|
+
"Shift"
|
|
4930
|
+
]
|
|
4931
|
+
},
|
|
4932
|
+
description: "Modifier keys held for this one event, e.g. ['Shift']."
|
|
3763
4933
|
}
|
|
3764
4934
|
},
|
|
3765
4935
|
required: [
|
|
3766
|
-
"
|
|
3767
|
-
"
|
|
4936
|
+
"action",
|
|
4937
|
+
"x",
|
|
4938
|
+
"y"
|
|
3768
4939
|
],
|
|
3769
4940
|
additionalProperties: false
|
|
3770
4941
|
}
|
|
3771
4942
|
},
|
|
4943
|
+
{
|
|
4944
|
+
name: "wait_for_download",
|
|
4945
|
+
description: "Wait for a file download to finish and return it as a real file on disk: {path,suggestedFilename,bytes,url,target}. The file is written under the artifact dir's downloads/ folder and renamed from Chrome's internal guid to the page's own filename, collision-suffixed (report.csv, report-1.csv, ...). ORDERING RULE, and it is not optional: download capture must be ARMED BEFORE the click that starts the download — call wait_for_download{arm:true} first (it arms and returns immediately, reporting {armed:true,downloadPath,pending}), then click, then call wait_for_download to collect the finished file. This is Chrome's behavior, not a preference: the download-behavior override is per-connection state that Chrome REVERTS the moment the arming client disconnects, and an unarmed headless Chrome denies the download outright, so a download triggered before anything armed is lost with no file anywhere. SIDE EFFECT, browser-global: arming redirects EVERY download in this browser (all tabs, all origins) into the toolkit's downloads directory for as long as this server runs, and downloads no longer land in the user's normal Downloads folder. Because the arm lives on a connection this server process holds open, this is an MCP-server capability: under the one-shot CLI the connection dies with the process and nothing is captured. Chrome-only (capability 'browser.downloads'): absent from tools/list under the Firefox backend, never present-and-throwing, because WebDriver BiDi has no command to redirect a download to a chosen directory.",
|
|
4946
|
+
inputSchema: {
|
|
4947
|
+
type: "object",
|
|
4948
|
+
properties: {
|
|
4949
|
+
lease: {
|
|
4950
|
+
type: "string",
|
|
4951
|
+
description: "Opaque lease token from claim_page. Omit it for a tab this process already holds: under CDP_REQUIRE_LEASE the gate acquires a lease automatically and one acquired that way passes for any later call from the same process. Required when the tab is held by ANOTHER process, and required for a tab claimed explicitly via claim_page or new_page{claim:true}, which always demands its own token. Without CDP_REQUIRE_LEASE an unleased tab needs no token at all."
|
|
4952
|
+
},
|
|
4953
|
+
target: {
|
|
4954
|
+
type: "string",
|
|
4955
|
+
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'. A download is browser-scoped, so this names the tab for the lease check and for the echoed target, not which download is returned."
|
|
4956
|
+
},
|
|
4957
|
+
arm: {
|
|
4958
|
+
type: "boolean",
|
|
4959
|
+
description: "Arm download capture and return immediately instead of waiting: {armed:true,downloadPath,pending}. Call this BEFORE the click that triggers the download. 'pending' counts downloads that already completed and have not been collected yet, so an arm call doubles as a peek."
|
|
4960
|
+
},
|
|
4961
|
+
timeoutMs: {
|
|
4962
|
+
type: "number",
|
|
4963
|
+
description: "How long to wait for a download to complete, in milliseconds. Default 30000. Ignored with arm:true. A download that already completed and was not yet collected is returned immediately, so this only bounds the wait for a download still in flight."
|
|
4964
|
+
}
|
|
4965
|
+
},
|
|
4966
|
+
required: [],
|
|
4967
|
+
additionalProperties: false
|
|
4968
|
+
}
|
|
4969
|
+
},
|
|
4970
|
+
{
|
|
4971
|
+
name: "grant_permissions",
|
|
4972
|
+
description: "Grant browser permissions for an origin up front, so a page asking for geolocation / notifications / clipboard gets an answer instead of showing a prompt no agent can click. 'permissions' takes CDP PermissionType values ('geolocation', 'notifications', 'clipboardReadWrite', 'camera', 'microphone', 'midi', ...); an unknown name is refused by Chrome with the bad value in the message. Returns {granted,origin,target}, or {reset:true,target} for a reset-only call. Grants are keyed by ORIGIN, not by tab: every tab on that origin is affected, including ones opened later. reset:true clears this server's previous grants first (or instead, when no permissions are given) — note CDP's reset is not origin-scoped, so it clears them for every origin at once. The grant lives on a connection this server process holds open and Chrome DISCARDS IT when that connection closes, so this is an MCP-server capability: under the one-shot CLI the grant dies with the process. Chrome-only (capability 'browser.permissions'): absent from tools/list under the Firefox backend, never present-and-throwing.",
|
|
4973
|
+
inputSchema: {
|
|
4974
|
+
type: "object",
|
|
4975
|
+
properties: {
|
|
4976
|
+
lease: {
|
|
4977
|
+
type: "string",
|
|
4978
|
+
description: "Opaque lease token from claim_page. Omit it for a tab this process already holds: under CDP_REQUIRE_LEASE the gate acquires a lease automatically and one acquired that way passes for any later call from the same process. Required when the tab is held by ANOTHER process, and required for a tab claimed explicitly via claim_page or new_page{claim:true}, which always demands its own token. Without CDP_REQUIRE_LEASE an unleased tab needs no token at all."
|
|
4979
|
+
},
|
|
4980
|
+
target: {
|
|
4981
|
+
type: "string",
|
|
4982
|
+
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'. Used for the lease check and, when 'origin' is omitted, as the source of the origin to grant for."
|
|
4983
|
+
},
|
|
4984
|
+
permissions: {
|
|
4985
|
+
type: "array",
|
|
4986
|
+
items: {
|
|
4987
|
+
type: "string"
|
|
4988
|
+
},
|
|
4989
|
+
description: "CDP PermissionType values to grant, e.g. ['geolocation'] or ['clipboardReadWrite','clipboardSanitizedWrite']. Required unless reset:true. An empty array is refused rather than treated as a successful no-op."
|
|
4990
|
+
},
|
|
4991
|
+
origin: {
|
|
4992
|
+
type: "string",
|
|
4993
|
+
description: "Origin the grant applies to, e.g. 'https://example.com'. Defaults to the target tab's own origin. Required explicitly when that tab has no grantable origin (data:, blob:, about:blank all serialize to the opaque origin 'null'), which is refused with a message naming the tab's url."
|
|
4994
|
+
},
|
|
4995
|
+
reset: {
|
|
4996
|
+
type: "boolean",
|
|
4997
|
+
description: "Clear this server's previous permission grants (Browser.resetPermissions). With 'permissions' it resets FIRST and then grants, so the result is exactly the listed permissions; on its own it is a reset-only call answering {reset:true}. Not scoped to 'origin': CDP resets every origin at once."
|
|
4998
|
+
}
|
|
4999
|
+
},
|
|
5000
|
+
required: [],
|
|
5001
|
+
additionalProperties: false
|
|
5002
|
+
}
|
|
5003
|
+
},
|
|
3772
5004
|
{
|
|
3773
5005
|
name: "fill",
|
|
3774
5006
|
description: "Fill an element by focusing it, clearing existing content, then inserting 'value' via Input.insertText (atomic paste-like commit, not per-character keystrokes). Target with exactly one of 'uid' (a CDP backendDOMNodeId from take_snapshot) or 'selector' (a CSS selector).",
|
|
@@ -3781,7 +5013,7 @@ var MANIFEST = [
|
|
|
3781
5013
|
},
|
|
3782
5014
|
target: {
|
|
3783
5015
|
type: "string",
|
|
3784
|
-
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
|
|
5016
|
+
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
|
|
3785
5017
|
},
|
|
3786
5018
|
uid: {
|
|
3787
5019
|
type: "number",
|
|
@@ -3814,7 +5046,7 @@ var MANIFEST = [
|
|
|
3814
5046
|
},
|
|
3815
5047
|
target: {
|
|
3816
5048
|
type: "string",
|
|
3817
|
-
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
|
|
5049
|
+
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
|
|
3818
5050
|
},
|
|
3819
5051
|
fields: {
|
|
3820
5052
|
type: "array",
|
|
@@ -3860,7 +5092,7 @@ var MANIFEST = [
|
|
|
3860
5092
|
},
|
|
3861
5093
|
target: {
|
|
3862
5094
|
type: "string",
|
|
3863
|
-
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
|
|
5095
|
+
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
|
|
3864
5096
|
},
|
|
3865
5097
|
uid: {
|
|
3866
5098
|
type: "number",
|
|
@@ -3893,7 +5125,7 @@ var MANIFEST = [
|
|
|
3893
5125
|
},
|
|
3894
5126
|
target: {
|
|
3895
5127
|
type: "string",
|
|
3896
|
-
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
|
|
5128
|
+
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
|
|
3897
5129
|
},
|
|
3898
5130
|
key: {
|
|
3899
5131
|
type: "string",
|
|
@@ -3925,7 +5157,7 @@ var MANIFEST = [
|
|
|
3925
5157
|
},
|
|
3926
5158
|
target: {
|
|
3927
5159
|
type: "string",
|
|
3928
|
-
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
|
|
5160
|
+
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
|
|
3929
5161
|
},
|
|
3930
5162
|
uid: {
|
|
3931
5163
|
type: "number",
|
|
@@ -3964,7 +5196,7 @@ var MANIFEST = [
|
|
|
3964
5196
|
},
|
|
3965
5197
|
target: {
|
|
3966
5198
|
type: "string",
|
|
3967
|
-
description: "Target page selector. undefined or 'active' -> first page target; '<32-hex targetId>' -> exact target by id; 'index:N' -> Nth page target (0-based); 'url:<substring>' -> first page whose url contains substring; 'title:<substring>' -> first page whose title contains substring."
|
|
5199
|
+
description: "Target page selector. undefined or 'active' -> first page target; '<32-hex targetId>' -> exact target by id; 'index:N' -> Nth page target (0-based); 'url:<substring>' -> first page whose url contains substring; 'title:<substring>' -> first page whose title contains substring; 'label:<name>' -> the page with exactly this label (origin ledger or live lease)."
|
|
3968
5200
|
},
|
|
3969
5201
|
format: {
|
|
3970
5202
|
type: "string",
|
|
@@ -4015,7 +5247,7 @@ var MANIFEST = [
|
|
|
4015
5247
|
},
|
|
4016
5248
|
target: {
|
|
4017
5249
|
type: "string",
|
|
4018
|
-
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
|
|
5250
|
+
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
|
|
4019
5251
|
},
|
|
4020
5252
|
width: {
|
|
4021
5253
|
type: "number",
|
|
@@ -4115,7 +5347,7 @@ var MANIFEST = [
|
|
|
4115
5347
|
},
|
|
4116
5348
|
target: {
|
|
4117
5349
|
type: "string",
|
|
4118
|
-
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
|
|
5350
|
+
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
|
|
4119
5351
|
},
|
|
4120
5352
|
width: {
|
|
4121
5353
|
type: "number",
|
|
@@ -4153,7 +5385,7 @@ var MANIFEST = [
|
|
|
4153
5385
|
},
|
|
4154
5386
|
target: {
|
|
4155
5387
|
type: "string",
|
|
4156
|
-
description: "Target page selector: 'active' (or omitted) for the first page-type target | 'index:N' (0-based) | 'url:<substring>' | 'title:<substring>' | a 32-hex <targetId>."
|
|
5388
|
+
description: "Target page selector: 'active' (or omitted) for the first page-type target | 'index:N' (0-based) | 'url:<substring>' | 'title:<substring>' | 'label:<name>' (exact label match, origin ledger or live lease) | a 32-hex <targetId>."
|
|
4157
5389
|
},
|
|
4158
5390
|
accept: {
|
|
4159
5391
|
type: "boolean",
|
|
@@ -4190,7 +5422,7 @@ var MANIFEST = [
|
|
|
4190
5422
|
},
|
|
4191
5423
|
target: {
|
|
4192
5424
|
type: "string",
|
|
4193
|
-
description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>'."
|
|
5425
|
+
description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>' | 'label:<name>'."
|
|
4194
5426
|
},
|
|
4195
5427
|
reload: {
|
|
4196
5428
|
type: "boolean",
|
|
@@ -4217,7 +5449,7 @@ var MANIFEST = [
|
|
|
4217
5449
|
},
|
|
4218
5450
|
target: {
|
|
4219
5451
|
type: "string",
|
|
4220
|
-
description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>'."
|
|
5452
|
+
description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>' | 'label:<name>'."
|
|
4221
5453
|
},
|
|
4222
5454
|
index: {
|
|
4223
5455
|
type: "number",
|
|
@@ -4240,7 +5472,7 @@ var MANIFEST = [
|
|
|
4240
5472
|
},
|
|
4241
5473
|
target: {
|
|
4242
5474
|
type: "string",
|
|
4243
|
-
description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>'."
|
|
5475
|
+
description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>' | 'label:<name>'."
|
|
4244
5476
|
},
|
|
4245
5477
|
reload: {
|
|
4246
5478
|
type: "boolean",
|
|
@@ -4271,7 +5503,7 @@ var MANIFEST = [
|
|
|
4271
5503
|
},
|
|
4272
5504
|
target: {
|
|
4273
5505
|
type: "string",
|
|
4274
|
-
description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>'."
|
|
5506
|
+
description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>' | 'label:<name>'."
|
|
4275
5507
|
},
|
|
4276
5508
|
requestId: {
|
|
4277
5509
|
type: "string",
|
|
@@ -4306,7 +5538,7 @@ var MANIFEST = [
|
|
|
4306
5538
|
},
|
|
4307
5539
|
target: {
|
|
4308
5540
|
type: "string",
|
|
4309
|
-
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
|
|
5541
|
+
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
|
|
4310
5542
|
},
|
|
4311
5543
|
categories: {
|
|
4312
5544
|
type: "array",
|
|
@@ -4332,7 +5564,7 @@ var MANIFEST = [
|
|
|
4332
5564
|
},
|
|
4333
5565
|
target: {
|
|
4334
5566
|
type: "string",
|
|
4335
|
-
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
|
|
5567
|
+
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
|
|
4336
5568
|
}
|
|
4337
5569
|
},
|
|
4338
5570
|
required: [],
|
|
@@ -4370,7 +5602,7 @@ var MANIFEST = [
|
|
|
4370
5602
|
},
|
|
4371
5603
|
target: {
|
|
4372
5604
|
type: "string",
|
|
4373
|
-
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
|
|
5605
|
+
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
|
|
4374
5606
|
},
|
|
4375
5607
|
durationMs: {
|
|
4376
5608
|
type: "number",
|
|
@@ -4408,7 +5640,7 @@ var MANIFEST = [
|
|
|
4408
5640
|
},
|
|
4409
5641
|
target: {
|
|
4410
5642
|
type: "string",
|
|
4411
|
-
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
|
|
5643
|
+
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
|
|
4412
5644
|
},
|
|
4413
5645
|
format: {
|
|
4414
5646
|
type: "string",
|
|
@@ -4455,7 +5687,7 @@ var MANIFEST = [
|
|
|
4455
5687
|
},
|
|
4456
5688
|
target: {
|
|
4457
5689
|
type: "string",
|
|
4458
|
-
description: "Which recording to stop: 'active' (default, valid only when exactly one recording is in flight) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
|
|
5690
|
+
description: "Which recording to stop: 'active' (default, valid only when exactly one recording is in flight) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
|
|
4459
5691
|
},
|
|
4460
5692
|
savePath: {
|
|
4461
5693
|
type: "string",
|
|
@@ -4478,7 +5710,7 @@ var MANIFEST = [
|
|
|
4478
5710
|
},
|
|
4479
5711
|
target: {
|
|
4480
5712
|
type: "string",
|
|
4481
|
-
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
|
|
5713
|
+
description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
|
|
4482
5714
|
},
|
|
4483
5715
|
savePath: {
|
|
4484
5716
|
type: "string",
|
|
@@ -4545,7 +5777,7 @@ var MANIFEST = [
|
|
|
4545
5777
|
},
|
|
4546
5778
|
target: {
|
|
4547
5779
|
type: "string",
|
|
4548
|
-
description: "Target selector: active | index:N | url:<substr> | title:<substr> | <targetId>. Defaults to the active page."
|
|
5780
|
+
description: "Target selector: active | index:N | url:<substr> | title:<substr> | label:<name> | <targetId>. Defaults to the active page."
|
|
4549
5781
|
},
|
|
4550
5782
|
urlPattern: {
|
|
4551
5783
|
type: "string",
|
|
@@ -4647,13 +5879,13 @@ var MANIFEST = [
|
|
|
4647
5879
|
},
|
|
4648
5880
|
{
|
|
4649
5881
|
name: "claim_page",
|
|
4650
|
-
description: "Take exclusive ownership of a browser tab and return an opaque lease token. Two modes. FRESH TAB: with no target and no targetId, opens a new tab (optionally at url) and claims it, so 'give me my own tab' is one call. TAKEOVER: with target, claims a tab that is already open — this is how you work in a tab the human already has open when they ask you to, instead of opening your own — resolving any target selector against the live page list and never creating anything. The answer's 'opened' flag says which happened (true only when this call created the tab), and that same creation record is what release_page consults, so a tab you took over is left open when you release it. Any later tool call against a leased tab must carry the matching token in its 'lease' argument or it is refused, naming the holder. The lease is reclaimable once its owning process dies, its ttlMs elapses without use, or its tab is closed. Refused from the CLI: a CLI invocation is one process per call, so its lease would be reclaimable immediately.",
|
|
5882
|
+
description: "Take exclusive ownership of a browser tab and return an opaque lease token. Two modes. FRESH TAB: with no target and no targetId, opens a new tab (optionally at url) and claims it, so 'give me my own tab' is one call. TAKEOVER: with target, claims a tab that is already open — this is how you work in a tab the human already has open when they ask you to, instead of opening your own — resolving any target selector against the live page list and never creating anything. The answer's 'opened' flag says which happened (true only when this call created the tab), and that same creation record is what release_page consults, so a tab you took over is left open when you release it. Any later tool call against a leased tab must carry the matching token in its 'lease' argument or it is refused, naming the holder. The lease is reclaimable once its owning process dies, its ttlMs elapses without use, or its tab is closed. Refused from the CLI: a CLI invocation is one process per call, so its lease would be reclaimable immediately. IS SOMEBODY ELSE IN THIS TAB: the answer also carries 'humanActiveMs', milliseconds since input that this server did not dispatch — i.e. since a person last clicked, typed, or scrolled here. null means NO DATA (the tab carries no activity beacon, which is normal the first time anyone claims a person's tab, or every input on it was this server's own); it never means 'no human'. The field is absent entirely on a backend that cannot answer. On a takeover of a tab a human used within the last 30 seconds the answer additionally carries 'contention', a warning string. THE CLAIM IS NEVER REFUSED FOR THIS: taking over a person's tab is what takeover mode is for, so by the time you read the warning you already hold the lease and the tab is yours to drive. It tells you that driving it now means fighting a live person for the keyboard and mouse, so you can open your own tab instead or ask first. Known limits: input inside a cross-origin iframe is invisible to it, and a second MCP server's clicks read as human to this one.",
|
|
4651
5883
|
inputSchema: {
|
|
4652
5884
|
type: "object",
|
|
4653
5885
|
properties: {
|
|
4654
5886
|
target: {
|
|
4655
5887
|
type: "string",
|
|
4656
|
-
description: "Take over an already-open tab, e.g. one the human has open, when asked to: pass any target selector — active | index:N | url:<substr> | title:<substr> | <targetId>. Resolved against the live page list only, so it NEVER opens a tab: a selector that matches nothing is an error, not a new blank tab. The tab is left open on release because the toolkit did not create it. Refused if another live process holds it, including a lease the gate acquired for that process automatically under CDP_REQUIRE_LEASE: this takes over unleased (human) tabs and never steals one from a live agent. Mutually exclusive with targetId."
|
|
5888
|
+
description: "Take over an already-open tab, e.g. one the human has open, when asked to: pass any target selector — active | index:N | url:<substr> | title:<substr> | label:<name> | <targetId>. Resolved against the live page list only, so it NEVER opens a tab: a selector that matches nothing is an error, not a new blank tab. The tab is left open on release because the toolkit did not create it. Refused if another live process holds it, including a lease the gate acquired for that process automatically under CDP_REQUIRE_LEASE: this takes over unleased (human) tabs and never steals one from a live agent. Mutually exclusive with targetId."
|
|
4657
5889
|
},
|
|
4658
5890
|
targetId: {
|
|
4659
5891
|
type: "string",
|
|
@@ -4688,7 +5920,7 @@ var MANIFEST = [
|
|
|
4688
5920
|
},
|
|
4689
5921
|
target: {
|
|
4690
5922
|
type: "string",
|
|
4691
|
-
description: "Target selector for a tab this process holds: active | index:N | url:<substr> | title:<substr> | <targetId>. Mutually exclusive with 'lease'. Refused if another process holds the tab."
|
|
5923
|
+
description: "Target selector for a tab this process holds: active | index:N | url:<substr> | title:<substr> | label:<name> | <targetId>. Mutually exclusive with 'lease'. Refused if another process holds the tab."
|
|
4692
5924
|
},
|
|
4693
5925
|
close: {
|
|
4694
5926
|
type: "boolean",
|
|
@@ -4701,7 +5933,7 @@ var MANIFEST = [
|
|
|
4701
5933
|
},
|
|
4702
5934
|
{
|
|
4703
5935
|
name: "list_leases",
|
|
4704
|
-
description: "Enumerate every active lease for diagnosis: backend, target id, agent label, pid, createdAt, lastUsedAt, ttlMs, whether the owning process is still alive, and whether the lease is reclaimable and why. Requires no token. Never returns the lease nonce, so this cannot be used to forge a token. A row for a lease file that could not be read or parsed instead carries `unreadable` (the errno, or \"unparseable\"), with label, pid, and the timestamp fields as zero placeholders and stale always false, since stale is what marks a lease free to take and an unreadable one must never read that way. Under CDP_REQUIRE_LEASE this call reaps abandoned agent tabs exactly as list_pages does, reporting them in an additive 'reaped' array and omitting their rows from the listing.",
|
|
5936
|
+
description: "Enumerate every active lease for diagnosis: backend, target id, agent label, pid, createdAt, lastUsedAt, ttlMs, whether the owning process is still alive, and whether the lease is reclaimable and why. Requires no token. Never returns the lease nonce, so this cannot be used to forge a token. A row for a lease file that could not be read or parsed instead carries `unreadable` (the errno, or \"unparseable\"), with label, pid, and the timestamp fields as zero placeholders and stale always false, since stale is what marks a lease free to take and an unreadable one must never read that way. Under CDP_REQUIRE_LEASE this call reaps abandoned agent tabs exactly as list_pages does (see that tool's description for the grace period between a lease reading reclaimable and its tab actually being closed), reporting them in an additive 'reaped' array and omitting their rows from the listing. A row also carries 'humanActiveMs' — milliseconds since input this server did not dispatch, i.e. since a person last used that tab — WHERE THE TAB CAN ANSWER. The field is ABSENT rather than null when it cannot: no activity beacon on that tab, every input on it accounted for by this server, a row belonging to the other backend, a tab no longer open, or a page that would not evaluate. Absence therefore means 'no answer', never 'nobody is there'. Note that lastUsedAt measures TOOLKIT calls only, so a tab a person has been typing in for ten minutes looks perfectly idle by that field and is exactly the case humanActiveMs exists to expose. Two further fields are computed fresh on every call, never read off disk: 'idleMs' (now minus lastUsedAt) and 'expiresAt' (lastUsedAt plus ttlMs — the same value claim_page already returns under that name). Both are omitted on an 'unreadable' row, whose lastUsedAt and ttlMs are the zeroed placeholders described above.",
|
|
4705
5937
|
inputSchema: {
|
|
4706
5938
|
type: "object",
|
|
4707
5939
|
properties: {},
|
|
@@ -5465,6 +6697,8 @@ var KEY_VALUES = {
|
|
|
5465
6697
|
};
|
|
5466
6698
|
var MODIFIER_VALUES = { shift: "", control: "", ctrl: "", alt: "", meta: "", cmd: "", command: "" };
|
|
5467
6699
|
var BUTTON_CODES = { left: 0, middle: 1, right: 2 };
|
|
6700
|
+
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);});}";
|
|
6701
|
+
var AWAIT_SCROLL_SETTLE_SOURCE = "function(){return window.__cdpScrollSettle;}";
|
|
5468
6702
|
var BIDI_CAPABILITIES = new Set([
|
|
5469
6703
|
"emulate.deviceMetrics",
|
|
5470
6704
|
"screenshot.fullPage",
|
|
@@ -5486,6 +6720,7 @@ async function getConnection(port, timeoutMs) {
|
|
|
5486
6720
|
connections.set(port, conn);
|
|
5487
6721
|
return conn;
|
|
5488
6722
|
}
|
|
6723
|
+
var bidiBeaconScripts = new Map;
|
|
5489
6724
|
async function safeTitle(conn, contextId) {
|
|
5490
6725
|
try {
|
|
5491
6726
|
const res = await conn.send("script.evaluate", { expression: "document.title", target: { context: contextId }, awaitPromise: false });
|
|
@@ -5530,6 +6765,22 @@ async function pickContext(conn, contexts, selector) {
|
|
|
5530
6765
|
return c;
|
|
5531
6766
|
throw driverError2("no-such-target", `no context with title containing '${needle}'`);
|
|
5532
6767
|
}
|
|
6768
|
+
if (selector.startsWith("label:")) {
|
|
6769
|
+
const label = selector.slice(6);
|
|
6770
|
+
const { matchIds, knownLabels } = await resolveLiveLabel("firefox", label, contexts.map((c) => c.context));
|
|
6771
|
+
if (matchIds.length > 1) {
|
|
6772
|
+
throw driverError2("no-such-target", `label 'label:${label}' matches more than one live target: ${matchIds.join(", ")}. Resolve by target id instead.`);
|
|
6773
|
+
}
|
|
6774
|
+
if (matchIds.length === 1) {
|
|
6775
|
+
const hit = contexts.find((c) => c.context === matchIds[0]);
|
|
6776
|
+
if (hit)
|
|
6777
|
+
return hit;
|
|
6778
|
+
}
|
|
6779
|
+
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)`);
|
|
6780
|
+
}
|
|
6781
|
+
if (isWorkerSelector(selector)) {
|
|
6782
|
+
throw driverError2("unsupported", WORKER_SELECTOR_UNSUPPORTED_MESSAGE);
|
|
6783
|
+
}
|
|
5533
6784
|
const exact = contexts.find((c) => c.context === selector);
|
|
5534
6785
|
if (exact)
|
|
5535
6786
|
return exact;
|
|
@@ -5549,9 +6800,31 @@ class BidiPageDriver {
|
|
|
5549
6800
|
this.info = info;
|
|
5550
6801
|
this.browser = browser;
|
|
5551
6802
|
}
|
|
6803
|
+
async urlAndReadyState() {
|
|
6804
|
+
const raw = await this.conn.send("script.evaluate", {
|
|
6805
|
+
expression: "location.href + '\\u0000' + document.readyState",
|
|
6806
|
+
target: { context: this.contextId },
|
|
6807
|
+
awaitPromise: false
|
|
6808
|
+
}).then((r) => r.type === "success" ? String(deserializeRemote(r.result) ?? "") : "").catch(() => "");
|
|
6809
|
+
const [url = "", ready = ""] = raw.split("\x00");
|
|
6810
|
+
return [url, ready];
|
|
6811
|
+
}
|
|
6812
|
+
async settleAfterTraversal(beforeUrl, waitUntil, timeoutMs) {
|
|
6813
|
+
const ready = waitUntil === "domcontentloaded" ? ["interactive", "complete"] : ["complete"];
|
|
6814
|
+
const deadline = Date.now() + timeoutMs;
|
|
6815
|
+
for (;; ) {
|
|
6816
|
+
const [url, state] = await this.urlAndReadyState();
|
|
6817
|
+
if (url && url !== beforeUrl && ready.includes(state))
|
|
6818
|
+
return { waitedFor: waitUntil, url };
|
|
6819
|
+
if (Date.now() >= deadline)
|
|
6820
|
+
return { waitedFor: "timeout", url: url || beforeUrl };
|
|
6821
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
6822
|
+
}
|
|
6823
|
+
}
|
|
5552
6824
|
async navigate(opts) {
|
|
5553
6825
|
const reload = opts.reload === true;
|
|
5554
|
-
|
|
6826
|
+
const history = opts.history;
|
|
6827
|
+
if (!reload && !history && !opts.url)
|
|
5555
6828
|
throw driverError2("page-error", "navigate: 'url' is required unless reload:true");
|
|
5556
6829
|
const waitUntil = opts.waitUntil ?? "load";
|
|
5557
6830
|
const wait = waitUntil === "domcontentloaded" ? "interactive" : "complete";
|
|
@@ -5561,6 +6834,17 @@ class BidiPageDriver {
|
|
|
5561
6834
|
const r2 = await this.conn.send("browsingContext.reload", { context: this.contextId, ignoreCache: opts.ignoreCache === true, wait }, sendOpts);
|
|
5562
6835
|
return { url: r2.url, contextId: this.contextId, reloaded: true, waitedFor: waitUntil };
|
|
5563
6836
|
}
|
|
6837
|
+
if (history) {
|
|
6838
|
+
const [beforeUrl] = await this.urlAndReadyState();
|
|
6839
|
+
await this.conn.send("browsingContext.traverseHistory", { context: this.contextId, delta: history === "back" ? -1 : 1 }, sendOpts).catch((e) => {
|
|
6840
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
6841
|
+
if (/no such history entry|history/i.test(msg))
|
|
6842
|
+
throw driverError2("page-error", `navigate: no history entry to go ${history} to (${msg})`);
|
|
6843
|
+
throw e;
|
|
6844
|
+
});
|
|
6845
|
+
const settled = await this.settleAfterTraversal(beforeUrl, waitUntil, opts.timeoutMs ?? 15000);
|
|
6846
|
+
return { url: settled.url, contextId: this.contextId, traversed: history, waitedFor: settled.waitedFor };
|
|
6847
|
+
}
|
|
5564
6848
|
const r = await this.conn.send("browsingContext.navigate", { context: this.contextId, url: opts.url, wait }, sendOpts);
|
|
5565
6849
|
return { url: r.url, contextId: this.contextId, waitedFor: waitUntil };
|
|
5566
6850
|
} catch (e) {
|
|
@@ -5643,6 +6927,7 @@ class BidiPageDriver {
|
|
|
5643
6927
|
throw driverError2("stale-uid", `uid does not resolve: ${uid}`);
|
|
5644
6928
|
}
|
|
5645
6929
|
async performActions(actions) {
|
|
6930
|
+
recordDispatch("firefox", this.contextId);
|
|
5646
6931
|
try {
|
|
5647
6932
|
await this.conn.send("input.performActions", { context: this.contextId, actions });
|
|
5648
6933
|
} catch (e) {
|
|
@@ -5663,6 +6948,9 @@ class BidiPageDriver {
|
|
|
5663
6948
|
return { x, y };
|
|
5664
6949
|
}
|
|
5665
6950
|
async click(loc, opts) {
|
|
6951
|
+
if (opts?.modifiers && opts.modifiers.length > 0) {
|
|
6952
|
+
throw driverError2("unsupported", "click: 'modifiers' is not supported by the Firefox/BiDi driver; omit 'modifiers' or use --browser chrome for a modifier click.");
|
|
6953
|
+
}
|
|
5666
6954
|
const ref = await resolveElementLocator2(this.conn, this.contextId, loc);
|
|
5667
6955
|
const { x, y } = await this.centerOf(ref);
|
|
5668
6956
|
const button = BUTTON_CODES[opts?.button ?? "left"];
|
|
@@ -5672,19 +6960,53 @@ class BidiPageDriver {
|
|
|
5672
6960
|
await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
|
|
5673
6961
|
return { x, y };
|
|
5674
6962
|
}
|
|
5675
|
-
async
|
|
6963
|
+
async scroll(anchor, opts) {
|
|
6964
|
+
const { x, y } = await this.resolveScrollPoint(anchor);
|
|
6965
|
+
const deltaX = Math.round(opts.deltaX ?? 0);
|
|
6966
|
+
const deltaY = Math.round(opts.deltaY ?? 0);
|
|
6967
|
+
await this.callFunctionValue(ARM_SCROLL_SETTLE_WATCH_SOURCE, []);
|
|
6968
|
+
const actions = [{ type: "scroll", x: Math.round(x), y: Math.round(y), deltaX, deltaY }];
|
|
6969
|
+
await this.performActions([{ type: "wheel", id: "cdp-wheel", actions }]);
|
|
6970
|
+
await this.callFunctionValue(AWAIT_SCROLL_SETTLE_SOURCE, []).catch(() => {
|
|
6971
|
+
return;
|
|
6972
|
+
});
|
|
6973
|
+
return { x, y };
|
|
6974
|
+
}
|
|
6975
|
+
async resolveScrollPoint(anchor) {
|
|
6976
|
+
if (anchor === undefined) {
|
|
6977
|
+
const v = await this.callFunctionValue("function(){return {x: window.innerWidth / 2, y: window.innerHeight / 2};}", []);
|
|
6978
|
+
if (!v || typeof v !== "object")
|
|
6979
|
+
throw driverError2("page-error", "could not measure viewport for scroll center");
|
|
6980
|
+
return v;
|
|
6981
|
+
}
|
|
6982
|
+
if ("x" in anchor)
|
|
6983
|
+
return anchor;
|
|
6984
|
+
return this.centerOf(await resolveElementLocator2(this.conn, this.contextId, anchor));
|
|
6985
|
+
}
|
|
6986
|
+
async drag(from, to, opts) {
|
|
6987
|
+
if (opts?.mode === "html5") {
|
|
6988
|
+
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.");
|
|
6989
|
+
}
|
|
5676
6990
|
const fromPt = await this.centerOf(await resolveElementLocator2(this.conn, this.contextId, from));
|
|
5677
|
-
const toPt = await this.
|
|
6991
|
+
const toPt = await this.resolveDragDestination(to, fromPt);
|
|
6992
|
+
const steps = Math.max(1, Math.trunc(opts?.steps ?? 2));
|
|
5678
6993
|
const actions = [
|
|
5679
6994
|
{ type: "pointerMove", x: fromPt.x, y: fromPt.y, duration: 0 },
|
|
5680
|
-
{ type: "pointerDown", button: 0 }
|
|
5681
|
-
{ type: "pointerMove", x: (fromPt.x + toPt.x) / 2, y: (fromPt.y + toPt.y) / 2, duration: 50 },
|
|
5682
|
-
{ type: "pointerMove", x: toPt.x, y: toPt.y, duration: 50 },
|
|
5683
|
-
{ type: "pointerUp", button: 0 }
|
|
6995
|
+
{ type: "pointerDown", button: 0 }
|
|
5684
6996
|
];
|
|
6997
|
+
for (const pt of interpolatePoints(fromPt, toPt, steps))
|
|
6998
|
+
actions.push({ type: "pointerMove", x: pt.x, y: pt.y, duration: 50 });
|
|
6999
|
+
actions.push({ type: "pointerUp", button: 0 });
|
|
5685
7000
|
await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
|
|
5686
7001
|
return { from: fromPt, to: toPt };
|
|
5687
7002
|
}
|
|
7003
|
+
async resolveDragDestination(to, fromPt) {
|
|
7004
|
+
if ("dx" in to)
|
|
7005
|
+
return { x: fromPt.x + to.dx, y: fromPt.y + to.dy };
|
|
7006
|
+
if ("x" in to)
|
|
7007
|
+
return to;
|
|
7008
|
+
return this.centerOf(await resolveElementLocator2(this.conn, this.contextId, to));
|
|
7009
|
+
}
|
|
5688
7010
|
async sendKeys(text) {
|
|
5689
7011
|
if (!text.length)
|
|
5690
7012
|
return;
|
|
@@ -6054,7 +7376,60 @@ class BidiBrowserDriver {
|
|
|
6054
7376
|
const info = await pageInfoOf(conn, ctx);
|
|
6055
7377
|
return new BidiPageDriver(conn, ctx.context, info, this);
|
|
6056
7378
|
}
|
|
7379
|
+
async installActivityBeacon(contextId) {
|
|
7380
|
+
const conn = await getConnection(this.port, this.timeoutMs).catch(() => {
|
|
7381
|
+
return;
|
|
7382
|
+
});
|
|
7383
|
+
if (!conn)
|
|
7384
|
+
return false;
|
|
7385
|
+
try {
|
|
7386
|
+
const key = `${this.port}:${contextId}`;
|
|
7387
|
+
if (!bidiBeaconScripts.has(key)) {
|
|
7388
|
+
const registered = await conn.send("script.addPreloadScript", {
|
|
7389
|
+
functionDeclaration: BEACON_FUNCTION_DECLARATION,
|
|
7390
|
+
contexts: [contextId]
|
|
7391
|
+
});
|
|
7392
|
+
bidiBeaconScripts.set(key, registered.script);
|
|
7393
|
+
}
|
|
7394
|
+
await conn.send("script.evaluate", {
|
|
7395
|
+
expression: BEACON_SOURCE,
|
|
7396
|
+
target: { context: contextId },
|
|
7397
|
+
awaitPromise: false
|
|
7398
|
+
});
|
|
7399
|
+
return true;
|
|
7400
|
+
} catch {
|
|
7401
|
+
return false;
|
|
7402
|
+
} finally {
|
|
7403
|
+
conn.release();
|
|
7404
|
+
}
|
|
7405
|
+
}
|
|
7406
|
+
async readActivityBeacon(contextId) {
|
|
7407
|
+
const conn = await getConnection(this.port, this.timeoutMs).catch(() => {
|
|
7408
|
+
return;
|
|
7409
|
+
});
|
|
7410
|
+
if (!conn)
|
|
7411
|
+
return null;
|
|
7412
|
+
try {
|
|
7413
|
+
const res = await conn.send("script.evaluate", {
|
|
7414
|
+
expression: BEACON_READ_EXPRESSION,
|
|
7415
|
+
target: { context: contextId },
|
|
7416
|
+
awaitPromise: false
|
|
7417
|
+
});
|
|
7418
|
+
if (res.type !== "success")
|
|
7419
|
+
return null;
|
|
7420
|
+
const value = deserializeRemote(res.result);
|
|
7421
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
7422
|
+
} catch {
|
|
7423
|
+
return null;
|
|
7424
|
+
} finally {
|
|
7425
|
+
conn.release();
|
|
7426
|
+
}
|
|
7427
|
+
}
|
|
6057
7428
|
async dispose() {
|
|
7429
|
+
for (const key of [...bidiBeaconScripts.keys()]) {
|
|
7430
|
+
if (key.startsWith(`${this.port}:`))
|
|
7431
|
+
bidiBeaconScripts.delete(key);
|
|
7432
|
+
}
|
|
6058
7433
|
const conn = connections.get(this.port);
|
|
6059
7434
|
if (!conn)
|
|
6060
7435
|
return;
|
|
@@ -6071,7 +7446,7 @@ import { spawn as spawn3 } from "node:child_process";
|
|
|
6071
7446
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
6072
7447
|
import { createConnection, createServer } from "node:net";
|
|
6073
7448
|
import { tmpdir } from "node:os";
|
|
6074
|
-
import { join as
|
|
7449
|
+
import { join as join9 } from "node:path";
|
|
6075
7450
|
function resolveBinary() {
|
|
6076
7451
|
const candidates = [
|
|
6077
7452
|
process.env.FIREFOX_BIN,
|
|
@@ -6145,7 +7520,7 @@ async function launchFirefox(opts = {}) {
|
|
|
6145
7520
|
const binary = resolveBinary();
|
|
6146
7521
|
const port = await pickPort(opts.port);
|
|
6147
7522
|
const isThrowaway = opts.profilePath === undefined;
|
|
6148
|
-
const profilePath = opts.profilePath ?? mkdtempSync(
|
|
7523
|
+
const profilePath = opts.profilePath ?? mkdtempSync(join9(tmpdir(), "cdp-toolkit-ff-profile-"));
|
|
6149
7524
|
const args = ["--profile", profilePath, "--remote-debugging-port", String(port), "--no-remote"];
|
|
6150
7525
|
if (opts.headless)
|
|
6151
7526
|
args.push("--headless");
|
|
@@ -6415,7 +7790,7 @@ var FIREFOX_TOOLS = {
|
|
|
6415
7790
|
};
|
|
6416
7791
|
|
|
6417
7792
|
// src/mcp.ts
|
|
6418
|
-
var VERSION = "1.
|
|
7793
|
+
var VERSION = "1.9.0";
|
|
6419
7794
|
var BROWSER = resolveBrowserKind(stripBrowserFlag(process.argv.slice(2)));
|
|
6420
7795
|
var AVAILABILITY = toolAvailability(BROWSER);
|
|
6421
7796
|
var AVAILABLE_NAMES = new Set(AVAILABILITY.available);
|
|
@@ -6462,8 +7837,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
6462
7837
|
if (!neutralFn)
|
|
6463
7838
|
return { content: [{ type: "text", text: `unknown tool: ${name}` }], isError: true };
|
|
6464
7839
|
try {
|
|
6465
|
-
const
|
|
6466
|
-
const result = await neutralFn(
|
|
7840
|
+
const session2 = await getOrCreateFirefoxSession();
|
|
7841
|
+
const result = await neutralFn(session2.driver, args);
|
|
6467
7842
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
6468
7843
|
} catch (err) {
|
|
6469
7844
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -6481,6 +7856,7 @@ async function shutdown() {
|
|
|
6481
7856
|
return;
|
|
6482
7857
|
shuttingDown = true;
|
|
6483
7858
|
await disposeFirefoxSession();
|
|
7859
|
+
await disposeBrowserSession();
|
|
6484
7860
|
process.exit(0);
|
|
6485
7861
|
}
|
|
6486
7862
|
process.on("SIGINT", () => void shutdown());
|