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,399 @@
1
+ /**
2
+ * Interaction tools: click, hover, drag, fill, fill_form, type_text,
3
+ * press_key, upload_file.
4
+ *
5
+ * Every element-targeting tool accepts exactly one of `{ uid }` (a
6
+ * backendDOMNodeId from take_snapshot) or `{ selector }` (a CSS selector).
7
+ * uid resolves via resolveUid (DOM.resolveNode); selector resolves via
8
+ * Runtime.evaluate(document.querySelector). Both end up as a JS object handle
9
+ * (objectId), then Runtime.callFunctionOn scrolls it into view + reads its
10
+ * bounding-rect center, and we dispatch real input events via
11
+ * Input.dispatchMouseEvent / Input.dispatchKeyEvent. This drives the page the
12
+ * same way a user would, so framework event handlers fire correctly.
13
+ */
14
+ import type { CdpConnection } from "../client.ts";
15
+ import { CdpError, withPage } from "../client.ts";
16
+ import type { TargetSelector, Uid } from "../types.ts";
17
+ import { resolveUid } from "./snapshot.ts";
18
+
19
+ /* --------------------------------- targeting --------------------------------- */
20
+
21
+ interface ElementTarget {
22
+ uid?: Uid;
23
+ selector?: string;
24
+ }
25
+
26
+ /** Resolve {uid|selector} (exactly one) to a JS object handle on the page. */
27
+ async function resolveElement(conn: CdpConnection, t: ElementTarget): Promise<{ objectId: string }> {
28
+ const hasUid = t.uid !== undefined && t.uid !== null;
29
+ const hasSelector = typeof t.selector === "string" && t.selector.length > 0;
30
+ if (hasUid === hasSelector) {
31
+ throw new CdpError("provide exactly one of { uid } or { selector }");
32
+ }
33
+ if (hasUid) {
34
+ return resolveUid(conn, t.uid as Uid);
35
+ }
36
+ // selector path
37
+ const { result, exceptionDetails } = await conn.send<{
38
+ result: { objectId?: string; subtype?: string };
39
+ exceptionDetails?: { text?: string };
40
+ }>("Runtime.evaluate", {
41
+ expression: `document.querySelector(${JSON.stringify(t.selector)})`,
42
+ returnByValue: false,
43
+ });
44
+ if (exceptionDetails) {
45
+ throw new CdpError(`selector '${t.selector}' evaluation failed: ${exceptionDetails.text ?? "error"}`);
46
+ }
47
+ if (!result.objectId || result.subtype === "null") {
48
+ throw new CdpError(`selector '${t.selector}' matched no element`);
49
+ }
50
+ return { objectId: result.objectId };
51
+ }
52
+
53
+ interface Point {
54
+ x: number;
55
+ y: number;
56
+ }
57
+
58
+ /** Scroll the element into view and return its viewport-space center point. */
59
+ async function centerOf(conn: CdpConnection, objectId: string): Promise<Point> {
60
+ const { result, exceptionDetails } = await conn.send<{
61
+ result: { value?: Point | null };
62
+ exceptionDetails?: { text?: string };
63
+ }>("Runtime.callFunctionOn", {
64
+ objectId,
65
+ functionDeclaration:
66
+ "function(){this.scrollIntoView({block:'center',inline:'center'});const r=this.getBoundingClientRect();if(r.width===0&&r.height===0)return null;return {x:r.left+r.width/2,y:r.top+r.height/2};}",
67
+ returnByValue: true,
68
+ });
69
+ if (exceptionDetails) {
70
+ throw new CdpError(`could not measure element: ${exceptionDetails.text ?? "error"}`);
71
+ }
72
+ if (!result.value) {
73
+ throw new CdpError("element has zero size / is not visible; cannot compute a click point");
74
+ }
75
+ return result.value;
76
+ }
77
+
78
+ /** Focus an element via its object handle (used by fill / type_text). */
79
+ async function focusElement(conn: CdpConnection, objectId: string): Promise<void> {
80
+ await conn.send("Runtime.callFunctionOn", {
81
+ objectId,
82
+ functionDeclaration: "function(){this.focus&&this.focus();}",
83
+ returnByValue: true,
84
+ });
85
+ }
86
+
87
+ /* ----------------------------------- click ----------------------------------- */
88
+
89
+ export interface ClickArgs extends ElementTarget {
90
+ target?: TargetSelector;
91
+ /** "left" (default) | "right" | "middle". */
92
+ button?: "left" | "right" | "middle";
93
+ /** 1 = single (default), 2 = double-click. */
94
+ clickCount?: number;
95
+ }
96
+
97
+ export async function click(args: ClickArgs): Promise<{ clicked: true; x: number; y: number }> {
98
+ const button = args.button ?? "left";
99
+ const clickCount = args.clickCount ?? 1;
100
+ return withPage(args.target, async (conn) => {
101
+ const { objectId } = await resolveElement(conn, args);
102
+ const { x, y } = await centerOf(conn, objectId);
103
+ await conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
104
+ await conn.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount });
105
+ await conn.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, clickCount });
106
+ return { clicked: true as const, x, y };
107
+ });
108
+ }
109
+
110
+ /* ----------------------------------- hover ----------------------------------- */
111
+
112
+ export interface HoverArgs extends ElementTarget {
113
+ target?: TargetSelector;
114
+ }
115
+
116
+ export async function hover(args: HoverArgs): Promise<{ hovered: true; x: number; y: number }> {
117
+ return withPage(args.target, async (conn) => {
118
+ const { objectId } = await resolveElement(conn, args);
119
+ const { x, y } = await centerOf(conn, objectId);
120
+ await conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
121
+ return { hovered: true as const, x, y };
122
+ });
123
+ }
124
+
125
+ /* ----------------------------------- drag ------------------------------------ */
126
+
127
+ export interface DragArgs {
128
+ target?: TargetSelector;
129
+ /** Source element (exactly one of from.uid / from.selector). */
130
+ from: ElementTarget;
131
+ /** Destination element (exactly one of to.uid / to.selector). */
132
+ to: ElementTarget;
133
+ }
134
+
135
+ export async function drag(args: DragArgs): Promise<{ dragged: true; from: Point; to: Point }> {
136
+ if (!args.from || !args.to) throw new CdpError("drag requires { from } and { to }");
137
+ return withPage(args.target, async (conn) => {
138
+ const { objectId: srcId } = await resolveElement(conn, args.from);
139
+ const from = await centerOf(conn, srcId);
140
+ const { objectId: dstId } = await resolveElement(conn, args.to);
141
+ const to = await centerOf(conn, dstId);
142
+
143
+ await conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: from.x, y: from.y });
144
+ await conn.send("Input.dispatchMouseEvent", {
145
+ type: "mousePressed",
146
+ x: from.x,
147
+ y: from.y,
148
+ button: "left",
149
+ clickCount: 1,
150
+ });
151
+ // Intermediate move so dragstart/dragover fire on the page.
152
+ await conn.send("Input.dispatchMouseEvent", {
153
+ type: "mouseMoved",
154
+ x: (from.x + to.x) / 2,
155
+ y: (from.y + to.y) / 2,
156
+ button: "left",
157
+ });
158
+ await conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: to.x, y: to.y, button: "left" });
159
+ await conn.send("Input.dispatchMouseEvent", {
160
+ type: "mouseReleased",
161
+ x: to.x,
162
+ y: to.y,
163
+ button: "left",
164
+ clickCount: 1,
165
+ });
166
+ return { dragged: true as const, from, to };
167
+ });
168
+ }
169
+
170
+ /* ------------------------------ fill / type_text ------------------------------ */
171
+
172
+ /** Set an element's value by clearing it then inserting text (focus + insertText). */
173
+ async function setValue(conn: CdpConnection, objectId: string, value: string): Promise<void> {
174
+ await focusElement(conn, objectId);
175
+ // Clear existing content so fill is an overwrite, then insert the new text.
176
+ await conn.send("Runtime.callFunctionOn", {
177
+ objectId,
178
+ functionDeclaration:
179
+ "function(){if('value'in this){this.value='';this.dispatchEvent(new Event('input',{bubbles:true}));}else if(this.isContentEditable){this.textContent='';}}",
180
+ returnByValue: true,
181
+ });
182
+ if (value.length) {
183
+ await conn.send("Input.insertText", { text: value });
184
+ }
185
+ }
186
+
187
+ export interface FillArgs extends ElementTarget {
188
+ target?: TargetSelector;
189
+ value: string;
190
+ }
191
+
192
+ export async function fill(args: FillArgs): Promise<{ filled: true; value: string }> {
193
+ if (typeof args.value !== "string") throw new CdpError("fill requires a string { value }");
194
+ return withPage(args.target, async (conn) => {
195
+ const { objectId } = await resolveElement(conn, args);
196
+ await setValue(conn, objectId, args.value);
197
+ return { filled: true as const, value: args.value };
198
+ });
199
+ }
200
+
201
+ export interface TypeTextArgs extends ElementTarget {
202
+ target?: TargetSelector;
203
+ text: string;
204
+ }
205
+
206
+ /**
207
+ * type_text — focus then insert text. Unlike fill it does NOT clear first, so
208
+ * it appends to whatever is already focused/present (closest to "typing").
209
+ */
210
+ export async function typeText(args: TypeTextArgs): Promise<{ typed: true; text: string }> {
211
+ if (typeof args.text !== "string") throw new CdpError("type_text requires a string { text }");
212
+ return withPage(args.target, async (conn) => {
213
+ const { objectId } = await resolveElement(conn, args);
214
+ await focusElement(conn, objectId);
215
+ if (args.text.length) await conn.send("Input.insertText", { text: args.text });
216
+ return { typed: true as const, text: args.text };
217
+ });
218
+ }
219
+
220
+ /* ---------------------------------- fill_form --------------------------------- */
221
+
222
+ export interface FillFormField extends ElementTarget {
223
+ value: string;
224
+ }
225
+
226
+ export interface FillFormArgs {
227
+ target?: TargetSelector;
228
+ fields: FillFormField[];
229
+ }
230
+
231
+ export async function fillForm(args: FillFormArgs): Promise<{ filled: number; fields: number }> {
232
+ if (!Array.isArray(args.fields) || args.fields.length === 0) {
233
+ throw new CdpError("fill_form requires a non-empty { fields } array");
234
+ }
235
+ return withPage(args.target, async (conn) => {
236
+ let filled = 0;
237
+ for (const field of args.fields) {
238
+ if (typeof field.value !== "string") {
239
+ throw new CdpError("each fill_form field requires a string value");
240
+ }
241
+ const { objectId } = await resolveElement(conn, field);
242
+ await setValue(conn, objectId, field.value);
243
+ filled++;
244
+ }
245
+ return { filled, fields: args.fields.length };
246
+ });
247
+ }
248
+
249
+ /* ---------------------------------- press_key --------------------------------- */
250
+
251
+ interface KeySpec {
252
+ key: string;
253
+ code: string;
254
+ keyCode: number;
255
+ /** text to commit (printable keys / Enter newline) */
256
+ text?: string;
257
+ }
258
+
259
+ /** Named-key table: maps friendly names to CDP dispatchKeyEvent fields. */
260
+ const NAMED_KEYS: Record<string, KeySpec> = {
261
+ enter: { key: "Enter", code: "Enter", keyCode: 13, text: "\r" },
262
+ return: { key: "Enter", code: "Enter", keyCode: 13, text: "\r" },
263
+ tab: { key: "Tab", code: "Tab", keyCode: 9 },
264
+ escape: { key: "Escape", code: "Escape", keyCode: 27 },
265
+ esc: { key: "Escape", code: "Escape", keyCode: 27 },
266
+ backspace: { key: "Backspace", code: "Backspace", keyCode: 8 },
267
+ delete: { key: "Delete", code: "Delete", keyCode: 46 },
268
+ space: { key: " ", code: "Space", keyCode: 32, text: " " },
269
+ arrowup: { key: "ArrowUp", code: "ArrowUp", keyCode: 38 },
270
+ arrowdown: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 },
271
+ arrowleft: { key: "ArrowLeft", code: "ArrowLeft", keyCode: 37 },
272
+ arrowright: { key: "ArrowRight", code: "ArrowRight", keyCode: 39 },
273
+ up: { key: "ArrowUp", code: "ArrowUp", keyCode: 38 },
274
+ down: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 },
275
+ left: { key: "ArrowLeft", code: "ArrowLeft", keyCode: 37 },
276
+ right: { key: "ArrowRight", code: "ArrowRight", keyCode: 39 },
277
+ home: { key: "Home", code: "Home", keyCode: 36 },
278
+ end: { key: "End", code: "End", keyCode: 35 },
279
+ pageup: { key: "PageUp", code: "PageUp", keyCode: 33 },
280
+ pagedown: { key: "PageDown", code: "PageDown", keyCode: 34 },
281
+ };
282
+
283
+ const MODIFIER_BITS: Record<string, number> = {
284
+ alt: 1,
285
+ control: 2,
286
+ ctrl: 2,
287
+ meta: 4,
288
+ cmd: 4,
289
+ command: 4,
290
+ shift: 8,
291
+ };
292
+
293
+ /** Resolve a key name (named or single printable char) to a KeySpec. */
294
+ function resolveKey(key: string): KeySpec {
295
+ const named = NAMED_KEYS[key.toLowerCase()];
296
+ if (named) return named;
297
+ if (key.length === 1) {
298
+ const upper = key.toUpperCase();
299
+ const isLetter = upper >= "A" && upper <= "Z";
300
+ const isDigit = key >= "0" && key <= "9";
301
+ const code = isLetter ? `Key${upper}` : isDigit ? `Digit${key}` : `Key${upper}`;
302
+ const keyCode = upper.charCodeAt(0);
303
+ return { key, code, keyCode, text: key };
304
+ }
305
+ throw new CdpError(`unknown key '${key}' (use a named key like Enter/Tab/ArrowDown or a single character)`);
306
+ }
307
+
308
+ export interface PressKeyArgs {
309
+ target?: TargetSelector;
310
+ /** Key name: "Enter", "Tab", "a", "ArrowDown", etc. */
311
+ key: string;
312
+ /** Modifier names: "Control"/"Ctrl", "Shift", "Alt", "Meta"/"Cmd". */
313
+ modifiers?: string[];
314
+ }
315
+
316
+ export async function pressKey(args: PressKeyArgs): Promise<{ pressed: string; modifiers: string[] }> {
317
+ if (typeof args.key !== "string" || !args.key.length) {
318
+ throw new CdpError("press_key requires a non-empty { key }");
319
+ }
320
+ const spec = resolveKey(args.key);
321
+ let modBits = 0;
322
+ const mods = args.modifiers ?? [];
323
+ for (const m of mods) {
324
+ const bit = MODIFIER_BITS[m.toLowerCase()];
325
+ if (bit === undefined) throw new CdpError(`unknown modifier '${m}'`);
326
+ modBits |= bit;
327
+ }
328
+ // When a modifier other than Shift is held, suppress text (it's a chord).
329
+ const suppressText = (modBits & ~MODIFIER_BITS.shift!) !== 0;
330
+
331
+ return withPage(args.target, async (conn) => {
332
+ const down: Record<string, unknown> = {
333
+ type: "keyDown",
334
+ key: spec.key,
335
+ code: spec.code,
336
+ windowsVirtualKeyCode: spec.keyCode,
337
+ nativeVirtualKeyCode: spec.keyCode,
338
+ modifiers: modBits,
339
+ };
340
+ if (spec.text && !suppressText) {
341
+ // A printable char dispatches as keyDown-with-text, which commits the
342
+ // character (verified: x/y/space produce value "xy "). `type` is already
343
+ // "keyDown" from construction above.
344
+ down.text = spec.text;
345
+ }
346
+ await conn.send("Input.dispatchKeyEvent", down);
347
+ await conn.send("Input.dispatchKeyEvent", {
348
+ type: "keyUp",
349
+ key: spec.key,
350
+ code: spec.code,
351
+ windowsVirtualKeyCode: spec.keyCode,
352
+ nativeVirtualKeyCode: spec.keyCode,
353
+ modifiers: modBits,
354
+ });
355
+ return { pressed: spec.key, modifiers: mods };
356
+ });
357
+ }
358
+
359
+ /* --------------------------------- upload_file -------------------------------- */
360
+
361
+ export interface UploadFileArgs extends ElementTarget {
362
+ target?: TargetSelector;
363
+ /** Absolute path(s) to the file(s) to attach to the <input type=file>. */
364
+ files: string | string[];
365
+ }
366
+
367
+ export async function uploadFile(args: UploadFileArgs): Promise<{ uploaded: string[] }> {
368
+ const files = Array.isArray(args.files) ? args.files : [args.files];
369
+ if (!files.length || files.some((f) => typeof f !== "string" || !f.length)) {
370
+ throw new CdpError("upload_file requires { files } as a non-empty path or array of paths");
371
+ }
372
+ return withPage(args.target, async (conn) => {
373
+ const { objectId } = await resolveElement(conn, args);
374
+ // setFileInputFiles needs a backendNodeId/objectId reference to the <input>.
375
+ await conn.send("DOM.setFileInputFiles", { files, objectId });
376
+ return { uploaded: files };
377
+ });
378
+ }
379
+
380
+ /* ------------------------------------------------------------------------------
381
+ * CDP methods used:
382
+ * DOM.resolveNode (via resolveUid), Runtime.evaluate (selector resolution),
383
+ * Runtime.callFunctionOn (scrollIntoView + rect center, focus, clear value),
384
+ * Input.dispatchMouseEvent (click/hover/drag),
385
+ * Input.insertText (fill/type_text/fill_form),
386
+ * Input.dispatchKeyEvent (press_key, named keys + modifiers),
387
+ * DOM.setFileInputFiles (upload_file).
388
+ * Parity gaps vs chrome-devtools-mcp interaction tools:
389
+ * - press_key supports a curated named-key table + single chars; it is not the
390
+ * full Puppeteer KeyInput enum (function keys F1-F12, numpad, IME, etc.).
391
+ * - fill/type_text use Input.insertText (atomic paste-like commit) rather than
392
+ * per-character keystrokes, so per-char keydown handlers / input masks that
393
+ * depend on individual keystrokes won't see each character.
394
+ * - drag is a synthetic mouse press/move/release; native HTML5 drag-and-drop
395
+ * (DataTransfer) is approximated, matching CDP's mouse-driven model but not
396
+ * guaranteed for every custom DnD library.
397
+ * - no implicit auto-wait/retry: tools resolve and act once. Callers re-snapshot
398
+ * between steps (MCP wraps each action in a Puppeteer locator auto-wait).
399
+ * ---------------------------------------------------------------------------- */
@@ -0,0 +1,285 @@
1
+ /**
2
+ * lighthouse_audit — THE ONLY non-CDP tool in this toolkit.
3
+ *
4
+ * Unlike every other module, this one does NOT speak the Chrome DevTools
5
+ * Protocol over a WebSocket. Instead it shells out to the external Lighthouse
6
+ * CLI (`npx --yes lighthouse ...`) via node:child_process. Lighthouse attaches
7
+ * to the already-running Chrome on the remote-debugging port (9222) through its
8
+ * `--port` flag, drives its own audit, and writes a JSON report. We parse that
9
+ * report and return the per-category scores.
10
+ *
11
+ * Zero npm runtime deps are added: Lighthouse is fetched on-demand by `npx`
12
+ * (the contract's explicit exception for this single module). If npx or
13
+ * Lighthouse is unavailable, we throw a clear CdpError naming the dependency.
14
+ */
15
+ import { spawn } from "node:child_process";
16
+ import { mkdir, readFile, stat } from "node:fs/promises";
17
+ import { CdpError } from "../client.ts";
18
+
19
+ const ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
20
+
21
+ /** Lighthouse port to attach to the already-running Chrome (matches CDP_BASE). */
22
+ function cdpPort(): number {
23
+ const base = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
24
+ const m = base.match(/:(\d+)/);
25
+ return m?.[1] ? Number(m[1]) : 9222;
26
+ }
27
+
28
+ function stamp(): string {
29
+ return new Date(Date.now()).toISOString().replace(/[:.]/g, "-");
30
+ }
31
+
32
+ function shortHost(url: string): string {
33
+ try {
34
+ const u = new URL(url);
35
+ const h = u.hostname || u.protocol.replace(/:$/, "");
36
+ return (h || "page").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 24);
37
+ } catch {
38
+ return "page";
39
+ }
40
+ }
41
+
42
+ /** Result of running a single child process to completion. */
43
+ interface SpawnResult {
44
+ code: number | null;
45
+ signal: NodeJS.Signals | null;
46
+ stdout: string;
47
+ stderr: string;
48
+ spawnError?: Error;
49
+ }
50
+
51
+ function runProcess(
52
+ cmd: string,
53
+ args: string[],
54
+ opts: { timeoutMs: number },
55
+ ): Promise<SpawnResult> {
56
+ return new Promise<SpawnResult>((resolve) => {
57
+ let stdout = "";
58
+ let stderr = "";
59
+ let settled = false;
60
+ const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
61
+
62
+ const timer = setTimeout(() => {
63
+ if (settled) return;
64
+ try {
65
+ child.kill("SIGKILL");
66
+ } catch {
67
+ /* ignore */
68
+ }
69
+ }, opts.timeoutMs);
70
+
71
+ child.stdout.on("data", (d: Buffer) => {
72
+ stdout += d.toString();
73
+ });
74
+ child.stderr.on("data", (d: Buffer) => {
75
+ stderr += d.toString();
76
+ });
77
+ child.on("error", (err: Error) => {
78
+ if (settled) return;
79
+ settled = true;
80
+ clearTimeout(timer);
81
+ resolve({ code: null, signal: null, stdout, stderr, spawnError: err });
82
+ });
83
+ child.on("close", (code, signal) => {
84
+ if (settled) return;
85
+ settled = true;
86
+ clearTimeout(timer);
87
+ resolve({ code, signal, stdout, stderr });
88
+ });
89
+ });
90
+ }
91
+
92
+ /** The shape we read out of a Lighthouse JSON report (only the bits we need). */
93
+ interface LighthouseReport {
94
+ requestedUrl?: string;
95
+ finalUrl?: string;
96
+ finalDisplayedUrl?: string;
97
+ lighthouseVersion?: string;
98
+ fetchTime?: string;
99
+ runtimeError?: { code?: string; message?: string };
100
+ runWarnings?: string[];
101
+ categories?: Record<
102
+ string,
103
+ { id?: string; title?: string; score?: number | null }
104
+ >;
105
+ }
106
+
107
+ export interface LighthouseAuditArgs {
108
+ /** The URL to audit. Required — never points at a user tab implicitly. */
109
+ url: string;
110
+ /**
111
+ * Lighthouse categories to run. Defaults to the full set.
112
+ * e.g. ["performance"], ["performance","accessibility","seo"].
113
+ */
114
+ categories?: string[];
115
+ /** Override the report output path (defaults under ARTIFACT_DIR). */
116
+ savePath?: string;
117
+ /**
118
+ * Form factor: "desktop" (default) or "mobile". Desktop avoids the heavy
119
+ * mobile throttling that makes quick smoke audits slow.
120
+ */
121
+ formFactor?: "desktop" | "mobile";
122
+ /** Overall budget for the lighthouse process (ms). Default 120000. */
123
+ timeoutMs?: number;
124
+ }
125
+
126
+ export interface LighthouseAuditResult {
127
+ path: string;
128
+ bytes: number;
129
+ url: string;
130
+ finalUrl?: string;
131
+ lighthouseVersion?: string;
132
+ fetchTime?: string;
133
+ categories: Record<string, number | null>;
134
+ warnings?: string[];
135
+ }
136
+
137
+ /**
138
+ * Run a Lighthouse audit against `url` using the already-running Chrome on the
139
+ * CDP port. Returns the report path and per-category scores (0..1, or null when
140
+ * a category was not run / not scored).
141
+ */
142
+ export async function lighthouseAudit(
143
+ args: LighthouseAuditArgs,
144
+ ): Promise<LighthouseAuditResult> {
145
+ if (!args || typeof args.url !== "string" || args.url.trim() === "") {
146
+ throw new CdpError("lighthouse_audit requires a non-empty 'url'");
147
+ }
148
+ const url = args.url.trim();
149
+ const timeoutMs = args.timeoutMs ?? 120_000;
150
+ const port = cdpPort();
151
+
152
+ await mkdir(ARTIFACT_DIR, { recursive: true });
153
+ const outPath =
154
+ args.savePath ?? `${ARTIFACT_DIR}/lighthouse-${shortHost(url)}-${stamp()}.json`;
155
+
156
+ const formFactor = args.formFactor ?? "desktop";
157
+ const lhArgs: string[] = [
158
+ "--yes",
159
+ "lighthouse",
160
+ url,
161
+ `--port=${port}`,
162
+ "--output=json",
163
+ `--output-path=${outPath}`,
164
+ "--quiet",
165
+ // Attach to the existing Chrome rather than letting Lighthouse launch its
166
+ // own; --headless=new keeps it off-screen. Lighthouse honors --port to
167
+ // reuse the running browser on that remote-debugging port.
168
+ '--chrome-flags=--headless=new',
169
+ ];
170
+ if (formFactor === "desktop") {
171
+ lhArgs.push("--preset=desktop");
172
+ }
173
+ if (Array.isArray(args.categories) && args.categories.length > 0) {
174
+ lhArgs.push(`--only-categories=${args.categories.join(",")}`);
175
+ }
176
+
177
+ const res = await runProcess("npx", lhArgs, { timeoutMs });
178
+
179
+ if (res.spawnError) {
180
+ const code = (res.spawnError as NodeJS.ErrnoException).code;
181
+ if (code === "ENOENT") {
182
+ throw new CdpError(
183
+ "lighthouse_audit requires 'npx' (Node.js) on PATH to fetch and run the Lighthouse CLI; 'npx' was not found.",
184
+ );
185
+ }
186
+ throw new CdpError(
187
+ `lighthouse_audit failed to spawn npx/lighthouse: ${res.spawnError.message}`,
188
+ );
189
+ }
190
+
191
+ // Try to read the report regardless of exit code: Lighthouse can exit
192
+ // non-zero on audit warnings while still having written a valid report.
193
+ let raw: string | undefined;
194
+ try {
195
+ raw = await readFile(outPath, "utf8");
196
+ } catch {
197
+ raw = undefined;
198
+ }
199
+
200
+ if (raw === undefined) {
201
+ if (res.signal === "SIGKILL") {
202
+ throw new CdpError(
203
+ `lighthouse_audit timed out after ${timeoutMs}ms (no report written). stderr: ${truncate(res.stderr)}`,
204
+ );
205
+ }
206
+ const hint = /not found|could not determine executable|npm ERR|E404|ERR_MODULE/i.test(
207
+ res.stderr,
208
+ )
209
+ ? " The Lighthouse CLI could not be fetched/installed via npx — ensure network access and that 'lighthouse' is installable."
210
+ : "";
211
+ throw new CdpError(
212
+ `lighthouse_audit produced no report (exit code ${res.code}).${hint} stderr: ${truncate(res.stderr)}`,
213
+ );
214
+ }
215
+
216
+ let report: LighthouseReport;
217
+ try {
218
+ report = JSON.parse(raw) as LighthouseReport;
219
+ } catch (e) {
220
+ throw new CdpError(
221
+ `lighthouse_audit wrote a report that is not valid JSON (${(e as Error).message})`,
222
+ );
223
+ }
224
+
225
+ if (report.runtimeError?.code && report.runtimeError.code !== "NO_ERROR") {
226
+ throw new CdpError(
227
+ `lighthouse_audit runtime error [${report.runtimeError.code}]: ${report.runtimeError.message ?? "unknown"}`,
228
+ );
229
+ }
230
+
231
+ const categories: Record<string, number | null> = {};
232
+ for (const [key, cat] of Object.entries(report.categories ?? {})) {
233
+ categories[key] = cat?.score ?? null;
234
+ }
235
+ if (Object.keys(categories).length === 0) {
236
+ throw new CdpError(
237
+ `lighthouse_audit report contained no category scores (exit code ${res.code}). stderr: ${truncate(res.stderr)}`,
238
+ );
239
+ }
240
+
241
+ const { size } = await stat(outPath);
242
+
243
+ return {
244
+ path: outPath,
245
+ bytes: size,
246
+ url,
247
+ finalUrl: report.finalDisplayedUrl ?? report.finalUrl,
248
+ lighthouseVersion: report.lighthouseVersion,
249
+ fetchTime: report.fetchTime,
250
+ categories,
251
+ warnings:
252
+ Array.isArray(report.runWarnings) && report.runWarnings.length > 0
253
+ ? report.runWarnings
254
+ : undefined,
255
+ };
256
+ }
257
+
258
+ function truncate(s: string, max = 600): string {
259
+ const t = s.trim();
260
+ return t.length > max ? `${t.slice(0, max)}…` : t;
261
+ }
262
+
263
+ /*
264
+ * ---------------------------------------------------------------------------
265
+ * NON-CDP MODULE. This is the toolkit's sole tool that does not speak raw CDP.
266
+ *
267
+ * External dependency (subprocess, fetched on demand by npx — not an npm dep):
268
+ * - `npx --yes lighthouse <url> --port=<cdpPort> --output=json
269
+ * --output-path=<artifact> --quiet --preset=desktop
270
+ * [--only-categories=...] --chrome-flags=--headless=new`
271
+ * Lighthouse attaches to the already-running Chrome via its --port flag and
272
+ * runs the audit; we parse the emitted JSON report.
273
+ *
274
+ * CDP methods used directly: NONE (Lighthouse drives CDP internally).
275
+ *
276
+ * Parity gaps vs the chrome-devtools-mcp `lighthouse_audit` tool:
277
+ * - Defaults to the desktop preset (the MCP/Puppeteer integration may default
278
+ * to mobile); pass formFactor:"mobile" to match a mobile run.
279
+ * - Returns numeric category scores only; does not surface per-audit
280
+ * opportunities/diagnostics (the full report is on disk at `path`).
281
+ * - No programmatic Puppeteer page reuse: Lighthouse opens its own about:blank
282
+ * tab in the attached Chrome for the run rather than auditing a live tab,
283
+ * which is intentional (never drives a real user tab).
284
+ * ---------------------------------------------------------------------------
285
+ */