cdp-toolkit 1.8.0 → 1.9.1
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 +237 -0
- package/CONTRACT.md +5 -1
- package/README.md +41 -8
- package/dist/cli.js +407 -121
- package/dist/index.js +377 -120
- package/dist/mcp.js +464 -166
- package/package.json +3 -2
- package/src/bidi/driver.ts +28 -0
- package/src/bidi-tools.ts +21 -0
- package/src/cdp/driver.ts +16 -1
- package/src/cdp/workers.ts +177 -0
- package/src/cli.ts +5 -1
- package/src/client.ts +52 -2
- package/src/driver.ts +27 -1
- package/src/leases-tools.ts +2 -2
- package/src/manifest.ts +61 -45
- package/src/mcp.ts +1 -1
- package/src/origins.ts +41 -1
- package/src/shared-tools.ts +104 -3
- package/src/tools/console.ts +24 -7
- package/src/tools/network.ts +21 -7
- package/src/tools/recorder.ts +110 -10
- package/src/types.ts +22 -0
- package/src/workers.ts +221 -0
package/dist/index.js
CHANGED
|
@@ -276,6 +276,185 @@ async function listLeases(opts = {}) {
|
|
|
276
276
|
return out;
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
// src/origins.ts
|
|
280
|
+
import { mkdir as mkdir2, readFile as readFile2, readdir as readdir2, unlink as unlink2, writeFile as writeFile2 } from "node:fs/promises";
|
|
281
|
+
import { join as join2 } from "node:path";
|
|
282
|
+
function safeId2(id) {
|
|
283
|
+
return id.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
284
|
+
}
|
|
285
|
+
function originFile(backend, targetId) {
|
|
286
|
+
return join2(leaseDir(), `origin-${backend}-${safeId2(targetId)}.json`);
|
|
287
|
+
}
|
|
288
|
+
var ORIGIN_FILE_RE = /^origin-(chrome|firefox)-(.+)\.json$/;
|
|
289
|
+
async function recordOrigin(backend, targetId, opts = {}) {
|
|
290
|
+
const record = {
|
|
291
|
+
backend,
|
|
292
|
+
targetId,
|
|
293
|
+
label: typeof opts.label === "string" && opts.label.length ? opts.label : defaultLabel(),
|
|
294
|
+
pid: process.pid,
|
|
295
|
+
createdAt: opts.now ?? Date.now()
|
|
296
|
+
};
|
|
297
|
+
const file = originFile(backend, targetId);
|
|
298
|
+
try {
|
|
299
|
+
await mkdir2(leaseDir(), { recursive: true });
|
|
300
|
+
try {
|
|
301
|
+
await writeFile2(file, JSON.stringify(record), { flag: "wx" });
|
|
302
|
+
return;
|
|
303
|
+
} catch (err) {
|
|
304
|
+
if (err.code !== "EEXIST")
|
|
305
|
+
throw err;
|
|
306
|
+
}
|
|
307
|
+
await unlink2(file).catch(() => {
|
|
308
|
+
return;
|
|
309
|
+
});
|
|
310
|
+
await writeFile2(file, JSON.stringify(record), { flag: "wx" });
|
|
311
|
+
} catch {}
|
|
312
|
+
}
|
|
313
|
+
async function readOrigin(backend, targetId) {
|
|
314
|
+
let raw;
|
|
315
|
+
try {
|
|
316
|
+
raw = await readFile2(originFile(backend, targetId), "utf8");
|
|
317
|
+
} catch {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
const rec = JSON.parse(raw);
|
|
322
|
+
if (typeof rec?.targetId === "string" && typeof rec?.label === "string" && typeof rec?.createdAt === "number")
|
|
323
|
+
return rec;
|
|
324
|
+
} catch {}
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
async function listOrigins(opts = {}) {
|
|
328
|
+
let names = [];
|
|
329
|
+
try {
|
|
330
|
+
names = await readdir2(leaseDir());
|
|
331
|
+
} catch {
|
|
332
|
+
return [];
|
|
333
|
+
}
|
|
334
|
+
const live = opts.liveIds === undefined ? undefined : new Set(opts.liveIds.map((id) => safeId2(id)));
|
|
335
|
+
const out = [];
|
|
336
|
+
for (const name of names) {
|
|
337
|
+
const parts = ORIGIN_FILE_RE.exec(name);
|
|
338
|
+
if (!parts)
|
|
339
|
+
continue;
|
|
340
|
+
const backend = parts[1];
|
|
341
|
+
const nameId = parts[2];
|
|
342
|
+
const scoped = live !== undefined && (opts.liveBackend === undefined || opts.liveBackend === backend);
|
|
343
|
+
if (scoped && !live.has(nameId)) {
|
|
344
|
+
await unlink2(join2(leaseDir(), name)).catch(() => {
|
|
345
|
+
return;
|
|
346
|
+
});
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
const fromName = { backend, targetId: nameId, label: "", pid: 0, createdAt: 0 };
|
|
350
|
+
let rec;
|
|
351
|
+
try {
|
|
352
|
+
rec = JSON.parse(await readFile2(join2(leaseDir(), name), "utf8"));
|
|
353
|
+
} catch (err) {
|
|
354
|
+
const code = err.code;
|
|
355
|
+
if (code === "ENOENT")
|
|
356
|
+
continue;
|
|
357
|
+
out.push({ ...fromName, unreadable: code ?? "unparseable" });
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (typeof rec?.targetId !== "string" || typeof rec?.label !== "string" || typeof rec?.createdAt !== "number") {
|
|
361
|
+
out.push({ ...fromName, unreadable: "unparseable" });
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
out.push({ backend: rec.backend, targetId: rec.targetId, label: rec.label, pid: rec.pid, createdAt: rec.createdAt });
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
async function originIndex(backend, liveIds) {
|
|
369
|
+
const rows = await listOrigins({ liveIds, liveBackend: backend }).catch(() => []);
|
|
370
|
+
const index = new Map;
|
|
371
|
+
for (const row of rows) {
|
|
372
|
+
if (row.backend !== backend)
|
|
373
|
+
continue;
|
|
374
|
+
index.set(row.targetId, row);
|
|
375
|
+
}
|
|
376
|
+
return index;
|
|
377
|
+
}
|
|
378
|
+
async function newTrackedPage(driver, backend, opts = {}) {
|
|
379
|
+
const page = await driver.newPage(opts.url);
|
|
380
|
+
await recordOrigin(backend, page.id, { label: opts.label });
|
|
381
|
+
return page;
|
|
382
|
+
}
|
|
383
|
+
async function resolveLiveLabel(backend, label, liveIds) {
|
|
384
|
+
const live = new Set(liveIds);
|
|
385
|
+
const [origins, leases] = await Promise.all([originIndex(backend), listLeases({ liveBackend: backend })]);
|
|
386
|
+
const knownLabels = new Set;
|
|
387
|
+
const matchIds = new Set;
|
|
388
|
+
for (const rec of origins.values()) {
|
|
389
|
+
if (rec.unreadable !== undefined || !live.has(rec.targetId))
|
|
390
|
+
continue;
|
|
391
|
+
knownLabels.add(rec.label);
|
|
392
|
+
if (rec.label === label)
|
|
393
|
+
matchIds.add(rec.targetId);
|
|
394
|
+
}
|
|
395
|
+
for (const rec of leases) {
|
|
396
|
+
if (rec.backend !== backend || rec.unreadable !== undefined || !live.has(rec.targetId))
|
|
397
|
+
continue;
|
|
398
|
+
knownLabels.add(rec.label);
|
|
399
|
+
if (rec.label === label)
|
|
400
|
+
matchIds.add(rec.targetId);
|
|
401
|
+
}
|
|
402
|
+
return { matchIds: [...matchIds], knownLabels: [...knownLabels].sort() };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// src/workers.ts
|
|
406
|
+
var WORKER_SELECTOR_PREFIX = "worker:";
|
|
407
|
+
var WORKER_CAPABLE_TOOLS = [
|
|
408
|
+
"evaluate_script",
|
|
409
|
+
"list_network_requests",
|
|
410
|
+
"get_network_request",
|
|
411
|
+
"list_console_messages"
|
|
412
|
+
];
|
|
413
|
+
var WORKER_TARGET_TYPES = new Set(["service_worker", "shared_worker"]);
|
|
414
|
+
function isWorkerSelector(selector) {
|
|
415
|
+
return typeof selector === "string" && selector.startsWith(WORKER_SELECTOR_PREFIX);
|
|
416
|
+
}
|
|
417
|
+
function workerNeedle(selector) {
|
|
418
|
+
return selector.slice(WORKER_SELECTOR_PREFIX.length);
|
|
419
|
+
}
|
|
420
|
+
function isWorkerTargetType(type) {
|
|
421
|
+
return type !== undefined && WORKER_TARGET_TYPES.has(type);
|
|
422
|
+
}
|
|
423
|
+
function resolveWorkerTargets(targets, needle) {
|
|
424
|
+
const liveWorkers = targets.filter((t) => isWorkerTargetType(t.type));
|
|
425
|
+
return {
|
|
426
|
+
matches: needle === "" ? [] : liveWorkers.filter((t) => t.url.includes(needle)),
|
|
427
|
+
liveWorkers
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
var WORKER_EMPTY_NEEDLE_MESSAGE = "'worker:' needs a substring of the worker's url, e.g. worker:<extension-id> or worker:background.js";
|
|
431
|
+
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, or record console/network from, an extension's background worker.";
|
|
432
|
+
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). The tools that accept a worker: target are " + `${WORKER_CAPABLE_TOOLS.join(", ")}.`;
|
|
433
|
+
function workerWakeMisuseMessage(kind) {
|
|
434
|
+
if (kind === "not-a-worker-selector") {
|
|
435
|
+
return "'wake' only applies to a target of the form 'worker:<substring>'. A page is never asleep, and a bare " + "worker target id cannot be woken either: Chrome destroys the target when an MV3 worker is evicted and " + "mints a NEW id when it restarts, so only the url-substring form can name a worker that is not running.";
|
|
436
|
+
}
|
|
437
|
+
return "'wake' only applies when a capture is being started (reload:true, or includeBody with url). Reading an " + "existing buffer cannot use a woken worker: a restarted MV3 worker gets a NEW target id, and the buffer is " + "keyed by id, so the wake would hand back an empty capture. Re-run with reload:true to record a fresh one.";
|
|
438
|
+
}
|
|
439
|
+
function workerBufferReadMissMessage(needle, liveWorkers) {
|
|
440
|
+
const running = liveWorkers.length ? ` (running workers: ${urlList(liveWorkers)})` : " (no workers are running)";
|
|
441
|
+
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. This call only READS a buffer " + "recorded earlier, and that buffer is keyed by the worker's target id, which Chrome re-mints on restart — so " + "re-run with reload:true, which starts the worker and records a fresh capture window.";
|
|
442
|
+
}
|
|
443
|
+
function urlList(workers) {
|
|
444
|
+
return workers.map((w) => w.url).join(", ");
|
|
445
|
+
}
|
|
446
|
+
function workerAmbiguityMessage(needle, matches) {
|
|
447
|
+
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.";
|
|
448
|
+
}
|
|
449
|
+
function workerMissMessage(needle, opts) {
|
|
450
|
+
const running = opts.liveWorkers?.length ? ` (running workers: ${urlList(opts.liveWorkers)})` : " (no workers are running)";
|
|
451
|
+
if (!opts.wakeAttempted) {
|
|
452
|
+
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.";
|
|
453
|
+
}
|
|
454
|
+
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.";
|
|
455
|
+
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.";
|
|
456
|
+
}
|
|
457
|
+
|
|
279
458
|
// src/client.ts
|
|
280
459
|
var BASE = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
|
|
281
460
|
var DEFAULT_TIMEOUT_MS = Number(process.env.CDP_TIMEOUT_MS ?? 15000);
|
|
@@ -431,11 +610,13 @@ async function browserWsUrl() {
|
|
|
431
610
|
}
|
|
432
611
|
async function resolveTarget(selector, opts = {}) {
|
|
433
612
|
const targets = await listTargets();
|
|
434
|
-
const hit = pickTarget(targets, selector);
|
|
613
|
+
const hit = await pickTarget(targets, selector);
|
|
614
|
+
if (isWorkerTargetType(hit.type))
|
|
615
|
+
return hit;
|
|
435
616
|
await assertLeaseOk("chrome", hit.id, { lease: opts.lease, url: hit.url, title: hit.title });
|
|
436
617
|
return hit;
|
|
437
618
|
}
|
|
438
|
-
function pickTarget(targets, selector) {
|
|
619
|
+
async function pickTarget(targets, selector) {
|
|
439
620
|
const pages = targets.filter((t) => t.type === "page");
|
|
440
621
|
if (!selector || selector === "active") {
|
|
441
622
|
if (!pages.length)
|
|
@@ -462,6 +643,30 @@ function pickTarget(targets, selector) {
|
|
|
462
643
|
throw new CdpError(`no page title containing '${needle}'`);
|
|
463
644
|
return hit;
|
|
464
645
|
}
|
|
646
|
+
if (selector.startsWith("label:")) {
|
|
647
|
+
const label = selector.slice(6);
|
|
648
|
+
const { matchIds, knownLabels } = await resolveLiveLabel("chrome", label, pages.map((t) => t.id));
|
|
649
|
+
if (matchIds.length > 1) {
|
|
650
|
+
throw new CdpError(`label 'label:${label}' matches more than one live target: ${matchIds.join(", ")}. Resolve by target id instead.`);
|
|
651
|
+
}
|
|
652
|
+
if (matchIds.length === 1) {
|
|
653
|
+
const hit = pages.find((t) => t.id === matchIds[0]);
|
|
654
|
+
if (hit)
|
|
655
|
+
return hit;
|
|
656
|
+
}
|
|
657
|
+
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)`);
|
|
658
|
+
}
|
|
659
|
+
if (isWorkerSelector(selector)) {
|
|
660
|
+
const needle = workerNeedle(selector);
|
|
661
|
+
if (needle === "")
|
|
662
|
+
throw new CdpError(WORKER_EMPTY_NEEDLE_MESSAGE);
|
|
663
|
+
const { matches, liveWorkers } = resolveWorkerTargets(targets, needle);
|
|
664
|
+
if (matches.length > 1)
|
|
665
|
+
throw new CdpError(workerAmbiguityMessage(needle, matches));
|
|
666
|
+
if (matches.length === 1)
|
|
667
|
+
return matches[0];
|
|
668
|
+
throw new CdpError(workerMissMessage(needle, { wakeAttempted: false, liveWorkers }));
|
|
669
|
+
}
|
|
465
670
|
const byId = targets.find((t) => t.id === selector);
|
|
466
671
|
if (!byId)
|
|
467
672
|
throw new CdpError(`no target with id '${selector}'`);
|
|
@@ -651,6 +856,91 @@ async function resolveUid(conn, uid) {
|
|
|
651
856
|
return { objectId: object.objectId };
|
|
652
857
|
}
|
|
653
858
|
|
|
859
|
+
// src/cdp/workers.ts
|
|
860
|
+
var INVENTORY_FIRST_EVENT_MS = 1500;
|
|
861
|
+
var INVENTORY_SETTLE_MS = 250;
|
|
862
|
+
var WAKE_POLL_MS = 300;
|
|
863
|
+
var WAKE_POLL_ATTEMPTS = 30;
|
|
864
|
+
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.";
|
|
865
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
866
|
+
async function requestWorkerStart(needle) {
|
|
867
|
+
const conn = await openBrowser();
|
|
868
|
+
try {
|
|
869
|
+
const host = (await listTargets()).find((t) => t.type === "page");
|
|
870
|
+
if (!host)
|
|
871
|
+
throw new CdpError(NO_HOST_PAGE_MESSAGE);
|
|
872
|
+
const { sessionId } = await conn.send("Target.attachToTarget", {
|
|
873
|
+
targetId: host.id,
|
|
874
|
+
flatten: true
|
|
875
|
+
});
|
|
876
|
+
if (!sessionId)
|
|
877
|
+
throw new CdpError("could not attach a session to host the ServiceWorker domain");
|
|
878
|
+
const scopeById = new Map;
|
|
879
|
+
const versions = [];
|
|
880
|
+
conn.on("ServiceWorker.workerRegistrationUpdated", (params) => {
|
|
881
|
+
for (const r of params.registrations ?? []) {
|
|
882
|
+
if (r.isDeleted === true)
|
|
883
|
+
continue;
|
|
884
|
+
scopeById.set(String(r.registrationId), String(r.scopeURL ?? ""));
|
|
885
|
+
}
|
|
886
|
+
});
|
|
887
|
+
conn.on("ServiceWorker.workerVersionUpdated", (params) => {
|
|
888
|
+
for (const v of params.versions ?? []) {
|
|
889
|
+
versions.push({ registrationId: String(v.registrationId), scriptURL: String(v.scriptURL ?? "") });
|
|
890
|
+
}
|
|
891
|
+
});
|
|
892
|
+
await conn.send("ServiceWorker.enable", {}, { sessionId });
|
|
893
|
+
await conn.waitFor("ServiceWorker.workerVersionUpdated", undefined, INVENTORY_FIRST_EVENT_MS).catch(() => {
|
|
894
|
+
return;
|
|
895
|
+
});
|
|
896
|
+
await sleep(INVENTORY_SETTLE_MS);
|
|
897
|
+
const scopes = [...new Set(scopeById.values())].filter((s) => s.length > 0).sort();
|
|
898
|
+
const matched = new Set;
|
|
899
|
+
for (const v of versions) {
|
|
900
|
+
const scope = scopeById.get(v.registrationId);
|
|
901
|
+
if (scope === undefined)
|
|
902
|
+
continue;
|
|
903
|
+
if (v.scriptURL.includes(needle) || scope.includes(needle))
|
|
904
|
+
matched.add(scope);
|
|
905
|
+
}
|
|
906
|
+
if (matched.size === 0)
|
|
907
|
+
return { startRequested: false, scopes };
|
|
908
|
+
if (matched.size > 1) {
|
|
909
|
+
throw new CdpError(`'worker:${needle}' matches more than one registered service-worker scope: ${[...matched].sort().join(", ")}. ` + "Narrow the substring.");
|
|
910
|
+
}
|
|
911
|
+
await conn.send("ServiceWorker.startWorker", { scopeURL: [...matched][0] }, { sessionId });
|
|
912
|
+
return { startRequested: true, scopes };
|
|
913
|
+
} finally {
|
|
914
|
+
conn.close();
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
async function resolveWorkerSelector(selector, opts) {
|
|
918
|
+
const needle = workerNeedle(selector);
|
|
919
|
+
if (needle === "")
|
|
920
|
+
throw new CdpError(WORKER_EMPTY_NEEDLE_MESSAGE);
|
|
921
|
+
const first = resolveWorkerTargets(await listTargets(), needle);
|
|
922
|
+
if (first.matches.length > 1)
|
|
923
|
+
throw new CdpError(workerAmbiguityMessage(needle, first.matches));
|
|
924
|
+
if (first.matches.length === 1)
|
|
925
|
+
return first.matches[0];
|
|
926
|
+
if (!opts.wake) {
|
|
927
|
+
throw new CdpError(workerMissMessage(needle, { wakeAttempted: false, liveWorkers: first.liveWorkers }));
|
|
928
|
+
}
|
|
929
|
+
const { startRequested, scopes } = await requestWorkerStart(needle);
|
|
930
|
+
if (startRequested) {
|
|
931
|
+
for (let i = 0;i < WAKE_POLL_ATTEMPTS; i++) {
|
|
932
|
+
await sleep(WAKE_POLL_MS);
|
|
933
|
+
const again = resolveWorkerTargets(await listTargets(), needle);
|
|
934
|
+
if (again.matches.length > 1)
|
|
935
|
+
throw new CdpError(workerAmbiguityMessage(needle, again.matches));
|
|
936
|
+
if (again.matches.length === 1)
|
|
937
|
+
return again.matches[0];
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
const final = resolveWorkerTargets(await listTargets(), needle);
|
|
941
|
+
throw new CdpError(workerMissMessage(needle, { wakeAttempted: true, liveWorkers: final.liveWorkers, registeredScopes: scopes }));
|
|
942
|
+
}
|
|
943
|
+
|
|
654
944
|
// src/tools/network_mock.ts
|
|
655
945
|
function escapeRegExpChar(ch) {
|
|
656
946
|
return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
|
|
@@ -1132,7 +1422,8 @@ var CDP_CAPABILITIES = new Set([
|
|
|
1132
1422
|
"input.raw",
|
|
1133
1423
|
"input.html5Drag",
|
|
1134
1424
|
"browser.downloads",
|
|
1135
|
-
"browser.permissions"
|
|
1425
|
+
"browser.permissions",
|
|
1426
|
+
"worker.targets"
|
|
1136
1427
|
]);
|
|
1137
1428
|
|
|
1138
1429
|
class CdpPageDriver {
|
|
@@ -1700,6 +1991,10 @@ class CdpBrowserDriver {
|
|
|
1700
1991
|
await withBrowserConn((conn) => conn.send("Target.activateTarget", { targetId: target.id }));
|
|
1701
1992
|
return { id: target.id, url: target.url, title: target.title };
|
|
1702
1993
|
}
|
|
1994
|
+
async resolveWorkerTarget(selector, opts) {
|
|
1995
|
+
const target = await resolveWorkerSelector(selector, opts).catch(noSuchTarget);
|
|
1996
|
+
return { id: target.id, url: target.url, title: target.title, type: target.type };
|
|
1997
|
+
}
|
|
1703
1998
|
async page(selector) {
|
|
1704
1999
|
const { conn, target } = await openPage(selector).catch(noSuchTarget);
|
|
1705
2000
|
return new CdpPageDriver(conn, target, this);
|
|
@@ -1786,111 +2081,6 @@ function createCdpDriver() {
|
|
|
1786
2081
|
return new CdpBrowserDriver;
|
|
1787
2082
|
}
|
|
1788
2083
|
|
|
1789
|
-
// src/origins.ts
|
|
1790
|
-
import { mkdir as mkdir2, readFile as readFile2, readdir as readdir2, unlink as unlink2, writeFile as writeFile2 } from "node:fs/promises";
|
|
1791
|
-
import { join as join2 } from "node:path";
|
|
1792
|
-
function safeId2(id) {
|
|
1793
|
-
return id.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
1794
|
-
}
|
|
1795
|
-
function originFile(backend, targetId) {
|
|
1796
|
-
return join2(leaseDir(), `origin-${backend}-${safeId2(targetId)}.json`);
|
|
1797
|
-
}
|
|
1798
|
-
var ORIGIN_FILE_RE = /^origin-(chrome|firefox)-(.+)\.json$/;
|
|
1799
|
-
async function recordOrigin(backend, targetId, opts = {}) {
|
|
1800
|
-
const record = {
|
|
1801
|
-
backend,
|
|
1802
|
-
targetId,
|
|
1803
|
-
label: typeof opts.label === "string" && opts.label.length ? opts.label : defaultLabel(),
|
|
1804
|
-
pid: process.pid,
|
|
1805
|
-
createdAt: opts.now ?? Date.now()
|
|
1806
|
-
};
|
|
1807
|
-
const file = originFile(backend, targetId);
|
|
1808
|
-
try {
|
|
1809
|
-
await mkdir2(leaseDir(), { recursive: true });
|
|
1810
|
-
try {
|
|
1811
|
-
await writeFile2(file, JSON.stringify(record), { flag: "wx" });
|
|
1812
|
-
return;
|
|
1813
|
-
} catch (err) {
|
|
1814
|
-
if (err.code !== "EEXIST")
|
|
1815
|
-
throw err;
|
|
1816
|
-
}
|
|
1817
|
-
await unlink2(file).catch(() => {
|
|
1818
|
-
return;
|
|
1819
|
-
});
|
|
1820
|
-
await writeFile2(file, JSON.stringify(record), { flag: "wx" });
|
|
1821
|
-
} catch {}
|
|
1822
|
-
}
|
|
1823
|
-
async function readOrigin(backend, targetId) {
|
|
1824
|
-
let raw;
|
|
1825
|
-
try {
|
|
1826
|
-
raw = await readFile2(originFile(backend, targetId), "utf8");
|
|
1827
|
-
} catch {
|
|
1828
|
-
return;
|
|
1829
|
-
}
|
|
1830
|
-
try {
|
|
1831
|
-
const rec = JSON.parse(raw);
|
|
1832
|
-
if (typeof rec?.targetId === "string" && typeof rec?.label === "string" && typeof rec?.createdAt === "number")
|
|
1833
|
-
return rec;
|
|
1834
|
-
} catch {}
|
|
1835
|
-
return;
|
|
1836
|
-
}
|
|
1837
|
-
async function listOrigins(opts = {}) {
|
|
1838
|
-
let names = [];
|
|
1839
|
-
try {
|
|
1840
|
-
names = await readdir2(leaseDir());
|
|
1841
|
-
} catch {
|
|
1842
|
-
return [];
|
|
1843
|
-
}
|
|
1844
|
-
const live = opts.liveIds === undefined ? undefined : new Set(opts.liveIds.map((id) => safeId2(id)));
|
|
1845
|
-
const out = [];
|
|
1846
|
-
for (const name of names) {
|
|
1847
|
-
const parts = ORIGIN_FILE_RE.exec(name);
|
|
1848
|
-
if (!parts)
|
|
1849
|
-
continue;
|
|
1850
|
-
const backend = parts[1];
|
|
1851
|
-
const nameId = parts[2];
|
|
1852
|
-
const scoped = live !== undefined && (opts.liveBackend === undefined || opts.liveBackend === backend);
|
|
1853
|
-
if (scoped && !live.has(nameId)) {
|
|
1854
|
-
await unlink2(join2(leaseDir(), name)).catch(() => {
|
|
1855
|
-
return;
|
|
1856
|
-
});
|
|
1857
|
-
continue;
|
|
1858
|
-
}
|
|
1859
|
-
const fromName = { backend, targetId: nameId, label: "", pid: 0, createdAt: 0 };
|
|
1860
|
-
let rec;
|
|
1861
|
-
try {
|
|
1862
|
-
rec = JSON.parse(await readFile2(join2(leaseDir(), name), "utf8"));
|
|
1863
|
-
} catch (err) {
|
|
1864
|
-
const code = err.code;
|
|
1865
|
-
if (code === "ENOENT")
|
|
1866
|
-
continue;
|
|
1867
|
-
out.push({ ...fromName, unreadable: code ?? "unparseable" });
|
|
1868
|
-
continue;
|
|
1869
|
-
}
|
|
1870
|
-
if (typeof rec?.targetId !== "string" || typeof rec?.label !== "string" || typeof rec?.createdAt !== "number") {
|
|
1871
|
-
out.push({ ...fromName, unreadable: "unparseable" });
|
|
1872
|
-
continue;
|
|
1873
|
-
}
|
|
1874
|
-
out.push({ backend: rec.backend, targetId: rec.targetId, label: rec.label, pid: rec.pid, createdAt: rec.createdAt });
|
|
1875
|
-
}
|
|
1876
|
-
return out;
|
|
1877
|
-
}
|
|
1878
|
-
async function originIndex(backend, liveIds) {
|
|
1879
|
-
const rows = await listOrigins({ liveIds, liveBackend: backend }).catch(() => []);
|
|
1880
|
-
const index = new Map;
|
|
1881
|
-
for (const row of rows) {
|
|
1882
|
-
if (row.backend !== backend)
|
|
1883
|
-
continue;
|
|
1884
|
-
index.set(row.targetId, row);
|
|
1885
|
-
}
|
|
1886
|
-
return index;
|
|
1887
|
-
}
|
|
1888
|
-
async function newTrackedPage(driver, backend, opts = {}) {
|
|
1889
|
-
const page = await driver.newPage(opts.url);
|
|
1890
|
-
await recordOrigin(backend, page.id, { label: opts.label });
|
|
1891
|
-
return page;
|
|
1892
|
-
}
|
|
1893
|
-
|
|
1894
2084
|
// src/reap.ts
|
|
1895
2085
|
function staleAgentTabs(input) {
|
|
1896
2086
|
const live = new Set(input.livePageIds);
|
|
@@ -1991,6 +2181,11 @@ async function pickPage(driver, pages, selector) {
|
|
|
1991
2181
|
throw new SharedToolError(`no page title containing '${needle}'`);
|
|
1992
2182
|
return p;
|
|
1993
2183
|
}
|
|
2184
|
+
if (selector.startsWith("label:"))
|
|
2185
|
+
return pickByLabel(driver, pages, selector.slice(6));
|
|
2186
|
+
if (isWorkerSelector(selector)) {
|
|
2187
|
+
throw new SharedToolError(backendOf(driver) === "firefox" ? WORKER_SELECTOR_UNSUPPORTED_MESSAGE : WORKER_SELECTOR_PAGE_ONLY_MESSAGE);
|
|
2188
|
+
}
|
|
1994
2189
|
const exactPage = pages.find((x) => x.id === selector);
|
|
1995
2190
|
if (exactPage)
|
|
1996
2191
|
return exactPage;
|
|
@@ -2000,6 +2195,18 @@ async function pickPage(driver, pages, selector) {
|
|
|
2000
2195
|
return exact;
|
|
2001
2196
|
throw new SharedToolError(`no target with id '${selector}'`);
|
|
2002
2197
|
}
|
|
2198
|
+
async function pickByLabel(driver, pages, label) {
|
|
2199
|
+
const { matchIds, knownLabels } = await resolveLiveLabel(backendOf(driver), label, pages.map((x) => x.id));
|
|
2200
|
+
if (matchIds.length > 1) {
|
|
2201
|
+
throw new SharedToolError(`label 'label:${label}' matches more than one live target: ${matchIds.join(", ")}. Resolve by target id instead.`);
|
|
2202
|
+
}
|
|
2203
|
+
if (matchIds.length === 1) {
|
|
2204
|
+
const p = pages.find((x) => x.id === matchIds[0]);
|
|
2205
|
+
if (p)
|
|
2206
|
+
return p;
|
|
2207
|
+
}
|
|
2208
|
+
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)`);
|
|
2209
|
+
}
|
|
2003
2210
|
function locatorOf(args) {
|
|
2004
2211
|
const hasUid = args.uid !== undefined && args.uid !== null && args.uid !== "";
|
|
2005
2212
|
const hasSelector = typeof args.selector === "string" && args.selector.length > 0;
|
|
@@ -2141,11 +2348,29 @@ async function writeJsonSink(savePath, value) {
|
|
|
2141
2348
|
function sinkRequested(savePath) {
|
|
2142
2349
|
return savePath !== undefined && savePath !== "";
|
|
2143
2350
|
}
|
|
2351
|
+
var EXPRESSION_ALIAS_KEYS = ["function", "code", "js", "script", "fn", "body"];
|
|
2352
|
+
async function resolveWorkerSelectorFor(driver, selector, wake) {
|
|
2353
|
+
if (!driver.capabilities.has("worker.targets") || typeof driver.resolveWorkerTarget !== "function") {
|
|
2354
|
+
throw new SharedToolError(`evaluateScript: ${WORKER_SELECTOR_UNSUPPORTED_MESSAGE}`);
|
|
2355
|
+
}
|
|
2356
|
+
return driver.resolveWorkerTarget(selector, { wake });
|
|
2357
|
+
}
|
|
2144
2358
|
async function evaluateScript(driver, args) {
|
|
2145
2359
|
if (typeof args.expression !== "string" || args.expression.length === 0) {
|
|
2360
|
+
const wrongKey = EXPRESSION_ALIAS_KEYS.find((k) => args[k] !== undefined);
|
|
2361
|
+
if (wrongKey !== undefined) {
|
|
2362
|
+
throw new SharedToolError(`evaluateScript: you passed '${wrongKey}'; the key is 'expression' — function literals go in 'expression', invoked with 'args'.`);
|
|
2363
|
+
}
|
|
2146
2364
|
throw new SharedToolError("evaluateScript: 'expression' must be a non-empty string");
|
|
2147
2365
|
}
|
|
2148
|
-
|
|
2366
|
+
if (args.wake !== undefined && typeof args.wake !== "boolean") {
|
|
2367
|
+
throw new SharedToolError("evaluateScript: 'wake' must be a boolean");
|
|
2368
|
+
}
|
|
2369
|
+
if (args.wake !== undefined && !isWorkerSelector(args.target)) {
|
|
2370
|
+
throw new SharedToolError("evaluateScript: 'wake' only applies to a target of the form 'worker:<substring>'; a page target is never asleep.");
|
|
2371
|
+
}
|
|
2372
|
+
const target = isWorkerSelector(args.target) ? (await resolveWorkerSelectorFor(driver, args.target, args.wake ?? true)).id : args.target;
|
|
2373
|
+
return withPage2(driver, target, async (page) => {
|
|
2149
2374
|
const value = await page.evaluate(args.expression, { args: args.args, awaitPromise: args.awaitPromise ?? true });
|
|
2150
2375
|
if (!sinkRequested(args.savePath))
|
|
2151
2376
|
return value;
|
|
@@ -2509,7 +2734,7 @@ async function claimPage(driver, args = {}) {
|
|
|
2509
2734
|
const hasTarget = typeof args.target === "string" && args.target.length > 0;
|
|
2510
2735
|
const hasTargetId = typeof args.targetId === "string" && args.targetId.length > 0;
|
|
2511
2736
|
if (hasTarget && hasTargetId) {
|
|
2512
|
-
throw new LeaseToolError("claim_page takes at most one of 'target' (any selector: active | index:N | url:<substr> | title:<substr> | <targetId>) or 'targetId' (an exact target id, kept for back-compat). Pass 'target' alone to claim an open tab, or neither to open a fresh one.");
|
|
2737
|
+
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.");
|
|
2513
2738
|
}
|
|
2514
2739
|
const backend = backendOf2(driver);
|
|
2515
2740
|
const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
|
|
@@ -2629,6 +2854,31 @@ function captureFile(targetId, captureId) {
|
|
|
2629
2854
|
function delay2(ms) {
|
|
2630
2855
|
return new Promise((r) => setTimeout(r, ms));
|
|
2631
2856
|
}
|
|
2857
|
+
async function resolveRecorderTarget(target, opts) {
|
|
2858
|
+
if (!isWorkerSelector(target))
|
|
2859
|
+
return resolveTarget(target);
|
|
2860
|
+
if (opts.capture)
|
|
2861
|
+
return resolveWorkerSelector(target, { wake: opts.wake ?? true });
|
|
2862
|
+
const needle = workerNeedle(target);
|
|
2863
|
+
if (needle === "")
|
|
2864
|
+
throw new CdpError(WORKER_EMPTY_NEEDLE_MESSAGE);
|
|
2865
|
+
const { matches, liveWorkers } = resolveWorkerTargets(await listTargets(), needle);
|
|
2866
|
+
if (matches.length > 1)
|
|
2867
|
+
throw new CdpError(workerAmbiguityMessage(needle, matches));
|
|
2868
|
+
if (matches.length === 1)
|
|
2869
|
+
return matches[0];
|
|
2870
|
+
throw new CdpError(workerBufferReadMissMessage(needle, liveWorkers));
|
|
2871
|
+
}
|
|
2872
|
+
function assertWakeApplies(target, wake, capture) {
|
|
2873
|
+
if (wake === undefined)
|
|
2874
|
+
return;
|
|
2875
|
+
if (typeof wake !== "boolean")
|
|
2876
|
+
throw new CdpError("'wake' must be a boolean");
|
|
2877
|
+
if (!isWorkerSelector(target))
|
|
2878
|
+
throw new CdpError(workerWakeMisuseMessage("not-a-worker-selector"));
|
|
2879
|
+
if (!capture)
|
|
2880
|
+
throw new CdpError(workerWakeMisuseMessage("read-only-call"));
|
|
2881
|
+
}
|
|
2632
2882
|
var NETWORK_EVENTS = [
|
|
2633
2883
|
"Network.requestWillBeSent",
|
|
2634
2884
|
"Network.responseReceived",
|
|
@@ -2694,7 +2944,8 @@ async function startRecorder(target, opts = {}) {
|
|
|
2694
2944
|
};
|
|
2695
2945
|
}
|
|
2696
2946
|
async function captureWindow(target, durationMs, opts = {}) {
|
|
2697
|
-
const resolved = await
|
|
2947
|
+
const resolved = await resolveRecorderTarget(target, { capture: true, wake: opts.wake });
|
|
2948
|
+
const isWorker = isWorkerTargetType(resolved.type);
|
|
2698
2949
|
const captureId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2699
2950
|
const file = captureFile(resolved.id, captureId);
|
|
2700
2951
|
const rec = await startRecorder(resolved.id, {
|
|
@@ -2704,13 +2955,16 @@ async function captureWindow(target, durationMs, opts = {}) {
|
|
|
2704
2955
|
file,
|
|
2705
2956
|
timeoutMs: opts.timeoutMs
|
|
2706
2957
|
});
|
|
2707
|
-
|
|
2708
|
-
|
|
2958
|
+
if (!isWorker) {
|
|
2959
|
+
await rec.conn.send("Page.enable");
|
|
2960
|
+
await rec.conn.send("Page.reload", { ignoreCache: false });
|
|
2961
|
+
}
|
|
2709
2962
|
await delay2(durationMs);
|
|
2710
2963
|
return {
|
|
2711
2964
|
file: rec.file,
|
|
2712
2965
|
conn: rec.conn,
|
|
2713
2966
|
resolved: { id: rec.target.id, url: rec.target.url, title: rec.target.title },
|
|
2967
|
+
mode: isWorker ? "listen" : "reload",
|
|
2714
2968
|
droppedWrites: rec.droppedWrites,
|
|
2715
2969
|
flush: () => rec.flush(),
|
|
2716
2970
|
async stop() {
|
|
@@ -2810,8 +3064,9 @@ async function readConsoleEntries(file) {
|
|
|
2810
3064
|
return out;
|
|
2811
3065
|
}
|
|
2812
3066
|
async function listConsoleMessages(args = {}) {
|
|
3067
|
+
assertWakeApplies(args.target, args.wake, args.reload === true);
|
|
2813
3068
|
if (args.reload) {
|
|
2814
|
-
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS);
|
|
3069
|
+
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS, { wake: args.wake });
|
|
2815
3070
|
await cap.stop();
|
|
2816
3071
|
const messages2 = await readConsoleEntries(cap.file);
|
|
2817
3072
|
return {
|
|
@@ -2821,7 +3076,7 @@ async function listConsoleMessages(args = {}) {
|
|
|
2821
3076
|
droppedWrites: cap.droppedWrites()
|
|
2822
3077
|
};
|
|
2823
3078
|
}
|
|
2824
|
-
const target = await
|
|
3079
|
+
const target = await resolveRecorderTarget(args.target, { capture: false });
|
|
2825
3080
|
const messages = await readConsoleEntries(recFile(target.id));
|
|
2826
3081
|
return {
|
|
2827
3082
|
target: { id: target.id, url: target.url, title: target.title },
|
|
@@ -2830,7 +3085,7 @@ async function listConsoleMessages(args = {}) {
|
|
|
2830
3085
|
};
|
|
2831
3086
|
}
|
|
2832
3087
|
async function getConsoleMessage(args = {}) {
|
|
2833
|
-
const target = await
|
|
3088
|
+
const target = await resolveRecorderTarget(args.target, { capture: false });
|
|
2834
3089
|
const messages = await readConsoleEntries(recFile(target.id));
|
|
2835
3090
|
const index = args.index ?? 0;
|
|
2836
3091
|
const entry = messages[index];
|
|
@@ -2928,14 +3183,15 @@ async function listNetworkRequests(args = {}) {
|
|
|
2928
3183
|
let file;
|
|
2929
3184
|
let resolved;
|
|
2930
3185
|
let droppedWrites;
|
|
3186
|
+
assertWakeApplies(args.target, args.wake, args.reload === true);
|
|
2931
3187
|
if (args.reload) {
|
|
2932
|
-
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS2);
|
|
3188
|
+
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS2, { wake: args.wake });
|
|
2933
3189
|
await cap.stop();
|
|
2934
3190
|
file = cap.file;
|
|
2935
3191
|
resolved = cap.resolved;
|
|
2936
3192
|
droppedWrites = cap.droppedWrites();
|
|
2937
3193
|
} else {
|
|
2938
|
-
const target = await
|
|
3194
|
+
const target = await resolveRecorderTarget(args.target, { capture: false });
|
|
2939
3195
|
file = recFile(target.id);
|
|
2940
3196
|
resolved = { id: target.id, url: target.url, title: target.title };
|
|
2941
3197
|
}
|
|
@@ -2956,8 +3212,9 @@ async function getNetworkRequest(args = {}) {
|
|
|
2956
3212
|
return requests.find((r) => r.url.includes(args.url));
|
|
2957
3213
|
};
|
|
2958
3214
|
const describeSelector = () => args.requestId ? `requestId ${args.requestId}` : `url ~ '${args.url}'`;
|
|
3215
|
+
assertWakeApplies(args.target, args.wake, Boolean(args.includeBody && args.url));
|
|
2959
3216
|
if (args.includeBody && args.url) {
|
|
2960
|
-
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS2);
|
|
3217
|
+
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS2, { wake: args.wake });
|
|
2961
3218
|
try {
|
|
2962
3219
|
await cap.flush();
|
|
2963
3220
|
const match2 = (await readNetworkRequests(cap.file)).find((r) => r.url.includes(args.url));
|
|
@@ -2974,7 +3231,7 @@ async function getNetworkRequest(args = {}) {
|
|
|
2974
3231
|
await cap.stop();
|
|
2975
3232
|
}
|
|
2976
3233
|
}
|
|
2977
|
-
const target = await
|
|
3234
|
+
const target = await resolveRecorderTarget(args.target, { capture: false });
|
|
2978
3235
|
const match = pick(await readNetworkRequests(recFile(target.id)));
|
|
2979
3236
|
if (!match) {
|
|
2980
3237
|
throw new CdpError(`no network request matched ${describeSelector()}; run list_network_requests with reload:true first` + (args.includeBody ? `, then fetch the body with the url selector (reload re-mints requestIds)` : ``));
|