cdp-toolkit 2.6.0 → 3.0.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 +44 -0
- package/CONTRACT.md +1 -1
- package/README.md +81 -46
- package/dist/bidi/mux-daemon.js +47 -0
- package/dist/cli.js +78 -5
- package/dist/index.js +73 -4
- package/dist/mcp.js +236 -50
- package/package.json +5 -3
- package/src/bidi/driver.ts +8 -0
- package/src/cdp/driver.ts +69 -4
- package/src/client.ts +22 -0
- package/src/driver.ts +2 -1
- package/src/frames.ts +169 -0
- package/src/manifest.ts +1 -1
- package/src/mcp.ts +177 -55
- package/src/shared-tools.ts +13 -0
- package/src/toolDocs.ts +3 -3
- package/src/toolGroups.ts +41 -10
- package/src/types.ts +13 -0
- package/src/version.ts +1 -1
package/dist/mcp.js
CHANGED
|
@@ -473,6 +473,37 @@ var init_workers = __esm(() => {
|
|
|
473
473
|
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(", ")}.`;
|
|
474
474
|
});
|
|
475
475
|
|
|
476
|
+
// src/frames.ts
|
|
477
|
+
function isFrameSelector(selector) {
|
|
478
|
+
return typeof selector === "string" && selector.startsWith(FRAME_SELECTOR_PREFIX);
|
|
479
|
+
}
|
|
480
|
+
function frameNeedle(selector) {
|
|
481
|
+
return selector.slice(FRAME_SELECTOR_PREFIX.length);
|
|
482
|
+
}
|
|
483
|
+
function isFrameTargetType(type) {
|
|
484
|
+
return type === FRAME_TARGET_TYPE;
|
|
485
|
+
}
|
|
486
|
+
function resolveFrameTargets(targets, needle) {
|
|
487
|
+
const liveFrames = targets.filter((t) => isFrameTargetType(t.type));
|
|
488
|
+
return {
|
|
489
|
+
matches: needle === "" ? [] : liveFrames.filter((t) => t.url.includes(needle)),
|
|
490
|
+
liveFrames
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
function frameAmbiguityMessage(needle, matches) {
|
|
494
|
+
return `'frame:${needle}' matches more than one out-of-process iframe: ` + matches.map((m) => `${m.url} (${m.id})`).join(", ") + ". Narrow the substring, or pass the iframe's target id directly.";
|
|
495
|
+
}
|
|
496
|
+
function frameMissMessage(needle, liveFrames) {
|
|
497
|
+
const live = liveFrames.length ? ` (out-of-process iframes: ${liveFrames.map((f) => f.url).join(", ")})` : " (no out-of-process iframes are live)";
|
|
498
|
+
return `no out-of-process iframe whose url contains '${needle}'${live}. ` + "Only a CROSS-ORIGIN iframe gets its own process and therefore its own target; a same-origin iframe " + "runs inside its parent page and is NOT a target at all, so no substring can ever select it — reach a " + "same-origin iframe through its parent page instead (evaluate_script on the page, then the iframe " + "element's contentDocument). An out-of-process iframe whose document navigated away is likewise gone " + "from the listing: re-list with list_pages{all:true} and pass the new target id.";
|
|
499
|
+
}
|
|
500
|
+
var FRAME_SELECTOR_PREFIX = "frame:", FRAME_PAGE_ONLY_TOOLS, FRAME_TARGET_TYPE = "iframe", FRAME_EMPTY_NEEDLE_MESSAGE = "'frame:' needs a substring of the iframe's url, e.g. frame:ads.example.com or frame:player.html", FRAME_SELECTOR_UNSUPPORTED_MESSAGE, FRAME_SELECTOR_PAGE_ONLY_MESSAGE;
|
|
501
|
+
var init_frames = __esm(() => {
|
|
502
|
+
FRAME_PAGE_ONLY_TOOLS = ["close_page", "select_page", "release_page", "claim_page"];
|
|
503
|
+
FRAME_SELECTOR_UNSUPPORTED_MESSAGE = "target 'frame:<substring>' selects an out-of-process iframe, which this backend cannot address " + "(WebDriver BiDi has no iframe target — an iframe is a realm inside its tab's browsing context, not a " + "selectable target; Capability 'frame.targets' is Chrome-only). Use --browser chrome to act on a cross-origin iframe.";
|
|
504
|
+
FRAME_SELECTOR_PAGE_ONLY_MESSAGE = "target 'frame:<substring>' selects an out-of-process iframe, and this tool is page-only " + "(closing, selecting, releasing or claiming an iframe is meaningless; these act on the embedder tab). " + `The page-only tools are ${FRAME_PAGE_ONLY_TOOLS.join(", ")}; every other tool that takes a 'target' ` + "accepts a frame: selector.";
|
|
505
|
+
});
|
|
506
|
+
|
|
476
507
|
// src/client.ts
|
|
477
508
|
class CdpConnection {
|
|
478
509
|
wsUrl;
|
|
@@ -671,6 +702,17 @@ async function pickTarget(targets, selector) {
|
|
|
671
702
|
return matches[0];
|
|
672
703
|
throw new CdpError(workerMissMessage(needle, { wakeAttempted: false, liveWorkers }));
|
|
673
704
|
}
|
|
705
|
+
if (isFrameSelector(selector)) {
|
|
706
|
+
const needle = frameNeedle(selector);
|
|
707
|
+
if (needle === "")
|
|
708
|
+
throw new CdpError(FRAME_EMPTY_NEEDLE_MESSAGE);
|
|
709
|
+
const { matches, liveFrames } = resolveFrameTargets(targets, needle);
|
|
710
|
+
if (matches.length > 1)
|
|
711
|
+
throw new CdpError(frameAmbiguityMessage(needle, matches));
|
|
712
|
+
if (matches.length === 1)
|
|
713
|
+
return matches[0];
|
|
714
|
+
throw new CdpError(frameMissMessage(needle, liveFrames));
|
|
715
|
+
}
|
|
674
716
|
const byId = targets.find((t) => t.id === selector);
|
|
675
717
|
if (!byId)
|
|
676
718
|
throw new CdpError(`no target with id '${selector}'`);
|
|
@@ -697,6 +739,7 @@ var init_client = __esm(() => {
|
|
|
697
739
|
init_leases();
|
|
698
740
|
init_origins();
|
|
699
741
|
init_workers();
|
|
742
|
+
init_frames();
|
|
700
743
|
BASE = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
|
|
701
744
|
DEFAULT_TIMEOUT_MS = Number(process.env.CDP_TIMEOUT_MS ?? 15000);
|
|
702
745
|
CdpError = class CdpError extends Error {
|
|
@@ -2844,6 +2887,9 @@ async function pickContext(conn, contexts, selector) {
|
|
|
2844
2887
|
if (isWorkerSelector(selector)) {
|
|
2845
2888
|
throw driverError2("unsupported", WORKER_SELECTOR_UNSUPPORTED_MESSAGE);
|
|
2846
2889
|
}
|
|
2890
|
+
if (isFrameSelector(selector)) {
|
|
2891
|
+
throw driverError2("unsupported", FRAME_SELECTOR_UNSUPPORTED_MESSAGE);
|
|
2892
|
+
}
|
|
2847
2893
|
const exact = contexts.find((c) => c.context === selector);
|
|
2848
2894
|
if (exact)
|
|
2849
2895
|
return exact;
|
|
@@ -3055,6 +3101,7 @@ var init_driver2 = __esm(() => {
|
|
|
3055
3101
|
init_leases();
|
|
3056
3102
|
init_origins();
|
|
3057
3103
|
init_workers();
|
|
3104
|
+
init_frames();
|
|
3058
3105
|
init_activity();
|
|
3059
3106
|
init_client2();
|
|
3060
3107
|
init_session_lease();
|
|
@@ -4175,7 +4222,8 @@ var CDP_CAPABILITIES = new Set([
|
|
|
4175
4222
|
"emulate.focus",
|
|
4176
4223
|
"browser.downloads",
|
|
4177
4224
|
"browser.permissions",
|
|
4178
|
-
"worker.targets"
|
|
4225
|
+
"worker.targets",
|
|
4226
|
+
"frame.targets"
|
|
4179
4227
|
]);
|
|
4180
4228
|
|
|
4181
4229
|
class CdpPageDriver {
|
|
@@ -4308,6 +4356,7 @@ class CdpPageDriver {
|
|
|
4308
4356
|
const interactiveOnly = opts?.interactiveOnly ?? false;
|
|
4309
4357
|
await this.ensureDomain("Accessibility");
|
|
4310
4358
|
const { nodes } = await this.conn.send("Accessibility.getFullAXTree");
|
|
4359
|
+
const oopifFrames = await this.detectOopifFrames();
|
|
4311
4360
|
const byId = new Map;
|
|
4312
4361
|
for (const n of nodes)
|
|
4313
4362
|
byId.set(n.nodeId, n);
|
|
@@ -4316,11 +4365,17 @@ class CdpPageDriver {
|
|
|
4316
4365
|
const walk = (node, depth) => {
|
|
4317
4366
|
const role = axString(node.role);
|
|
4318
4367
|
const name = axString(node.name);
|
|
4319
|
-
const
|
|
4320
|
-
const
|
|
4368
|
+
const backendDOMNodeId = node.backendDOMNodeId;
|
|
4369
|
+
const marker = backendDOMNodeId !== undefined ? oopifFrames.get(backendDOMNodeId) : undefined;
|
|
4370
|
+
const emitted = !node.ignored && backendDOMNodeId !== undefined && (interactiveOnly ? isInteractive(role) || marker !== undefined : !!(role || name));
|
|
4321
4371
|
if (emitted) {
|
|
4322
4372
|
const extras = axExtras(node);
|
|
4323
|
-
|
|
4373
|
+
if (marker) {
|
|
4374
|
+
extras.frame = marker.frameId;
|
|
4375
|
+
if (marker.frameUrl)
|
|
4376
|
+
extras.frameUrl = marker.frameUrl;
|
|
4377
|
+
}
|
|
4378
|
+
out.push({ uid: encodeUid(backendDOMNodeId), role: role ?? "generic", depth, ...name ? { name } : {}, ...Object.keys(extras).length ? { extras } : {} });
|
|
4324
4379
|
}
|
|
4325
4380
|
const childDepth = emitted && !interactiveOnly ? depth + 1 : depth;
|
|
4326
4381
|
for (const childId of node.childIds ?? []) {
|
|
@@ -4333,6 +4388,20 @@ class CdpPageDriver {
|
|
|
4333
4388
|
walk(root, 0);
|
|
4334
4389
|
return out;
|
|
4335
4390
|
}
|
|
4391
|
+
async detectOopifFrames() {
|
|
4392
|
+
const markers = new Map;
|
|
4393
|
+
try {
|
|
4394
|
+
await this.ensureDomainSoft("DOM");
|
|
4395
|
+
const iframeTargets = (await listTargets()).filter((t) => t.type === "iframe");
|
|
4396
|
+
for (const target of iframeTargets) {
|
|
4397
|
+
const owner = await this.conn.send("DOM.getFrameOwner", { frameId: target.id }).catch(() => ({ backendNodeId: undefined }));
|
|
4398
|
+
if (typeof owner.backendNodeId === "number") {
|
|
4399
|
+
markers.set(owner.backendNodeId, { frameId: target.id, ...target.url ? { frameUrl: target.url } : {} });
|
|
4400
|
+
}
|
|
4401
|
+
}
|
|
4402
|
+
} catch {}
|
|
4403
|
+
return markers;
|
|
4404
|
+
}
|
|
4336
4405
|
async locate(loc) {
|
|
4337
4406
|
return encodeUid(await backendNodeIdOf(this.conn, loc));
|
|
4338
4407
|
}
|
|
@@ -5510,6 +5579,7 @@ init_leases();
|
|
|
5510
5579
|
init_activity();
|
|
5511
5580
|
init_origins();
|
|
5512
5581
|
init_workers();
|
|
5582
|
+
init_frames();
|
|
5513
5583
|
var ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
5514
5584
|
var STATE_DIR = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
|
|
5515
5585
|
|
|
@@ -5566,6 +5636,9 @@ async function pickPage(driver, pages, selector) {
|
|
|
5566
5636
|
if (isWorkerSelector(selector)) {
|
|
5567
5637
|
throw new SharedToolError(backendOf(driver) === "firefox" ? WORKER_SELECTOR_UNSUPPORTED_MESSAGE : WORKER_SELECTOR_PAGE_ONLY_MESSAGE);
|
|
5568
5638
|
}
|
|
5639
|
+
if (isFrameSelector(selector)) {
|
|
5640
|
+
throw new SharedToolError(backendOf(driver) === "firefox" ? FRAME_SELECTOR_UNSUPPORTED_MESSAGE : FRAME_SELECTOR_PAGE_ONLY_MESSAGE);
|
|
5641
|
+
}
|
|
5569
5642
|
const exactPage = pages.find((x) => x.id === selector);
|
|
5570
5643
|
if (exactPage)
|
|
5571
5644
|
return exactPage;
|
|
@@ -8405,7 +8478,7 @@ var MANIFEST = [
|
|
|
8405
8478
|
},
|
|
8406
8479
|
all: {
|
|
8407
8480
|
type: "boolean",
|
|
8408
|
-
description: "Include non-page targets."
|
|
8481
|
+
description: "Include non-page targets (workers, iframes)."
|
|
8409
8482
|
},
|
|
8410
8483
|
probe: {
|
|
8411
8484
|
type: "boolean",
|
|
@@ -10750,38 +10823,54 @@ for (const group of TOOL_GROUPS) {
|
|
|
10750
10823
|
TOOL_GROUP[tool] = group;
|
|
10751
10824
|
}
|
|
10752
10825
|
}
|
|
10826
|
+
var GATEWAY_TOOLS = ["navigate_page", "take_snapshot", "click", "fill", "evaluate_script"];
|
|
10753
10827
|
var PROFILES = {
|
|
10828
|
+
gateway: [],
|
|
10754
10829
|
core: ["core"],
|
|
10755
10830
|
full: [...TOOL_GROUPS]
|
|
10756
10831
|
};
|
|
10832
|
+
function isListed(profile, name) {
|
|
10833
|
+
const group = TOOL_GROUP[name];
|
|
10834
|
+
return profile.tools.has(name) || group !== undefined && profile.groups.has(group);
|
|
10835
|
+
}
|
|
10757
10836
|
function resolveProfile(spec) {
|
|
10758
|
-
const all = () => ({ groups: new Set(TOOL_GROUPS), label: "full" });
|
|
10837
|
+
const all = () => ({ groups: new Set(TOOL_GROUPS), tools: new Set, label: "full" });
|
|
10759
10838
|
const raw = (spec ?? "").trim();
|
|
10760
|
-
if (raw === ""
|
|
10839
|
+
if (raw === "")
|
|
10840
|
+
return { groups: new Set, tools: new Set(GATEWAY_TOOLS), label: "gateway" };
|
|
10841
|
+
if (raw.toLowerCase() === "full")
|
|
10761
10842
|
return all();
|
|
10762
10843
|
const tokens = raw.split(",").map((t) => t.trim().toLowerCase()).filter((t) => t !== "");
|
|
10763
10844
|
if (tokens.includes("full"))
|
|
10764
10845
|
return all();
|
|
10846
|
+
const gateway = tokens.includes("gateway");
|
|
10765
10847
|
const known = new Set(TOOL_GROUPS);
|
|
10766
|
-
const picked = new Set(["core"]);
|
|
10848
|
+
const picked = new Set(gateway ? [] : ["core"]);
|
|
10767
10849
|
for (const token of tokens) {
|
|
10850
|
+
if (token === "gateway")
|
|
10851
|
+
continue;
|
|
10768
10852
|
if (!known.has(token)) {
|
|
10769
|
-
throw new Error(`CDP_TOOL_PROFILE: unknown tool group '${token}'. Known: full, core, ${TOOL_GROUPS.filter((g) => g !== "core").join(", ")}`);
|
|
10853
|
+
throw new Error(`CDP_TOOL_PROFILE: unknown tool group '${token}'. Known: gateway, full, core, ${TOOL_GROUPS.filter((g) => g !== "core").join(", ")}`);
|
|
10770
10854
|
}
|
|
10771
10855
|
picked.add(token);
|
|
10772
10856
|
}
|
|
10773
10857
|
if (picked.size === TOOL_GROUPS.length)
|
|
10774
10858
|
return all();
|
|
10775
|
-
|
|
10859
|
+
const groupLabel = TOOL_GROUPS.filter((g) => picked.has(g));
|
|
10860
|
+
return {
|
|
10861
|
+
groups: picked,
|
|
10862
|
+
tools: new Set(gateway ? GATEWAY_TOOLS : []),
|
|
10863
|
+
label: (gateway ? ["gateway", ...groupLabel] : groupLabel).join(",")
|
|
10864
|
+
};
|
|
10776
10865
|
}
|
|
10777
10866
|
|
|
10778
10867
|
// src/toolDocs.ts
|
|
10779
10868
|
var TOOL_DOCS = {
|
|
10780
10869
|
list_pages: {
|
|
10781
|
-
description: "Enumerate browser page targets (GET /json/list); page-type tabs only unless all=true. Each entry carries its targetId plus an origin field: 'agent' means this toolkit created it (also carries the creating label/createdAt, and stays 'agent' after the lease is released or the creator dies, making stray agent tabs findable), 'unknown' means no creation record - it never says 'human', the honest word for any tab it did not create; a record that exists but won't read adds originUnreadable. An entry under an active, readable lease also carries a 'lease' object {label,pid,idleMs,expiresAt,stale}, with idleMs and expiresAt computed fresh every call (never off disk), independent of probe. Under CDP_REQUIRE_LEASE this call also reaps abandoned agent tabs into an additive reaped array - but a lease reads reclaimable the moment its TTL elapses, while the tab is CLOSED only after a further grace (CDP_REAP_GRACE_MS, default 2700000ms/45min, so 60min total after last use); a dead-pid lease is reaped at once, a tab with no lease never. With probe, each page-type entry also gets 'responsive' (and humanActiveMs where human input is seen); both are absent, never false/null, without probe.",
|
|
10870
|
+
description: "Enumerate browser page targets (GET /json/list); page-type tabs only unless all=true, which also lists workers and out-of-process (OOPIF) iframes. Each entry carries its targetId plus an origin field: 'agent' means this toolkit created it (also carries the creating label/createdAt, and stays 'agent' after the lease is released or the creator dies, making stray agent tabs findable), 'unknown' means no creation record - it never says 'human', the honest word for any tab it did not create; a record that exists but won't read adds originUnreadable. An entry under an active, readable lease also carries a 'lease' object {label,pid,idleMs,expiresAt,stale}, with idleMs and expiresAt computed fresh every call (never off disk), independent of probe. Under CDP_REQUIRE_LEASE this call also reaps abandoned agent tabs into an additive reaped array - but a lease reads reclaimable the moment its TTL elapses, while the tab is CLOSED only after a further grace (CDP_REAP_GRACE_MS, default 2700000ms/45min, so 60min total after last use); a dead-pid lease is reaped at once, a tab with no lease never. With probe, each page-type entry also gets 'responsive' (and humanActiveMs where human input is seen); both are absent, never false/null, without probe.",
|
|
10782
10871
|
params: {
|
|
10783
10872
|
lease: "Lease token from claim_page. Omit for a tab this process holds; required for a tab held by another process or one claimed explicitly. Auto-acquired under CDP_REQUIRE_LEASE (then pass target, not lease). See server instructions.",
|
|
10784
|
-
all: "Include non-page targets (service/shared workers, background pages); default false lists page-type tabs only.",
|
|
10873
|
+
all: "Include non-page targets (service/shared workers, background pages, out-of-process (OOPIF) iframes); default false lists page-type tabs only. A returned non-page targetId is accepted as a bare id by any tool's `target` param.",
|
|
10785
10874
|
probe: "Ping each page-type renderer before returning, one bounded 500ms check per target and never more, so a wedged tab can't stall the listing. Adds 'responsive' (false on timeout/exception/unreachable, never an error for the whole call) and, where the same round trip finds human input, humanActiveMs. Default false. On a backend that cannot answer the ping, responsive is absent too (not false)."
|
|
10786
10875
|
}
|
|
10787
10876
|
},
|
|
@@ -10882,7 +10971,7 @@ var TOOL_DOCS = {
|
|
|
10882
10971
|
}
|
|
10883
10972
|
},
|
|
10884
10973
|
take_snapshot: {
|
|
10885
|
-
description:
|
|
10974
|
+
description: `Captures the page's accessibility tree as an indented text tree, each line prefixed [uid] (a CDP backendDOMNodeId). Every interaction tool (click/hover/fill/etc.) resolves that uid via DOM.resolveNode — run this first to discover uids to act on. Cross-origin iframe subtrees are not walked (they live in a separate renderer process); the iframe node itself is emitted with a frame="<targetId>" marker (also in interactiveOnly mode), so the frame is discoverable and driveable via that id or frame:<url-substring>.`,
|
|
10886
10975
|
params: {
|
|
10887
10976
|
lease: "Lease token from claim_page. Omit for a tab this process holds; required for a tab held by another process or one claimed explicitly. Auto-acquired under CDP_REQUIRE_LEASE (then pass target, not lease). See server instructions.",
|
|
10888
10977
|
target: "Page selector: 'active' (default), 'index:N', 'url:<substr>', 'title:<substr>', 'label:<name>', or a 32-hex '<targetId>'. Grammar: see server instructions.",
|
|
@@ -11280,7 +11369,7 @@ var TOOL_DOCS = {
|
|
|
11280
11369
|
};
|
|
11281
11370
|
|
|
11282
11371
|
// src/version.ts
|
|
11283
|
-
var VERSION = "
|
|
11372
|
+
var VERSION = "3.0.0";
|
|
11284
11373
|
|
|
11285
11374
|
// src/mcp.ts
|
|
11286
11375
|
function isDirectRun() {
|
|
@@ -11338,15 +11427,18 @@ function auditCoverage() {
|
|
|
11338
11427
|
console.error(`[cdp-toolkit] WARN: manifest schemas with no registered tool: ${orphanSchema.join(", ")}`);
|
|
11339
11428
|
}
|
|
11340
11429
|
var INSTRUCTIONS = [
|
|
11341
|
-
"cdp-toolkit drives a real browser over
|
|
11342
|
-
"
|
|
11343
|
-
"TARGET SELECTOR (the `target` param, unless a tool says otherwise): 'active' (default = first page) | 'index:N' (0-based) | 'url:<substr>' | 'title:<substr>' | 'label:<name>' (exact,
|
|
11344
|
-
"LEASES (the `lease` param): claim_page, and new_page{claim:true}, mint an opaque token. Omit it for a tab THIS process already holds. It is required for a tab held by ANOTHER process, or one claimed explicitly. Under CDP_REQUIRE_LEASE the gate auto-acquires a lease for any tab this process drives — no token is surfaced, so pass `target`, not `lease` — while an explicit claim:true still demands its token on every later call.
|
|
11430
|
+
"cdp-toolkit drives a real browser over CDP (or Firefox WebDriver-BiDi): pages, input, screenshots, snapshots, console, network, cookies, emulation, Lighthouse, traces, screencasts.",
|
|
11431
|
+
"TOOLS: by default only 5 are listed (navigate_page, take_snapshot, click, fill, evaluate_script). ~45 more exist: find one with search_tools {query} (returns its schema; no query = catalog), run it with call_tool {name, arguments}. No tab yet? call_tool {name:'new_page'}. The listing is fixed for the process's life; CDP_TOOL_PROFILE=full lists everything.",
|
|
11432
|
+
"TARGET SELECTOR (the `target` param, unless a tool says otherwise): 'active' (default = first page) | 'index:N' (0-based) | 'url:<substr>' | 'title:<substr>' | 'label:<name>' (exact, ledger or live lease) | a 32-hex '<targetId>' of any target type — an iframe id from list_pages{all:true} drives like a tab. Chrome tools also accept 'frame:<substr>' for OOPIF iframe targets. Four Chrome-only tools (evaluate_script, list_network_requests, get_network_request, list_console_messages) also accept 'worker:<substr>' to reach a service/shared worker (e.g. an MV3 background worker); an idle-evicted worker is started first (see `wake`).",
|
|
11433
|
+
"LEASES (the `lease` param): claim_page, and new_page{claim:true}, mint an opaque token. Omit it for a tab THIS process already holds. It is required for a tab held by ANOTHER process, or one claimed explicitly. Under CDP_REQUIRE_LEASE the gate auto-acquires a lease for any tab this process drives — no token is surfaced, so pass `target`, not `lease` — while an explicit claim:true still demands its token on every later call.",
|
|
11345
11434
|
"ORIGIN: list_pages and list_leases tag each tab's `origin` as 'agent' (this toolkit created it) or 'unknown' — never 'human', because the toolkit cannot prove a person opened a tab. An 'agent' tab stays findable after its creator releases the lease or dies."
|
|
11346
11435
|
].join(`
|
|
11347
11436
|
|
|
11348
11437
|
`);
|
|
11349
11438
|
var PROFILE = startupProfile();
|
|
11439
|
+
var META_TOOLS = new Set(["search_tools", "call_tool", "describe_tool"]);
|
|
11440
|
+
var SEARCH_DEFAULT_LIMIT = 8;
|
|
11441
|
+
var SEARCH_MAX_LIMIT = 20;
|
|
11350
11442
|
function startupProfile() {
|
|
11351
11443
|
try {
|
|
11352
11444
|
return resolveProfile(process.env.CDP_TOOL_PROFILE);
|
|
@@ -11357,20 +11449,37 @@ function startupProfile() {
|
|
|
11357
11449
|
}
|
|
11358
11450
|
var LISTING = Object.freeze([
|
|
11359
11451
|
{
|
|
11360
|
-
name: "
|
|
11361
|
-
description: "
|
|
11452
|
+
name: "search_tools",
|
|
11453
|
+
description: "Find a cdp-toolkit tool that is not in your tool list (cookies, network, console, screenshots, tabs, emulation, performance, recording, leases, downloads, …). Returns the best matches with full docs and inputSchema; run one with call_tool. With no query, returns the catalog of every tool by group.",
|
|
11362
11454
|
inputSchema: {
|
|
11363
11455
|
type: "object",
|
|
11364
11456
|
properties: {
|
|
11365
|
-
|
|
11457
|
+
query: {
|
|
11366
11458
|
type: "string",
|
|
11367
|
-
description: "
|
|
11459
|
+
description: "Keywords or an exact tool name, e.g. 'screenshot', 'network requests', 'new_page'. Omit for the catalog."
|
|
11460
|
+
},
|
|
11461
|
+
limit: {
|
|
11462
|
+
type: "integer",
|
|
11463
|
+
description: `Max matches to return (default ${SEARCH_DEFAULT_LIMIT}, max ${SEARCH_MAX_LIMIT}).`
|
|
11368
11464
|
}
|
|
11369
11465
|
},
|
|
11370
11466
|
additionalProperties: false
|
|
11371
11467
|
}
|
|
11372
11468
|
},
|
|
11373
|
-
|
|
11469
|
+
{
|
|
11470
|
+
name: "call_tool",
|
|
11471
|
+
description: "Run any cdp-toolkit tool by name, including the ones not in your tool list. Look its arguments up with search_tools first. Behaves exactly like calling the tool directly (same leases, same errors).",
|
|
11472
|
+
inputSchema: {
|
|
11473
|
+
type: "object",
|
|
11474
|
+
properties: {
|
|
11475
|
+
name: { type: "string", description: "Tool name from search_tools, e.g. new_page." },
|
|
11476
|
+
arguments: { type: "object", description: "That tool's arguments, per its inputSchema.", additionalProperties: true }
|
|
11477
|
+
},
|
|
11478
|
+
required: ["name"],
|
|
11479
|
+
additionalProperties: false
|
|
11480
|
+
}
|
|
11481
|
+
},
|
|
11482
|
+
...MANIFEST.filter((s) => AVAILABLE_NAMES.has(s.name) && isListed(PROFILE, s.name)).map((s) => ({ name: s.name, description: s.description, inputSchema: s.inputSchema }))
|
|
11374
11483
|
]);
|
|
11375
11484
|
function renderCatalog() {
|
|
11376
11485
|
const lines = [
|
|
@@ -11380,12 +11489,65 @@ function renderCatalog() {
|
|
|
11380
11489
|
const shown = GROUP_TOOLS[g].filter((n) => AVAILABLE_NAMES.has(n));
|
|
11381
11490
|
if (!shown.length)
|
|
11382
11491
|
continue;
|
|
11383
|
-
|
|
11492
|
+
const listed = shown.filter((n) => isListed(PROFILE, n));
|
|
11493
|
+
const state = listed.length === shown.length ? "[listed]" : listed.length === 0 ? "[hidden]" : "[partly listed]";
|
|
11494
|
+
lines.push(`${state} ${g} (${shown.length}): ${shown.join(", ")}`);
|
|
11384
11495
|
}
|
|
11385
|
-
lines.push("
|
|
11496
|
+
lines.push("Run any tool with call_tool {name, arguments}; search_tools {query:<name>} returns its docs and inputSchema.");
|
|
11386
11497
|
return lines.join(`
|
|
11387
11498
|
`);
|
|
11388
11499
|
}
|
|
11500
|
+
function tokenize(text) {
|
|
11501
|
+
return text.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 1);
|
|
11502
|
+
}
|
|
11503
|
+
var MANIFEST_BY_NAME = new Map(MANIFEST.map((s) => [s.name, s]));
|
|
11504
|
+
function searchTools(query, rawLimit) {
|
|
11505
|
+
const limit = typeof rawLimit === "number" && Number.isInteger(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, SEARCH_MAX_LIMIT) : SEARCH_DEFAULT_LIMIT;
|
|
11506
|
+
const q = query.trim().toLowerCase();
|
|
11507
|
+
const describe = (name) => {
|
|
11508
|
+
const spec = MANIFEST_BY_NAME.get(name);
|
|
11509
|
+
return {
|
|
11510
|
+
name,
|
|
11511
|
+
group: TOOL_GROUP[name] ?? null,
|
|
11512
|
+
listed: isListed(PROFILE, name),
|
|
11513
|
+
description: TOOL_DOCS[name]?.description ?? spec.description,
|
|
11514
|
+
inputSchema: spec.inputSchema
|
|
11515
|
+
};
|
|
11516
|
+
};
|
|
11517
|
+
const candidates = MANIFEST.map((s) => s.name).filter((n) => AVAILABLE_NAMES.has(n));
|
|
11518
|
+
let ranked;
|
|
11519
|
+
if (candidates.includes(q)) {
|
|
11520
|
+
ranked = [q];
|
|
11521
|
+
} else {
|
|
11522
|
+
const tokens = tokenize(q);
|
|
11523
|
+
const scored = candidates.map((name, order) => {
|
|
11524
|
+
const nameTokens = tokenize(name);
|
|
11525
|
+
const group = TOOL_GROUP[name] ?? "";
|
|
11526
|
+
const docs = `${MANIFEST_BY_NAME.get(name).description} ${TOOL_DOCS[name]?.description ?? ""}`.toLowerCase();
|
|
11527
|
+
let score = 0;
|
|
11528
|
+
for (const t of tokens) {
|
|
11529
|
+
if (nameTokens.includes(t))
|
|
11530
|
+
score += 5;
|
|
11531
|
+
else if (name.includes(t))
|
|
11532
|
+
score += 3;
|
|
11533
|
+
if (group === t || group.includes(t))
|
|
11534
|
+
score += 2;
|
|
11535
|
+
if (docs.includes(t))
|
|
11536
|
+
score += 1;
|
|
11537
|
+
}
|
|
11538
|
+
return { name, order, score };
|
|
11539
|
+
});
|
|
11540
|
+
ranked = scored.filter((s) => s.score > 0).sort((a, b) => b.score - a.score || a.order - b.order).map((s) => s.name);
|
|
11541
|
+
}
|
|
11542
|
+
const unavailable = AVAILABILITY.unavailable.find((u) => u.name === q);
|
|
11543
|
+
return {
|
|
11544
|
+
query,
|
|
11545
|
+
total: ranked.length,
|
|
11546
|
+
matches: ranked.slice(0, limit).map(describe),
|
|
11547
|
+
...unavailable ? { unavailable: `${q} is not available under --browser ${BROWSER} (needs: ${unavailable.missing.join(", ")})` } : {},
|
|
11548
|
+
next: ranked.length ? "Run one with call_tool {name, arguments}." : "No match. Call search_tools with no query for the full catalog."
|
|
11549
|
+
};
|
|
11550
|
+
}
|
|
11389
11551
|
function renderToolDoc(toolName) {
|
|
11390
11552
|
const doc = TOOL_DOCS[toolName];
|
|
11391
11553
|
if (!doc)
|
|
@@ -11412,6 +11574,13 @@ function buildServer() {
|
|
|
11412
11574
|
server.setRequestHandler("tools/call", async (request) => {
|
|
11413
11575
|
const { name } = request.params;
|
|
11414
11576
|
const args = request.params.arguments ?? {};
|
|
11577
|
+
if (name === "search_tools") {
|
|
11578
|
+
const { query, limit } = args;
|
|
11579
|
+
if (typeof query !== "string" || query.trim() === "") {
|
|
11580
|
+
return { content: [{ type: "text", text: renderCatalog() }] };
|
|
11581
|
+
}
|
|
11582
|
+
return { content: [{ type: "text", text: JSON.stringify(searchTools(query, limit), null, 2) }] };
|
|
11583
|
+
}
|
|
11415
11584
|
if (name === "describe_tool") {
|
|
11416
11585
|
const requested = args.name;
|
|
11417
11586
|
if (typeof requested !== "string" || requested === "") {
|
|
@@ -11420,40 +11589,57 @@ function buildServer() {
|
|
|
11420
11589
|
const known = Boolean(TOOL_DOCS[requested]);
|
|
11421
11590
|
return { content: [{ type: "text", text: renderToolDoc(requested) }], isError: !known };
|
|
11422
11591
|
}
|
|
11423
|
-
if (
|
|
11424
|
-
const
|
|
11425
|
-
|
|
11426
|
-
content: [{ type: "text", text:
|
|
11427
|
-
|
|
11428
|
-
|
|
11429
|
-
|
|
11430
|
-
|
|
11431
|
-
|
|
11432
|
-
|
|
11433
|
-
|
|
11434
|
-
return { content: [{ type: "text", text: `unknown tool: ${name}` }], isError: true };
|
|
11435
|
-
try {
|
|
11436
|
-
const result = await fn(args);
|
|
11437
|
-
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
11438
|
-
} catch (err) {
|
|
11439
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
11440
|
-
return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
|
|
11441
|
-
}
|
|
11592
|
+
if (name === "call_tool") {
|
|
11593
|
+
const inner = args;
|
|
11594
|
+
if (typeof inner.name !== "string" || inner.name === "") {
|
|
11595
|
+
return { content: [{ type: "text", text: "call_tool: `name` is required (find one with search_tools)" }], isError: true };
|
|
11596
|
+
}
|
|
11597
|
+
if (META_TOOLS.has(inner.name)) {
|
|
11598
|
+
return { content: [{ type: "text", text: `call_tool: '${inner.name}' is a meta-tool; call it directly` }], isError: true };
|
|
11599
|
+
}
|
|
11600
|
+
const innerArgs = inner.arguments ?? {};
|
|
11601
|
+
if (typeof innerArgs !== "object" || Array.isArray(innerArgs)) {
|
|
11602
|
+
return { content: [{ type: "text", text: "call_tool: `arguments` must be an object" }], isError: true };
|
|
11442
11603
|
}
|
|
11443
|
-
|
|
11444
|
-
|
|
11604
|
+
return runTool(inner.name, innerArgs);
|
|
11605
|
+
}
|
|
11606
|
+
return runTool(name, args);
|
|
11607
|
+
});
|
|
11608
|
+
return server;
|
|
11609
|
+
}
|
|
11610
|
+
async function runTool(name, args) {
|
|
11611
|
+
if (!AVAILABLE_NAMES.has(name)) {
|
|
11612
|
+
const gap = AVAILABILITY.unavailable.find((u) => u.name === name);
|
|
11613
|
+
return {
|
|
11614
|
+
content: [{ type: "text", text: `unknown tool: ${name}${gap ? ` (not available under --browser ${BROWSER}, needs: ${gap.missing.join(", ")})` : ""}` }],
|
|
11615
|
+
isError: true
|
|
11616
|
+
};
|
|
11617
|
+
}
|
|
11618
|
+
return withLeaseScope(leaseFromArgs(args), async () => {
|
|
11619
|
+
if (BROWSER === "chrome") {
|
|
11620
|
+
const fn = dispatch[name];
|
|
11621
|
+
if (!fn)
|
|
11445
11622
|
return { content: [{ type: "text", text: `unknown tool: ${name}` }], isError: true };
|
|
11446
11623
|
try {
|
|
11447
|
-
const
|
|
11448
|
-
const result = await neutralFn(session.driver, args);
|
|
11624
|
+
const result = await fn(args);
|
|
11449
11625
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
11450
11626
|
} catch (err) {
|
|
11451
11627
|
const message = err instanceof Error ? err.message : String(err);
|
|
11452
11628
|
return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
|
|
11453
11629
|
}
|
|
11454
|
-
}
|
|
11630
|
+
}
|
|
11631
|
+
const neutralFn = neutralDispatch[name];
|
|
11632
|
+
if (!neutralFn)
|
|
11633
|
+
return { content: [{ type: "text", text: `unknown tool: ${name}` }], isError: true };
|
|
11634
|
+
try {
|
|
11635
|
+
const session = await getOrCreateFirefoxSession({ endpoint: FIREFOX_ENDPOINT });
|
|
11636
|
+
const result = await neutralFn(session.driver, args);
|
|
11637
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
11638
|
+
} catch (err) {
|
|
11639
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
11640
|
+
return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
|
|
11641
|
+
}
|
|
11455
11642
|
});
|
|
11456
|
-
return server;
|
|
11457
11643
|
}
|
|
11458
11644
|
async function run2(argv) {
|
|
11459
11645
|
const serve = parseServeOptions(argv);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cdp-toolkit",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "A lightweight chrome-devtools-mcp alternative: an MCP server + CLI that drives the tabs you name, one target per call over one direct WebSocket, with a bounded timeout on every call, so a stuck page can't wedge your agent or force a /mcp restart. Chrome over CDP and Firefox over WebDriver BiDi, with tools a backend can't run absent from tools/list rather than throwing mid-task. 48 tools including built-in network mocking, a cookie group, tab leases with human-activity detection, real HTML5 drag-and-drop, raw mouse/scroll dispatch, downloads, permission grants, and tab-to-video screen recording so several agents can drive one browser without stealing each other's tabs or a human's. No Puppeteer, no all-target fan-out.",
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "A lightweight chrome-devtools-mcp alternative: an MCP server + CLI that drives the tabs you name, one target per call over one direct WebSocket, with a bounded timeout on every call, so a stuck page can't wedge your agent or force a /mcp restart. Chrome over CDP and Firefox over WebDriver BiDi, with tools a backend can't run absent from tools/list rather than throwing mid-task. 48 tools (only 5 core tools listed by default, plus search_tools/call_tool to reach the rest) including built-in network mocking, a cookie group, tab leases with human-activity detection, real HTML5 drag-and-drop, raw mouse/scroll dispatch, downloads, permission grants, and tab-to-video screen recording so several agents can drive one browser without stealing each other's tabs or a human's. No Puppeteer, no all-target fan-out.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": {
|
|
@@ -75,6 +75,7 @@
|
|
|
75
75
|
"test": "bun test",
|
|
76
76
|
"test:runtime": "bun test test/runtime-entry.test.ts",
|
|
77
77
|
"smoke": "bun run ./test/smoke.ts",
|
|
78
|
+
"bench:wedge": "bun run scripts/wedge-bench.ts",
|
|
78
79
|
"mock:smoke": "bun run ./test/mock-smoke.ts",
|
|
79
80
|
"screencast:smoke": "bun run ./test/screencast-smoke.ts",
|
|
80
81
|
"mcp:smoke": "bun run ./test/mcp-smoke.ts",
|
|
@@ -87,7 +88,8 @@
|
|
|
87
88
|
"input:smoke": "bun run ./test/input-smoke.ts",
|
|
88
89
|
"extension:smoke": "bun run ./test/extension-smoke.ts",
|
|
89
90
|
"focus:smoke": "bun run ./test/focus-emulation-smoke.ts",
|
|
90
|
-
"extract:smoke": "bun run ./test/extract-smoke.ts"
|
|
91
|
+
"extract:smoke": "bun run ./test/extract-smoke.ts",
|
|
92
|
+
"gateway:smoke": "bun run ./test/gateway-smoke.ts"
|
|
91
93
|
},
|
|
92
94
|
"devDependencies": {
|
|
93
95
|
"@modelcontextprotocol/client": "^2.0.0",
|
package/src/bidi/driver.ts
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
import { assertLeaseOk, defaultLabel, isPidAlive } from "../leases.ts";
|
|
47
47
|
import { resolveLiveLabel } from "../origins.ts";
|
|
48
48
|
import { isWorkerSelector, WORKER_SELECTOR_UNSUPPORTED_MESSAGE } from "../workers.ts";
|
|
49
|
+
import { FRAME_SELECTOR_UNSUPPORTED_MESSAGE, isFrameSelector } from "../frames.ts";
|
|
49
50
|
import { BEACON_FUNCTION_DECLARATION, BEACON_READ_EXPRESSION, BEACON_SOURCE, recordDispatch } from "../activity.ts";
|
|
50
51
|
import { BidiConnection, BidiError, connectBidiSessionUrl } from "./client.ts";
|
|
51
52
|
import {
|
|
@@ -845,6 +846,13 @@ async function pickContext(
|
|
|
845
846
|
// "no context matching 'worker:abc'" that reads like a typo.
|
|
846
847
|
throw driverError("unsupported", WORKER_SELECTOR_UNSUPPORTED_MESSAGE);
|
|
847
848
|
}
|
|
849
|
+
if (isFrameSelector(selector)) {
|
|
850
|
+
// Capability-gated for the same reason as the worker: arm above. WebDriver
|
|
851
|
+
// BiDi has no iframe TARGET to resolve to — an iframe is a realm inside its
|
|
852
|
+
// tab's browsing context — so the honest error names the capability
|
|
853
|
+
// ('frame.targets', Chrome-only) rather than retrying or reading as a typo.
|
|
854
|
+
throw driverError("unsupported", FRAME_SELECTOR_UNSUPPORTED_MESSAGE);
|
|
855
|
+
}
|
|
848
856
|
const exact = contexts.find((c) => c.context === selector);
|
|
849
857
|
if (exact) return exact;
|
|
850
858
|
throw driverError("no-such-target", `no context matching '${selector}'`);
|
package/src/cdp/driver.ts
CHANGED
|
@@ -652,7 +652,7 @@ const CDP_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
|
|
|
652
652
|
"screenshot.fullPage", "screenshot.element", "screenshot.scale", "screenshot.renderSize", "screenshot.tile", "network.intercept",
|
|
653
653
|
"snapshot.accessibilityTree", "input.insertTextAtomic", "locate.text", "locate.xpath",
|
|
654
654
|
"capture.screencast", "input.raw", "input.html5Drag", "emulate.focus",
|
|
655
|
-
"browser.downloads", "browser.permissions", "worker.targets",
|
|
655
|
+
"browser.downloads", "browser.permissions", "worker.targets", "frame.targets",
|
|
656
656
|
]);
|
|
657
657
|
/* ---------------------------------- PageDriver ---------------------------------- */
|
|
658
658
|
class CdpPageDriver implements PageDriver {
|
|
@@ -820,10 +820,36 @@ class CdpPageDriver implements PageDriver {
|
|
|
820
820
|
const { objectId } = await resolveElementLocator(this.conn, loc);
|
|
821
821
|
return this.callFn(objectId, functionDeclaration, args, true);
|
|
822
822
|
}
|
|
823
|
+
/**
|
|
824
|
+
* OOPIF MARKERS (issue #7 fix 1): which emitted snapshot nodes own an out-of-process iframe.
|
|
825
|
+
*
|
|
826
|
+
* Chrome's Accessibility.getFullAXTree includes the <iframe> ELEMENT's AX node (role "Iframe",
|
|
827
|
+
* with a backendDOMNodeId) but not the subtree of an out-of-process iframe (OOPIF): that subtree
|
|
828
|
+
* lives in a separate renderer/AX tree, so its contents are simply absent and a caller reading
|
|
829
|
+
* the snapshot has no signal that an entire interactable document ends at that line. Same-origin
|
|
830
|
+
* iframes ARE merged into the one response, so they need no marker — only process-isolated
|
|
831
|
+
* frames do, and process isolation is decided by Chrome, not by any origin heuristic here.
|
|
832
|
+
*
|
|
833
|
+
* Truth source: the browser's target list (`/json/list`) names every OOPIF as its own target of
|
|
834
|
+
* type "iframe", and an OOPIF's target id IS its frame id. `Page.getFrameTree` is deliberately
|
|
835
|
+
* NOT used here: on a page-level websocket attachment it omits out-of-process child frames
|
|
836
|
+
* (observed Chrome 153: same DOM with the <iframe>, frame tree with no childFrames), which is
|
|
837
|
+
* exactly the set this marker exists for. DOM.getFrameOwner maps each candidate frame id to the
|
|
838
|
+
* backendNodeId of the owning <iframe> element — the node snapshot() is about to emit; it throws
|
|
839
|
+
* for frame ids this page doesn't own, which is the filter.
|
|
840
|
+
*
|
|
841
|
+
* The marker rides the node's existing `extras` map ("frame" = frame/target id, "frameUrl" when
|
|
842
|
+
* the frame tree reported one) because SnapshotNode is owned by ../driver.ts and shared with the
|
|
843
|
+
* BiDi driver; renderSnapshotLine renders unknown extras keys quoted, which is the desired shape
|
|
844
|
+
* (`[frame="A1B2..."]`). Everything here is failure-soft by design: a snapshot must never fail
|
|
845
|
+
* because marker detection did, so any error returns an empty map and the snapshot degrades to
|
|
846
|
+
* exactly its pre-marker output.
|
|
847
|
+
*/
|
|
823
848
|
async snapshot(opts?: { interactiveOnly?: boolean }): Promise<SnapshotNode[]> {
|
|
824
849
|
const interactiveOnly = opts?.interactiveOnly ?? false;
|
|
825
850
|
await this.ensureDomain("Accessibility");
|
|
826
851
|
const { nodes } = await this.conn.send<{ nodes: AxNode[] }>("Accessibility.getFullAXTree");
|
|
852
|
+
const oopifFrames = await this.detectOopifFrames();
|
|
827
853
|
const byId = new Map<string, AxNode>();
|
|
828
854
|
for (const n of nodes) byId.set(n.nodeId, n);
|
|
829
855
|
const roots = nodes.filter((n) => !n.parentId || !byId.has(n.parentId));
|
|
@@ -837,11 +863,20 @@ class CdpPageDriver implements PageDriver {
|
|
|
837
863
|
const walk = (node: AxNode, depth: number): void => {
|
|
838
864
|
const role = axString(node.role);
|
|
839
865
|
const name = axString(node.name);
|
|
840
|
-
const
|
|
841
|
-
const
|
|
866
|
+
const backendDOMNodeId = node.backendDOMNodeId;
|
|
867
|
+
const marker = backendDOMNodeId !== undefined ? oopifFrames.get(backendDOMNodeId) : undefined;
|
|
868
|
+
// interactiveOnly drops "Iframe" (not in INTERACTIVE_ROLES), but an iframe carrying an
|
|
869
|
+
// OOPIF marker is itself the accessibility boundary of an entire process, so it is
|
|
870
|
+
// emitted anyway: the default view must surface that the boundary exists. Everything
|
|
871
|
+
// else about the emit rule is unchanged, and the depth rule below is untouched.
|
|
872
|
+
const emitted = !node.ignored && backendDOMNodeId !== undefined && (interactiveOnly ? isInteractive(role) || marker !== undefined : !!(role || name));
|
|
842
873
|
if (emitted) {
|
|
843
874
|
const extras = axExtras(node);
|
|
844
|
-
|
|
875
|
+
if (marker) {
|
|
876
|
+
extras.frame = marker.frameId;
|
|
877
|
+
if (marker.frameUrl) extras.frameUrl = marker.frameUrl;
|
|
878
|
+
}
|
|
879
|
+
out.push({ uid: encodeUid(backendDOMNodeId as number), role: role ?? "generic", depth, ...(name ? { name } : {}), ...(Object.keys(extras).length ? { extras } : {}) });
|
|
845
880
|
}
|
|
846
881
|
const childDepth = emitted && !interactiveOnly ? depth + 1 : depth;
|
|
847
882
|
for (const childId of node.childIds ?? []) {
|
|
@@ -852,6 +887,36 @@ class CdpPageDriver implements PageDriver {
|
|
|
852
887
|
for (const root of roots) walk(root, 0);
|
|
853
888
|
return out;
|
|
854
889
|
}
|
|
890
|
+
/**
|
|
891
|
+
* The marker map behind snapshot()'s OOPIF annotations (see snapshot()'s OOPIF MARKERS block):
|
|
892
|
+
* backendNodeId of the owning <iframe> element -> { frameId (= CDP target id), frameUrl }.
|
|
893
|
+
* Failure-soft end to end: any error anywhere in the round-trip yields an EMPTY map, never a
|
|
894
|
+
* failed snapshot. DOM.getFrameOwner throws for the main frame, which is why only non-main
|
|
895
|
+
* frames are visited; ensureDomainSoft covers the DOM.enable it requires without making a
|
|
896
|
+
* failed enable break anything else (getFrameOwner would simply throw into the same catch).
|
|
897
|
+
*/
|
|
898
|
+
private async detectOopifFrames(): Promise<Map<number, { frameId: string; frameUrl?: string }>> {
|
|
899
|
+
const markers = new Map<number, { frameId: string; frameUrl?: string }>();
|
|
900
|
+
try {
|
|
901
|
+
await this.ensureDomainSoft("DOM");
|
|
902
|
+
// Process-isolation truth, not an origin heuristic: every OOPIF has its own iframe-type
|
|
903
|
+
// CDP target whose id IS its frame id (listTargets is client.ts's /json/list). This page
|
|
904
|
+
// owns only some of them — DOM.getFrameOwner throws for any frame id the page doesn't own
|
|
905
|
+
// (notably iframe targets inside OTHER tabs), and that throw is the ownership filter.
|
|
906
|
+
const iframeTargets = (await listTargets()).filter((t) => t.type === "iframe");
|
|
907
|
+
for (const target of iframeTargets) {
|
|
908
|
+
const owner = await this.conn
|
|
909
|
+
.send<{ backendNodeId?: number }>("DOM.getFrameOwner", { frameId: target.id })
|
|
910
|
+
.catch(() => ({ backendNodeId: undefined }));
|
|
911
|
+
if (typeof owner.backendNodeId === "number") {
|
|
912
|
+
markers.set(owner.backendNodeId, { frameId: target.id, ...(target.url ? { frameUrl: target.url } : {}) });
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
} catch {
|
|
916
|
+
// Deliberately empty: see the doc comment. Markers are an annotation, not a guarantee.
|
|
917
|
+
}
|
|
918
|
+
return markers;
|
|
919
|
+
}
|
|
855
920
|
async locate(loc: ElementLocator): Promise<DriverUid> {
|
|
856
921
|
return encodeUid(await backendNodeIdOf(this.conn, loc));
|
|
857
922
|
}
|