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,212 @@
1
+ /**
2
+ * emulation tools — `emulate` and `resize_page` over raw CDP.
3
+ *
4
+ * `emulate` is a one-call switchboard over Chrome's emulation overrides:
5
+ * device metrics (size / DPR / mobile), user-agent, CPU throttling, emulated
6
+ * media (type + features like prefers-color-scheme), and network conditions.
7
+ * `resize_page` is the narrow case of setting just the device-metrics size.
8
+ * A `clearOverrides` path resets every override back to the browser default.
9
+ *
10
+ * NOTE on persistence: these overrides live on the DevTools *session*. The
11
+ * toolkit's stateless one-shot model (withPage opens then closes the
12
+ * connection) means an override applied in one call is gone once that
13
+ * connection closes — except device metrics, which Chrome keeps applied to the
14
+ * target until explicitly cleared or the renderer navigates/reloads. We
15
+ * therefore re-assert metrics in resize_page and document this in the footer.
16
+ */
17
+ import type { CdpConnection } from "../client.ts";
18
+ import { CdpError, withPage } from "../client.ts";
19
+ import type { Target, TargetSelector } from "../types.ts";
20
+
21
+ export interface NetworkConditions {
22
+ /** true = simulate offline. */
23
+ offline?: boolean;
24
+ /** additional round-trip latency, ms. */
25
+ latency?: number;
26
+ /** max download throughput, bytes/sec (-1 = no limit). */
27
+ downloadThroughput?: number;
28
+ /** max upload throughput, bytes/sec (-1 = no limit). */
29
+ uploadThroughput?: number;
30
+ /** "none" | "cellular2g" | "cellular3g" | "cellular4g" | "bluetooth" | "ethernet" | "wifi" | "wimax" | "other" */
31
+ connectionType?: string;
32
+ }
33
+
34
+ export interface EmulateArgs {
35
+ target?: TargetSelector;
36
+ width?: number;
37
+ height?: number;
38
+ deviceScaleFactor?: number;
39
+ mobile?: boolean;
40
+ userAgent?: string;
41
+ /** 1 = no throttle; 2 = 2x slower; etc. */
42
+ cpuThrottlingRate?: number;
43
+ /** Emulated CSS media type: "screen" | "print" | "" (clear). */
44
+ media?: string;
45
+ /** Emulated media features, e.g. [{ name:"prefers-color-scheme", value:"dark" }]. */
46
+ mediaFeatures?: Array<{ name: string; value: string }>;
47
+ networkConditions?: NetworkConditions;
48
+ /** Reset every override to the browser default; ignores all other fields. */
49
+ clearOverrides?: boolean;
50
+ }
51
+
52
+ export interface EmulateResult {
53
+ target: { id: string; url: string; title: string };
54
+ applied: string[];
55
+ cleared?: boolean;
56
+ }
57
+
58
+ export interface ResizeArgs {
59
+ target?: TargetSelector;
60
+ width: number;
61
+ height: number;
62
+ deviceScaleFactor?: number;
63
+ mobile?: boolean;
64
+ }
65
+
66
+ export interface ResizeResult {
67
+ target: { id: string; url: string; title: string };
68
+ width: number;
69
+ height: number;
70
+ /** window.innerWidth/innerHeight observed after the override is applied. */
71
+ innerWidth: number;
72
+ innerHeight: number;
73
+ }
74
+
75
+ async function clearAll(conn: CdpConnection): Promise<void> {
76
+ // Order doesn't matter; each is independent. Tolerate per-domain absence.
77
+ await conn.send("Emulation.clearDeviceMetricsOverride").catch(() => undefined);
78
+ await conn.send("Emulation.setUserAgentOverride", { userAgent: "" }).catch(() => undefined);
79
+ await conn.send("Emulation.setCPUThrottlingRate", { rate: 1 }).catch(() => undefined);
80
+ await conn.send("Emulation.setEmulatedMedia", { media: "", features: [] }).catch(() => undefined);
81
+ await conn.send("Network.enable").catch(() => undefined);
82
+ await conn
83
+ .send("Network.emulateNetworkConditions", {
84
+ offline: false,
85
+ latency: 0,
86
+ downloadThroughput: -1,
87
+ uploadThroughput: -1,
88
+ })
89
+ .catch(() => undefined);
90
+ }
91
+
92
+ /**
93
+ * emulate — apply any subset of device-metrics / UA / CPU / media / network
94
+ * overrides in a single call, or clear them all with { clearOverrides:true }.
95
+ */
96
+ export async function emulate(args: EmulateArgs = {}): Promise<EmulateResult> {
97
+ const { target } = args;
98
+ return withPage(target, async (conn: CdpConnection, t: Target) => {
99
+ if (args.clearOverrides) {
100
+ await clearAll(conn);
101
+ return {
102
+ target: { id: t.id, url: t.url, title: t.title },
103
+ applied: [],
104
+ cleared: true,
105
+ };
106
+ }
107
+
108
+ const applied: string[] = [];
109
+
110
+ if (args.width != null || args.height != null) {
111
+ if (args.width == null || args.height == null) {
112
+ throw new CdpError("emulate device metrics require both width and height");
113
+ }
114
+ await conn.send("Emulation.setDeviceMetricsOverride", {
115
+ width: args.width,
116
+ height: args.height,
117
+ deviceScaleFactor: args.deviceScaleFactor ?? 0,
118
+ mobile: args.mobile ?? false,
119
+ });
120
+ applied.push("deviceMetrics");
121
+ }
122
+
123
+ if (args.userAgent != null) {
124
+ await conn.send("Emulation.setUserAgentOverride", { userAgent: args.userAgent });
125
+ applied.push("userAgent");
126
+ }
127
+
128
+ if (args.cpuThrottlingRate != null) {
129
+ if (args.cpuThrottlingRate < 1) throw new CdpError("cpuThrottlingRate must be >= 1");
130
+ await conn.send("Emulation.setCPUThrottlingRate", { rate: args.cpuThrottlingRate });
131
+ applied.push("cpuThrottlingRate");
132
+ }
133
+
134
+ if (args.media != null || args.mediaFeatures != null) {
135
+ await conn.send("Emulation.setEmulatedMedia", {
136
+ media: args.media ?? "",
137
+ features: args.mediaFeatures ?? [],
138
+ });
139
+ applied.push("emulatedMedia");
140
+ }
141
+
142
+ if (args.networkConditions != null) {
143
+ const n = args.networkConditions;
144
+ await conn.send("Network.enable").catch(() => undefined);
145
+ await conn.send("Network.emulateNetworkConditions", {
146
+ offline: n.offline ?? false,
147
+ latency: n.latency ?? 0,
148
+ downloadThroughput: n.downloadThroughput ?? -1,
149
+ uploadThroughput: n.uploadThroughput ?? -1,
150
+ ...(n.connectionType ? { connectionType: n.connectionType } : {}),
151
+ });
152
+ applied.push("networkConditions");
153
+ }
154
+
155
+ if (applied.length === 0) {
156
+ throw new CdpError("emulate: no overrides specified (pass clearOverrides:true to reset)");
157
+ }
158
+
159
+ return { target: { id: t.id, url: t.url, title: t.title }, applied };
160
+ });
161
+ }
162
+
163
+ /**
164
+ * resize_page — set the page's device-metrics width/height. Verifies by reading
165
+ * back window.innerWidth/innerHeight after the override is applied.
166
+ */
167
+ export async function resizePage(args: ResizeArgs): Promise<ResizeResult> {
168
+ const { target, width, height } = args;
169
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
170
+ throw new CdpError("resize_page requires positive numeric width and height");
171
+ }
172
+ return withPage(target, async (conn: CdpConnection, t: Target) => {
173
+ await conn.send("Emulation.setDeviceMetricsOverride", {
174
+ width,
175
+ height,
176
+ deviceScaleFactor: args.deviceScaleFactor ?? 0,
177
+ mobile: args.mobile ?? false,
178
+ });
179
+ await conn.send("Runtime.enable").catch(() => undefined);
180
+ const { result } = await conn.send<{ result: { value?: { w: number; h: number } } }>("Runtime.evaluate", {
181
+ expression: "({ w: window.innerWidth, h: window.innerHeight })",
182
+ returnByValue: true,
183
+ });
184
+ const v = result.value;
185
+ return {
186
+ target: { id: t.id, url: t.url, title: t.title },
187
+ width,
188
+ height,
189
+ innerWidth: v?.w ?? width,
190
+ innerHeight: v?.h ?? height,
191
+ };
192
+ });
193
+ }
194
+
195
+ /*
196
+ * CDP methods/domains used:
197
+ * - Emulation.setDeviceMetricsOverride (resize + emulate metrics)
198
+ * - Emulation.clearDeviceMetricsOverride (clearOverrides)
199
+ * - Emulation.setUserAgentOverride
200
+ * - Emulation.setCPUThrottlingRate
201
+ * - Emulation.setEmulatedMedia (media type + features)
202
+ * - Network.enable / Network.emulateNetworkConditions
203
+ * - Runtime.enable / Runtime.evaluate (resize_page read-back verification)
204
+ * Parity gaps vs chrome-devtools-mcp emulate/resize_page:
205
+ * - Override lifetime: toolkit is stateless (connection closes after each call). Device-metrics
206
+ * overrides persist on the target until cleared/navigated; UA/CPU/media/network overrides are
207
+ * session-scoped and reset when the per-call connection closes. The MCP keeps one long-lived
208
+ * session so its non-metrics overrides persist across subsequent MCP calls; ours do not.
209
+ * - No named device-descriptor presets (e.g. "iPhone 15"); caller passes raw metrics + UA.
210
+ * - No touch-emulation / screen-orientation override (Emulation.setTouchEmulationEnabled,
211
+ * setDeviceMetricsOverride.screenOrientation) — out of the assigned arg surface.
212
+ */
@@ -0,0 +1,155 @@
1
+ /**
2
+ * evaluate_script — run arbitrary JavaScript in a page's main context over raw
3
+ * CDP and return the evaluated value.
4
+ *
5
+ * Two modes, selected by whether `args` is supplied:
6
+ * - No args: `Runtime.evaluate` of the raw expression (returnByValue,
7
+ * awaitPromise). The natural fast path; "1+2" -> 3.
8
+ * - With args: the expression is treated as a JS function (e.g. an arrow
9
+ * `(a,b)=>a+b` or a `function(a,b){...}`). We resolve the page's global
10
+ * object, then `Runtime.callFunctionOn` that global with the expression as
11
+ * the function declaration and the supplied `args` passed positionally.
12
+ * This mirrors chrome-devtools-mcp's evaluate_script(function, args) shape.
13
+ *
14
+ * Either way, a `exceptionDetails` in the response is surfaced as a thrown
15
+ * `CdpError` carrying the exception/description text.
16
+ */
17
+ import { CdpError, withPage } from "../client.ts";
18
+ import type { CdpConnection } from "../client.ts";
19
+ import type { TargetSelector } from "../types.ts";
20
+
21
+ /** A CDP RemoteObject as returned by Runtime.evaluate / Runtime.callFunctionOn. */
22
+ interface RemoteObject {
23
+ type: string;
24
+ subtype?: string;
25
+ className?: string;
26
+ value?: unknown;
27
+ unserializableValue?: string;
28
+ description?: string;
29
+ objectId?: string;
30
+ }
31
+
32
+ /** Shape of a CDP exceptionDetails payload (subset we report on). */
33
+ interface ExceptionDetails {
34
+ text?: string;
35
+ exception?: RemoteObject;
36
+ lineNumber?: number;
37
+ columnNumber?: number;
38
+ }
39
+
40
+ interface EvalResponse {
41
+ result: RemoteObject;
42
+ exceptionDetails?: ExceptionDetails;
43
+ }
44
+
45
+ export interface EvaluateScriptArgs {
46
+ /** Page selector; defaults to the active page. */
47
+ target?: TargetSelector;
48
+ /**
49
+ * JavaScript to run. When `args` is omitted this is evaluated as an
50
+ * expression. When `args` is provided this must be a function literal
51
+ * (arrow or classic) whose parameters receive the supplied `args`.
52
+ */
53
+ expression: string;
54
+ /** Await the result if it is a Promise (default true). */
55
+ awaitPromise?: boolean;
56
+ /** Positional arguments to pass to the expression (treats it as a function). */
57
+ args?: unknown[];
58
+ }
59
+
60
+ /** Build a readable message out of a CDP exceptionDetails block. */
61
+ function exceptionMessage(details: ExceptionDetails): string {
62
+ const ex = details.exception;
63
+ const fromObject =
64
+ ex?.description ??
65
+ (typeof ex?.value === "string" ? ex.value : ex?.value !== undefined ? JSON.stringify(ex.value) : undefined);
66
+ return fromObject ?? details.text ?? "evaluation threw";
67
+ }
68
+
69
+ /**
70
+ * Extract the JS value from a RemoteObject. With returnByValue, `value` holds
71
+ * the serialized value; fall back to unserializableValue (Infinity/NaN/-0) and
72
+ * finally the description for non-serializable objects.
73
+ */
74
+ function unwrap(obj: RemoteObject): unknown {
75
+ if (Object.prototype.hasOwnProperty.call(obj, "value")) return obj.value;
76
+ if (obj.unserializableValue !== undefined) return obj.unserializableValue;
77
+ if (obj.type === "undefined") return undefined;
78
+ return obj.description ?? null;
79
+ }
80
+
81
+ async function evaluateExpression(conn: CdpConnection, expression: string, awaitPromise: boolean): Promise<unknown> {
82
+ const res = await conn.send<EvalResponse>("Runtime.evaluate", {
83
+ expression,
84
+ returnByValue: true,
85
+ awaitPromise,
86
+ // Allow `await` at the top of expressions and surface a usable result.
87
+ replMode: false,
88
+ // Don't let a thrown value escape as a raw protocol error; we read
89
+ // exceptionDetails ourselves below.
90
+ silent: false,
91
+ });
92
+ if (res.exceptionDetails) throw new CdpError(exceptionMessage(res.exceptionDetails));
93
+ return unwrap(res.result);
94
+ }
95
+
96
+ async function evaluateFunction(
97
+ conn: CdpConnection,
98
+ expression: string,
99
+ awaitPromise: boolean,
100
+ args: unknown[],
101
+ ): Promise<unknown> {
102
+ // Resolve the page's global object to give callFunctionOn an `this`/context.
103
+ const globalObj = await conn.send<{ result: RemoteObject }>("Runtime.evaluate", {
104
+ expression: "globalThis",
105
+ returnByValue: false,
106
+ });
107
+ const objectId = globalObj.result.objectId;
108
+ if (!objectId) throw new CdpError("could not resolve global object for function call");
109
+
110
+ const res = await conn.send<EvalResponse>("Runtime.callFunctionOn", {
111
+ objectId,
112
+ functionDeclaration: expression,
113
+ arguments: args.map((value) => ({ value })),
114
+ returnByValue: true,
115
+ awaitPromise,
116
+ silent: false,
117
+ });
118
+ if (res.exceptionDetails) throw new CdpError(exceptionMessage(res.exceptionDetails));
119
+ return unwrap(res.result);
120
+ }
121
+
122
+ /**
123
+ * Evaluate `expression` in the page and return its value. When `args` is
124
+ * supplied, `expression` is invoked as a function with those positional args.
125
+ */
126
+ export async function evaluateScript(args: EvaluateScriptArgs): Promise<unknown> {
127
+ const { expression } = args;
128
+ if (typeof expression !== "string" || expression.length === 0) {
129
+ throw new CdpError("evaluateScript: 'expression' must be a non-empty string");
130
+ }
131
+ const awaitPromise = args.awaitPromise ?? true;
132
+ const callArgs = args.args;
133
+
134
+ return withPage(args.target, async (conn) => {
135
+ await conn.send("Runtime.enable");
136
+ if (callArgs && callArgs.length > 0) {
137
+ return evaluateFunction(conn, expression, awaitPromise, callArgs);
138
+ }
139
+ return evaluateExpression(conn, expression, awaitPromise);
140
+ });
141
+ }
142
+
143
+ /*
144
+ * CDP methods/domains used:
145
+ * - Runtime.enable
146
+ * - Runtime.evaluate (no-args expression path; returnByValue + awaitPromise)
147
+ * - Runtime.callFunctionOn (args path: expression treated as a function literal, called on globalThis)
148
+ *
149
+ * Parity gaps vs chrome-devtools-mcp evaluate_script:
150
+ * - MCP injects a typed `page`/element handle into the evaluated function; here `args` are plain
151
+ * JSON-serializable values passed positionally — no live element/page handle is bound.
152
+ * - Non-serializable return values (DOM nodes, functions, circular objects) come back as their CDP
153
+ * `description` string rather than a structured handle, since returnByValue is used.
154
+ * - No isolated-world/contextId selection: evaluation runs in the page's default main-world context.
155
+ */
@@ -0,0 +1,109 @@
1
+ /**
2
+ * heap.ts — `take_heapsnapshot` over raw CDP.
3
+ *
4
+ * Captures a V8 heap snapshot of a page target and writes it as a
5
+ * `.heapsnapshot` file (the JSON format DevTools' Memory panel loads).
6
+ *
7
+ * CDP semantics this module relies on:
8
+ * - `HeapProfiler.takeHeapSnapshot` streams the snapshot back as a sequence
9
+ * of `HeapProfiler.addHeapSnapshotChunk` events. The command's own result
10
+ * does NOT carry the data — it resolves only after the final chunk has been
11
+ * emitted. So we subscribe to the chunk event first, accumulate every
12
+ * chunk's string, then `await` the command; once it resolves we have the
13
+ * complete snapshot and join the chunks into one JSON document.
14
+ */
15
+
16
+ import { mkdir, writeFile } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+ import { withPage } from "../client.ts";
19
+ import type { TargetSelector } from "../types.ts";
20
+
21
+ const ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
22
+
23
+ export interface TakeHeapsnapshotArgs {
24
+ /** Page selector (defaults to the active page). */
25
+ target?: TargetSelector;
26
+ /** Override the output path. When relative, resolved under ARTIFACT_DIR. */
27
+ savePath?: string;
28
+ }
29
+
30
+ export interface TakeHeapsnapshotResult {
31
+ path: string;
32
+ bytes: number;
33
+ /** Number of `addHeapSnapshotChunk` events accumulated. */
34
+ chunks: number;
35
+ target: { id: string; url: string; title: string };
36
+ }
37
+
38
+ /** A short, fs-safe stamp derived from the target id and the current time. */
39
+ function stamp(targetId: string): string {
40
+ const short = targetId.slice(0, 8);
41
+ const iso = new Date().toISOString().replace(/[:.]/g, "-");
42
+ return `${short}-${iso}`;
43
+ }
44
+
45
+ /**
46
+ * Take a V8 heap snapshot of the selected page and persist it as a
47
+ * `.heapsnapshot` file under ARTIFACT_DIR (or `savePath`).
48
+ */
49
+ export async function takeHeapsnapshot(
50
+ args: TakeHeapsnapshotArgs = {},
51
+ ): Promise<TakeHeapsnapshotResult> {
52
+ return withPage(args.target, async (conn, target) => {
53
+ await conn.send("HeapProfiler.enable");
54
+
55
+ // Subscribe BEFORE issuing the command so we don't miss early chunks.
56
+ const parts: string[] = [];
57
+ const off = conn.on("HeapProfiler.addHeapSnapshotChunk", (params) => {
58
+ const chunk = (params as { chunk?: unknown }).chunk;
59
+ if (typeof chunk === "string") parts.push(chunk);
60
+ });
61
+
62
+ try {
63
+ // Resolves only after the final addHeapSnapshotChunk has fired. We grant
64
+ // it a generous timeout: heap snapshots of large pages can take a while.
65
+ await conn.send(
66
+ "HeapProfiler.takeHeapSnapshot",
67
+ { reportProgress: false, captureNumericValue: false },
68
+ { timeoutMs: 120_000 },
69
+ );
70
+ } finally {
71
+ off();
72
+ }
73
+
74
+ const snapshot = parts.join("");
75
+ if (snapshot.length === 0) {
76
+ throw new Error("HeapProfiler.takeHeapSnapshot produced no chunks");
77
+ }
78
+
79
+ await mkdir(ARTIFACT_DIR, { recursive: true });
80
+ const path = args.savePath
81
+ ? args.savePath.startsWith("/")
82
+ ? args.savePath
83
+ : join(ARTIFACT_DIR, args.savePath)
84
+ : join(ARTIFACT_DIR, `take_heapsnapshot-${stamp(target.id)}.heapsnapshot`);
85
+
86
+ await writeFile(path, snapshot, "utf8");
87
+ const bytes = Buffer.byteLength(snapshot, "utf8");
88
+
89
+ return {
90
+ path,
91
+ bytes,
92
+ chunks: parts.length,
93
+ target: { id: target.id, url: target.url, title: target.title },
94
+ };
95
+ });
96
+ }
97
+
98
+ /* ----------------------------------------------------------------------------
99
+ * CDP methods/domains used:
100
+ * - HeapProfiler.enable
101
+ * - HeapProfiler.takeHeapSnapshot (command; resolves after last chunk)
102
+ * - HeapProfiler.addHeapSnapshotChunk (event; accumulated into the file)
103
+ *
104
+ * Parity gaps vs chrome-devtools-mcp `take_heapsnapshot`:
105
+ * - reportProgress is forced false; no incremental progress callbacks surfaced.
106
+ * - Returns {path,bytes,chunks,target} only — does not parse/summarize the
107
+ * snapshot (node counts, retained sizes); the .heapsnapshot file is for the
108
+ * DevTools Memory panel to load, matching the MCP tool's artifact behavior.
109
+ * --------------------------------------------------------------------------*/