cdp-toolkit 1.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.
@@ -0,0 +1,265 @@
1
+ /**
2
+ * recorder.ts — the stateful console+network capture engine.
3
+ *
4
+ * RECORDER MODEL
5
+ * ==============
6
+ * A recorder holds ONE persistent page connection (via `openPage`, never closed
7
+ * until `stop()`), enables the relevant CDP domains, and appends every relevant
8
+ * event as a single-line JSON object ("JSON Lines") to a per-target buffer file:
9
+ *
10
+ * ${ARTIFACT_DIR}/rec-<targetId>.jsonl
11
+ *
12
+ * Each line is one CDP event, wrapped with a discriminator so the console/network
13
+ * readers can cheaply filter the same file:
14
+ *
15
+ * { "kind": "network" | "console", "method": "<CDP event>", "ts": <ms>, "params": { ... } }
16
+ *
17
+ * Domains enabled per option:
18
+ * - network: Network.enable -> Network.requestWillBeSent, .responseReceived,
19
+ * .loadingFinished, .loadingFailed
20
+ * - console: Runtime.enable -> Runtime.consoleAPICalled, .exceptionThrown
21
+ * Log.enable -> Log.entryAdded (browser-surfaced messages)
22
+ *
23
+ * CAPTURE MODEL (what the tool surface actually uses)
24
+ * ---------------------------------------------------
25
+ * The console/network tools drive `captureWindow()` — a one-shot reload capture
26
+ * that records BOTH domains into a UNIQUE per-capture file
27
+ * (`rec-<targetId>-<captureId>.jsonl`) and, on stop, publishes that capture as
28
+ * the target's shared "latest" buffer (`rec-<targetId>.jsonl`). Capturing both
29
+ * domains every time means a network capture never clobbers console history
30
+ * (and vice-versa); the per-capture file means two concurrent captures against
31
+ * the same target can't interleave into one another's results.
32
+ *
33
+ * BACKGROUND RECORDING (library-only)
34
+ * -----------------------------------
35
+ * `startRecorder({ truncate: false })` opens a long-lived appending recorder.
36
+ * This is reachable from the library API but NOT wired to a CLI tool (a stateless
37
+ * CLI process can't keep it alive across invocations); the CLI surface is
38
+ * reload-driven via `captureWindow`. `startRecorder` resolves only AFTER the CDP
39
+ * domains are enabled, so a caller can trigger navigation/reload immediately and
40
+ * be sure events are caught.
41
+ */
42
+ import { mkdir, appendFile, writeFile, copyFile } from "node:fs/promises";
43
+ import { openPage, resolveTarget } from "../client.ts";
44
+ import type { CdpConnection } from "../client.ts";
45
+ import type { Target, TargetSelector } from "../types.ts";
46
+
47
+ /** Artifact / buffer directory (shared with the rest of the toolkit). */
48
+ export const ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
49
+
50
+ /** Compute the shared "latest" JSONL buffer path for a given targetId. */
51
+ export function recFile(targetId: string): string {
52
+ return `${ARTIFACT_DIR}/rec-${targetId}.jsonl`;
53
+ }
54
+
55
+ /** Compute a unique per-capture JSONL buffer path (isolates concurrent captures). */
56
+ export function captureFile(targetId: string, captureId: string): string {
57
+ return `${ARTIFACT_DIR}/rec-${targetId}-${captureId}.jsonl`;
58
+ }
59
+
60
+ /** Sleep helper (module runtime only; not the workflow-script sandbox). */
61
+ function delay(ms: number): Promise<void> {
62
+ return new Promise((r) => setTimeout(r, ms));
63
+ }
64
+
65
+ /** Network CDP events we persist. */
66
+ const NETWORK_EVENTS = [
67
+ "Network.requestWillBeSent",
68
+ "Network.responseReceived",
69
+ "Network.loadingFinished",
70
+ "Network.loadingFailed",
71
+ ] as const;
72
+
73
+ /** Console / log CDP events we persist. */
74
+ const CONSOLE_EVENTS = ["Runtime.consoleAPICalled", "Runtime.exceptionThrown", "Log.entryAdded"] as const;
75
+
76
+ /** A single persisted line in a rec-<targetId>.jsonl buffer. */
77
+ export interface RecLine {
78
+ kind: "network" | "console";
79
+ method: string;
80
+ ts: number;
81
+ params: Record<string, unknown>;
82
+ }
83
+
84
+ export interface RecorderOptions {
85
+ /** Capture Network.* events (default true). */
86
+ network?: boolean;
87
+ /** Capture Runtime/Log console events (default true). */
88
+ console?: boolean;
89
+ /** Truncate the buffer file before recording (fresh capture window). */
90
+ truncate?: boolean;
91
+ /** Override the buffer file path (defaults to the shared recFile(targetId)). */
92
+ file?: string;
93
+ /** Per-command timeout override forwarded to the connection. */
94
+ timeoutMs?: number;
95
+ }
96
+
97
+ export interface RecorderHandle {
98
+ /** Await all in-flight appends WITHOUT closing the connection (so bodies stay fetchable). */
99
+ flush(): Promise<void>;
100
+ /** Flush, then close the connection (events stop, file stays). */
101
+ stop(): Promise<void>;
102
+ /** The JSONL buffer file being written. */
103
+ file: string;
104
+ /** The resolved target this recorder is attached to. */
105
+ target: Target;
106
+ /** The live connection (exposed so a one-shot caller can drive Page.reload on it). */
107
+ conn: CdpConnection;
108
+ /** Count of buffer-write failures observed (0 on a healthy capture). */
109
+ droppedWrites(): number;
110
+ }
111
+
112
+ /**
113
+ * Start a persistent recorder against `target`. Enables the requested domains
114
+ * and begins appending matching events to the per-target JSONL buffer. The
115
+ * returned handle's `stop()` closes the connection (events stop, file stays).
116
+ */
117
+ export async function startRecorder(target: TargetSelector, opts: RecorderOptions = {}): Promise<RecorderHandle> {
118
+ const network = opts.network ?? true;
119
+ const consoleCap = opts.console ?? true;
120
+
121
+ await mkdir(ARTIFACT_DIR, { recursive: true });
122
+ const { conn, target: resolved } = await openPage(target, { timeoutMs: opts.timeoutMs });
123
+ const file = opts.file ?? recFile(resolved.id);
124
+
125
+ if (opts.truncate) {
126
+ await writeFile(file, "");
127
+ }
128
+
129
+ // Serialize appends so concurrent events never interleave a partial line.
130
+ let chain: Promise<void> = Promise.resolve();
131
+ let dropped = 0;
132
+ const persist = (kind: RecLine["kind"], method: string, params: Record<string, unknown>): void => {
133
+ const line: RecLine = { kind, method, ts: Date.now(), params };
134
+ const text = `${JSON.stringify(line)}\n`;
135
+ chain = chain.then(() => appendFile(file, text)).catch(() => {
136
+ // Buffer-write failures are tolerated (capture is best-effort) but counted
137
+ // so callers can surface under-capture instead of silently returning fewer rows.
138
+ dropped += 1;
139
+ });
140
+ };
141
+
142
+ const unsubs: Array<() => void> = [];
143
+
144
+ try {
145
+ if (network) {
146
+ await conn.send("Network.enable");
147
+ for (const ev of NETWORK_EVENTS) {
148
+ unsubs.push(conn.on(ev, (params) => persist("network", ev, params)));
149
+ }
150
+ }
151
+ if (consoleCap) {
152
+ await conn.send("Runtime.enable");
153
+ // Log domain is optional on some targets; tolerate failure.
154
+ try {
155
+ await conn.send("Log.enable");
156
+ } catch {
157
+ /* Log not supported on this target type — Runtime events still flow */
158
+ }
159
+ for (const ev of CONSOLE_EVENTS) {
160
+ unsubs.push(conn.on(ev, (params) => persist("console", ev, params)));
161
+ }
162
+ }
163
+ } catch (err) {
164
+ conn.close();
165
+ throw err;
166
+ }
167
+
168
+ // Stop accepting new events, then let queued appends settle — without closing
169
+ // the connection (response bodies stay fetchable on `conn`).
170
+ const flush = async (): Promise<void> => {
171
+ for (const off of unsubs) off();
172
+ await chain;
173
+ };
174
+
175
+ return {
176
+ file,
177
+ target: resolved,
178
+ conn,
179
+ droppedWrites: () => dropped,
180
+ flush,
181
+ async stop(): Promise<void> {
182
+ await flush();
183
+ conn.close();
184
+ },
185
+ };
186
+ }
187
+
188
+ /**
189
+ * One-shot reload capture used by the console/network tools. Records BOTH
190
+ * domains into a unique per-capture file, reloads the page, waits `durationMs`,
191
+ * and (on stop) publishes the capture as the target's shared "latest" buffer.
192
+ * The returned handle keeps the connection open until `stop()`, so a caller can
193
+ * `flush()` then fetch response bodies before closing.
194
+ */
195
+ export interface CaptureWindow {
196
+ /** The unique per-capture buffer file (read this for isolated results). */
197
+ file: string;
198
+ /** Live connection (open until stop) — for Network.getResponseBody etc. */
199
+ conn: CdpConnection;
200
+ /** Resolved target identity. */
201
+ resolved: { id: string; url: string; title: string };
202
+ /** Buffer-write failure count. */
203
+ droppedWrites(): number;
204
+ /** Await in-flight appends without closing (bodies stay fetchable). */
205
+ flush(): Promise<void>;
206
+ /** Flush, publish to the shared latest buffer, and close the connection. */
207
+ stop(): Promise<void>;
208
+ }
209
+
210
+ export async function captureWindow(
211
+ target: TargetSelector,
212
+ durationMs: number,
213
+ opts: { timeoutMs?: number } = {},
214
+ ): Promise<CaptureWindow> {
215
+ const resolved = await resolveTarget(target);
216
+ const captureId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
217
+ const file = captureFile(resolved.id, captureId);
218
+ // Record both domains so neither tool clobbers the other's history.
219
+ const rec = await startRecorder(resolved.id, {
220
+ network: true,
221
+ console: true,
222
+ truncate: true,
223
+ file,
224
+ timeoutMs: opts.timeoutMs,
225
+ });
226
+ await rec.conn.send("Page.enable");
227
+ await rec.conn.send("Page.reload", { ignoreCache: false });
228
+ await delay(durationMs);
229
+ return {
230
+ file: rec.file,
231
+ conn: rec.conn,
232
+ resolved: { id: rec.target.id, url: rec.target.url, title: rec.target.title },
233
+ droppedWrites: rec.droppedWrites,
234
+ flush: () => rec.flush(),
235
+ async stop(): Promise<void> {
236
+ await rec.stop();
237
+ // Publish this capture as the target's shared "latest" buffer (best-effort),
238
+ // so a later default (reload:false) read surfaces the most recent capture.
239
+ try {
240
+ await copyFile(rec.file, recFile(rec.target.id));
241
+ } catch {
242
+ /* shared-buffer publish is best-effort */
243
+ }
244
+ },
245
+ };
246
+ }
247
+
248
+ /*
249
+ * CDP methods / domains used:
250
+ * Network.enable
251
+ * Network.requestWillBeSent (event)
252
+ * Network.responseReceived (event)
253
+ * Network.loadingFinished (event)
254
+ * Network.loadingFailed (event)
255
+ * Runtime.enable
256
+ * Runtime.consoleAPICalled (event)
257
+ * Runtime.exceptionThrown (event)
258
+ * Log.enable
259
+ * Log.entryAdded (event)
260
+ *
261
+ * Parity gaps vs chrome-devtools-mcp:
262
+ * - Buffering is to a per-target JSONL file on disk, not an in-memory ring; the
263
+ * MCP keeps an in-process buffer scoped to its single managed page.
264
+ * - No automatic resource-type / initiator enrichment beyond raw CDP params.
265
+ */
@@ -0,0 +1,180 @@
1
+ /**
2
+ * screenshot tool — `take_screenshot` over raw CDP.
3
+ *
4
+ * Captures the viewport, the full scrollable page, or a single element via
5
+ * Page.captureScreenshot. Full-page support computes the content size from
6
+ * Page.getLayoutMetrics and passes an explicit clip + captureBeyondViewport so
7
+ * we don't depend on Headless-only conveniences (Arc is headful). Element
8
+ * capture resolves the node (uid -> backendNodeId, or CSS selector) and clips
9
+ * to its box. Artifacts are written under ARTIFACT_DIR; the base64 bytes are
10
+ * only returned when explicitly requested.
11
+ */
12
+ import { writeFile, mkdir } from "node:fs/promises";
13
+ import { join } from "node:path";
14
+ import type { CdpConnection } from "../client.ts";
15
+ import { CdpError, withPage } from "../client.ts";
16
+ import type { Target, TargetSelector, Uid } from "../types.ts";
17
+
18
+ const ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
19
+
20
+ export interface ScreenshotArgs {
21
+ target?: TargetSelector;
22
+ format?: "png" | "jpeg";
23
+ /** JPEG/quality 0-100. Ignored for png. */
24
+ quality?: number;
25
+ /** Capture the full scrollable content height, not just the viewport. */
26
+ fullPage?: boolean;
27
+ /** Element to clip to (a backendDOMNodeId). Mutually exclusive with selector. */
28
+ uid?: Uid;
29
+ /** CSS selector to clip to. Mutually exclusive with uid. */
30
+ selector?: string;
31
+ /** Override the artifact path. Default: ARTIFACT_DIR/<stamp>.<ext>. */
32
+ savePath?: string;
33
+ /** Also return the raw base64 image in the result. */
34
+ returnBase64?: boolean;
35
+ }
36
+
37
+ export interface ScreenshotResult {
38
+ path: string;
39
+ bytes: number;
40
+ format: "png" | "jpeg";
41
+ target: { id: string; url: string; title: string };
42
+ base64?: string;
43
+ }
44
+
45
+ interface Viewport {
46
+ x: number;
47
+ y: number;
48
+ width: number;
49
+ height: number;
50
+ scale: number;
51
+ }
52
+
53
+ interface LayoutMetrics {
54
+ cssContentSize?: { x: number; y: number; width: number; height: number };
55
+ contentSize?: { x: number; y: number; width: number; height: number };
56
+ cssLayoutViewport?: { clientWidth: number; clientHeight: number };
57
+ }
58
+
59
+ interface BoxModel {
60
+ model: {
61
+ /** content quad: [x1,y1,x2,y2,x3,y3,x4,y4] (top-left, top-right, bottom-right, bottom-left). */
62
+ content: number[];
63
+ width: number;
64
+ height: number;
65
+ };
66
+ }
67
+
68
+ function shortId(id: string): string {
69
+ return id.slice(0, 8);
70
+ }
71
+
72
+ function stamp(): string {
73
+ return new Date(Date.now()).toISOString().replace(/[:.]/g, "-");
74
+ }
75
+
76
+ /** Compute a full-content clip from layout metrics (CSS pixels). */
77
+ async function fullPageClip(conn: CdpConnection): Promise<Viewport> {
78
+ const m = await conn.send<LayoutMetrics>("Page.getLayoutMetrics");
79
+ const size = m.cssContentSize ?? m.contentSize;
80
+ if (!size) throw new CdpError("Page.getLayoutMetrics returned no content size");
81
+ return { x: 0, y: 0, width: Math.ceil(size.width), height: Math.ceil(size.height), scale: 1 };
82
+ }
83
+
84
+ /** Compute an element clip from its box model (uid or CSS selector). */
85
+ async function elementClip(conn: CdpConnection, uid: Uid | undefined, selector: string | undefined): Promise<Viewport> {
86
+ let backendNodeId: number;
87
+ if (uid != null) {
88
+ backendNodeId = uid;
89
+ } else if (selector) {
90
+ const { root } = await conn.send<{ root: { nodeId: number } }>("DOM.getDocument", { depth: 0 });
91
+ const { nodeId } = await conn.send<{ nodeId: number }>("DOM.querySelector", {
92
+ nodeId: root.nodeId,
93
+ selector,
94
+ });
95
+ if (!nodeId) throw new CdpError(`selector matched no element: ${selector}`);
96
+ const desc = await conn.send<{ node: { backendNodeId: number } }>("DOM.describeNode", { nodeId });
97
+ backendNodeId = desc.node.backendNodeId;
98
+ } else {
99
+ throw new CdpError("elementClip requires uid or selector");
100
+ }
101
+
102
+ // Scroll the element into view so the clip is within the captured region.
103
+ try {
104
+ await conn.send("DOM.scrollIntoViewIfNeeded", { backendNodeId });
105
+ } catch {
106
+ /* not all builds expose this; box-model coordinates are absolute regardless */
107
+ }
108
+
109
+ const box = await conn.send<BoxModel>("DOM.getBoxModel", { backendNodeId });
110
+ const q = box.model.content;
111
+ const q0 = q[0];
112
+ const q1 = q[1];
113
+ if (q0 == null || q1 == null) throw new CdpError("DOM.getBoxModel returned an empty content quad");
114
+ const xs = [q[0], q[2], q[4], q[6]].filter((n): n is number => n != null);
115
+ const ys = [q[1], q[3], q[5], q[7]].filter((n): n is number => n != null);
116
+ const minX = Math.min(...xs);
117
+ const minY = Math.min(...ys);
118
+ const maxX = Math.max(...xs);
119
+ const maxY = Math.max(...ys);
120
+ const width = Math.ceil(maxX - minX);
121
+ const height = Math.ceil(maxY - minY);
122
+ if (width <= 0 || height <= 0) throw new CdpError("resolved element has zero area");
123
+ return { x: minX, y: minY, width, height, scale: 1 };
124
+ }
125
+
126
+ /**
127
+ * take_screenshot — capture viewport / full page / element to a file.
128
+ * Returns { path, bytes, format } (+ base64 when requested).
129
+ */
130
+ export async function takeScreenshot(args: ScreenshotArgs = {}): Promise<ScreenshotResult> {
131
+ const { target, fullPage, uid, selector, savePath, returnBase64 } = args;
132
+ if (uid != null && selector) throw new CdpError("provide exactly one of uid or selector, not both");
133
+ const format: "png" | "jpeg" = args.format ?? "png";
134
+ const quality = format === "jpeg" ? (args.quality ?? 80) : undefined;
135
+
136
+ return withPage(target, async (conn: CdpConnection, t: Target) => {
137
+ await conn.send("Page.enable");
138
+
139
+ const params: Record<string, unknown> = { format, captureBeyondViewport: true };
140
+ if (quality != null) params.quality = quality;
141
+
142
+ if (uid != null || selector) {
143
+ params.clip = await elementClip(conn, uid, selector);
144
+ } else if (fullPage) {
145
+ params.clip = await fullPageClip(conn);
146
+ }
147
+
148
+ const { data } = await conn.send<{ data: string }>("Page.captureScreenshot", params);
149
+ const buf = Buffer.from(data, "base64");
150
+ if (buf.byteLength === 0) throw new CdpError("Page.captureScreenshot returned empty data");
151
+
152
+ await mkdir(ARTIFACT_DIR, { recursive: true });
153
+ const ext = format === "jpeg" ? "jpg" : "png";
154
+ const path = savePath ?? join(ARTIFACT_DIR, `screenshot-${shortId(t.id)}-${stamp()}.${ext}`);
155
+ await writeFile(path, buf);
156
+
157
+ const result: ScreenshotResult = {
158
+ path,
159
+ bytes: buf.byteLength,
160
+ format,
161
+ target: { id: t.id, url: t.url, title: t.title },
162
+ };
163
+ if (returnBase64) result.base64 = data;
164
+ return result;
165
+ });
166
+ }
167
+
168
+ /*
169
+ * CDP methods/domains used:
170
+ * - Page.enable
171
+ * - Page.captureScreenshot { format, quality, clip, captureBeyondViewport }
172
+ * - Page.getLayoutMetrics (full-page content clip)
173
+ * - DOM.getDocument / DOM.querySelector / DOM.describeNode (selector -> backendNodeId)
174
+ * - DOM.scrollIntoViewIfNeeded (best-effort, ignored if unsupported)
175
+ * - DOM.getBoxModel (element clip)
176
+ * Parity gaps vs chrome-devtools-mcp take_screenshot:
177
+ * - MCP refs are its own snapshot uids; here uid IS a backendDOMNodeId (toolkit ref scheme).
178
+ * - No device-pixel upscaling control beyond emulation-set deviceScaleFactor (clip scale fixed at 1).
179
+ * - Full-page on headful Arc relies on captureBeyondViewport + layout-metrics clip rather than headless auto-sizing.
180
+ */
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Accessibility snapshot + the shared element-reference helper.
3
+ *
4
+ * The reference scheme (CONTRACT.md "The element-reference scheme"):
5
+ * A `Uid` IS a CDP `backendDOMNodeId` (a number). Refs are therefore
6
+ * stateless — `take_snapshot` emits them straight from the a11y tree, and
7
+ * every interaction tool resolves them back to a live DOM node via
8
+ * `DOM.resolveNode({ backendNodeId: uid })`. There is no server-side ref
9
+ * table to drift or expire.
10
+ */
11
+ import type { CdpConnection } from "../client.ts";
12
+ import { withPage } from "../client.ts";
13
+ import type { Target, TargetSelector, Uid } from "../types.ts";
14
+
15
+ /* --------------------------- Accessibility AX types --------------------------- */
16
+
17
+ interface AxValue {
18
+ type?: string;
19
+ value?: unknown;
20
+ }
21
+
22
+ interface AxProperty {
23
+ name: string;
24
+ value?: AxValue;
25
+ }
26
+
27
+ interface AxNode {
28
+ nodeId: string;
29
+ ignored?: boolean;
30
+ role?: AxValue;
31
+ name?: AxValue;
32
+ value?: AxValue;
33
+ description?: AxValue;
34
+ properties?: AxProperty[];
35
+ childIds?: string[];
36
+ backendDOMNodeId?: number;
37
+ parentId?: string;
38
+ }
39
+
40
+ /** Roles that are meaningful to a downstream agent driving the page. */
41
+ const INTERACTIVE_ROLES = new Set<string>([
42
+ "button",
43
+ "link",
44
+ "textbox",
45
+ "searchbox",
46
+ "checkbox",
47
+ "radio",
48
+ "combobox",
49
+ "listbox",
50
+ "option",
51
+ "menuitem",
52
+ "menuitemcheckbox",
53
+ "menuitemradio",
54
+ "tab",
55
+ "switch",
56
+ "slider",
57
+ "spinbutton",
58
+ "textfield",
59
+ "MenuItem",
60
+ "Button",
61
+ ]);
62
+
63
+ function axString(v: AxValue | undefined): string | undefined {
64
+ if (!v) return undefined;
65
+ const raw = v.value;
66
+ if (raw === undefined || raw === null) return undefined;
67
+ const s = String(raw).trim();
68
+ return s.length ? s : undefined;
69
+ }
70
+
71
+ function propValue(node: AxNode, name: string): string | undefined {
72
+ const p = node.properties?.find((x) => x.name === name);
73
+ return axString(p?.value);
74
+ }
75
+
76
+ function isInteractive(role: string | undefined): boolean {
77
+ if (!role) return false;
78
+ return INTERACTIVE_ROLES.has(role) || INTERACTIVE_ROLES.has(role.toLowerCase());
79
+ }
80
+
81
+ /** Build the single-line summary for one AX node, or undefined to skip it. */
82
+ function formatNode(node: AxNode, interactiveOnly: boolean): string | undefined {
83
+ const role = axString(node.role);
84
+ const name = axString(node.name);
85
+ const uid = node.backendDOMNodeId;
86
+
87
+ // Nodes without a backend DOM id cannot be referenced by interaction tools.
88
+ if (uid === undefined) return undefined;
89
+
90
+ if (interactiveOnly && !isInteractive(role)) return undefined;
91
+
92
+ // In full mode, skip structurally-empty generic containers (no role/name).
93
+ if (!interactiveOnly && !role && !name) return undefined;
94
+
95
+ const extras: string[] = [];
96
+ const value = axString(node.value);
97
+ if (value) extras.push(`value=${JSON.stringify(value)}`);
98
+ const checked = propValue(node, "checked");
99
+ if (checked && checked !== "false") extras.push(`checked=${checked}`);
100
+ const expanded = propValue(node, "expanded");
101
+ if (expanded) extras.push(`expanded=${expanded}`);
102
+ const disabled = propValue(node, "disabled");
103
+ if (disabled === "true") extras.push("disabled");
104
+ const url = propValue(node, "url");
105
+ if (url) extras.push(`url=${url}`);
106
+ const focused = propValue(node, "focused");
107
+ if (focused === "true") extras.push("focused");
108
+
109
+ const label = name ? ` ${JSON.stringify(name)}` : "";
110
+ const extra = extras.length ? ` [${extras.join(" ")}]` : "";
111
+ return `[${uid}] ${role ?? "generic"}${label}${extra}`;
112
+ }
113
+
114
+ export interface TakeSnapshotArgs {
115
+ target?: TargetSelector;
116
+ /** When true, emit only interactive/meaningful nodes (default false = full tree). */
117
+ interactiveOnly?: boolean;
118
+ }
119
+
120
+ export interface TakeSnapshotResult {
121
+ snapshot: string;
122
+ target: { id: string; url: string; title: string };
123
+ nodeCount: number;
124
+ }
125
+
126
+ /**
127
+ * take_snapshot — Accessibility.getFullAXTree walked into a compact indented
128
+ * text tree. Each emitted line carries the node's backendDOMNodeId as [uid],
129
+ * which interaction tools feed straight back to resolveUid.
130
+ */
131
+ export async function takeSnapshot(args: TakeSnapshotArgs = {}): Promise<TakeSnapshotResult> {
132
+ const interactiveOnly = args.interactiveOnly ?? false;
133
+ return withPage(args.target, async (conn, target) => {
134
+ await conn.send("Accessibility.enable");
135
+ const { nodes } = await conn.send<{ nodes: AxNode[] }>("Accessibility.getFullAXTree");
136
+
137
+ const byId = new Map<string, AxNode>();
138
+ for (const n of nodes) byId.set(n.nodeId, n);
139
+
140
+ // Find roots: a node whose parentId is absent or not present in the set.
141
+ const roots = nodes.filter((n) => !n.parentId || !byId.has(n.parentId));
142
+
143
+ const lines: string[] = [];
144
+ let nodeCount = 0;
145
+
146
+ const walk = (node: AxNode, depth: number): void => {
147
+ const line = node.ignored ? undefined : formatNode(node, interactiveOnly);
148
+ // In interactiveOnly mode we keep tree depth flat for emitted nodes so the
149
+ // output stays readable; in full mode we preserve hierarchy via indent.
150
+ if (line !== undefined) {
151
+ const indent = interactiveOnly ? "" : " ".repeat(depth);
152
+ lines.push(`${indent}${line}`);
153
+ nodeCount++;
154
+ }
155
+ const childDepth = line !== undefined && !interactiveOnly ? depth + 1 : depth;
156
+ for (const childId of node.childIds ?? []) {
157
+ const child = byId.get(childId);
158
+ if (child) walk(child, childDepth);
159
+ }
160
+ };
161
+
162
+ for (const root of roots) walk(root, 0);
163
+
164
+ return {
165
+ snapshot: lines.join("\n"),
166
+ target: { id: target.id, url: target.url, title: target.title },
167
+ nodeCount,
168
+ } satisfies TakeSnapshotResult;
169
+ });
170
+ }
171
+
172
+ /* ------------------------------ shared ref helper ------------------------------ */
173
+
174
+ /**
175
+ * Resolve a Uid (backendDOMNodeId) to a live JS object handle. Shared by every
176
+ * interaction tool in input.ts. Throws if the node no longer exists.
177
+ */
178
+ export async function resolveUid(conn: CdpConnection, uid: Uid): Promise<{ objectId: string }> {
179
+ const { object } = await conn.send<{ object: { objectId?: string } }>("DOM.resolveNode", {
180
+ backendNodeId: uid,
181
+ });
182
+ if (!object?.objectId) {
183
+ throw new Error(`resolveUid: DOM.resolveNode returned no objectId for uid ${uid}`);
184
+ }
185
+ return { objectId: object.objectId };
186
+ }
187
+
188
+ /**
189
+ * Internal helper exposed for input.ts: load the active page target so input
190
+ * tools can echo it back without re-resolving. Not part of the public tool set.
191
+ */
192
+ export type SnapshotTarget = Pick<Target, "id" | "url" | "title">;
193
+
194
+ /* ------------------------------------------------------------------------------
195
+ * CDP methods used:
196
+ * Accessibility.enable, Accessibility.getFullAXTree (take_snapshot)
197
+ * DOM.resolveNode (resolveUid helper)
198
+ * Parity gaps vs chrome-devtools-mcp take_snapshot:
199
+ * - MCP assigns sequential ref ids (1,2,3...) backed by a server-side handle
200
+ * table; we instead expose the raw backendDOMNodeId as the uid (stateless,
201
+ * but ids are larger / non-sequential and not human-friendly).
202
+ * - MCP's snapshot includes a per-frame structure and a "more nodes" cursor;
203
+ * this returns the full tree in one shot (frames flattened into one tree).
204
+ * - interactiveOnly is our addition (flattens to interactive nodes only);
205
+ * MCP always returns the structured tree.
206
+ * ---------------------------------------------------------------------------- */