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.
- package/CHANGELOG.md +29 -0
- package/CONTRACT.md +116 -0
- package/LICENSE +21 -0
- package/README.md +255 -0
- package/dist/cli.js +2108 -0
- package/dist/index.js +2019 -0
- package/dist/mcp.js +3056 -0
- package/package.json +82 -0
- package/src/cli.ts +145 -0
- package/src/client.ts +255 -0
- package/src/index.ts +128 -0
- package/src/manifest.ts +1030 -0
- package/src/mcp.ts +61 -0
- package/src/tools/console.ts +199 -0
- package/src/tools/dialogs.ts +236 -0
- package/src/tools/emulation.ts +212 -0
- package/src/tools/evaluate.ts +155 -0
- package/src/tools/heap.ts +109 -0
- package/src/tools/input.ts +399 -0
- package/src/tools/lighthouse.ts +285 -0
- package/src/tools/navigation.ts +202 -0
- package/src/tools/network.ts +275 -0
- package/src/tools/network_mock.ts +367 -0
- package/src/tools/pages.ts +159 -0
- package/src/tools/performance.ts +492 -0
- package/src/tools/recorder.ts +265 -0
- package/src/tools/screenshot.ts +180 -0
- package/src/tools/snapshot.ts +206 -0
- package/src/types.ts +63 -0
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* performance.ts — performance tracing + insight analysis over raw CDP.
|
|
3
|
+
*
|
|
4
|
+
* NOTE ON THE INSIGHT ANALYZER: `performanceAnalyzeInsight` is a CDP-native
|
|
5
|
+
* APPROXIMATION of chrome-devtools-mcp's insight analyzer. The MCP tool runs the
|
|
6
|
+
* DevTools/Lighthouse "Trace Engine" insight pipeline (a large TS engine that
|
|
7
|
+
* models the full RAIL/Core-Web-Vitals heuristics). We do NOT vendor that
|
|
8
|
+
* engine (zero-runtime-deps rule). Instead we parse the raw Chrome trace event
|
|
9
|
+
* stream ourselves and derive the headline metrics — navigationStart, FCP, LCP,
|
|
10
|
+
* long tasks (>50ms), layout shifts / CLS, and total blocking time — directly
|
|
11
|
+
* from the trace events. Numbers will be close to but not byte-identical with
|
|
12
|
+
* the MCP/DevTools panel, which applies extra normalization (e.g. main-thread
|
|
13
|
+
* attribution, frame-scoped LCP, soft-navigation handling).
|
|
14
|
+
*
|
|
15
|
+
* NOTE ON TRACING STATE: CDP `Tracing.start`/`Tracing.end` are bound to a single
|
|
16
|
+
* live session — the recording lives on the WebSocket connection, and
|
|
17
|
+
* `Tracing.dataCollected` events stream back on that same connection. There is
|
|
18
|
+
* no way to "re-attach" to an in-flight trace from a fresh process, because the
|
|
19
|
+
* buffer is owned by the connection that started it. Cross-process persistence
|
|
20
|
+
* of a live recording is therefore INFEASIBLE.
|
|
21
|
+
*
|
|
22
|
+
* What we do instead:
|
|
23
|
+
* - `performanceStartTrace` / `performanceStopTrace` are implemented as a
|
|
24
|
+
* best-effort in-process pair: start opens a persistent page connection,
|
|
25
|
+
* parks it in a module-level registry keyed by targetId, and writes a state
|
|
26
|
+
* file recording which target is being traced. stop looks up the live
|
|
27
|
+
* connection in the registry, ends the trace, drains data, writes JSON.
|
|
28
|
+
* This works when start and stop run in the SAME process (e.g. the MCP
|
|
29
|
+
* dispatcher staying resident, or a single CLI invocation that does both).
|
|
30
|
+
* If stop runs in a DIFFERENT process than start, the connection is gone and
|
|
31
|
+
* stop throws a clear CdpError telling the caller to use `performanceTrace`.
|
|
32
|
+
* - `performanceTrace` is the PRIMARY, robust one-shot: it starts tracing,
|
|
33
|
+
* optionally reloads/navigates the page, waits `durationMs`, ends the trace,
|
|
34
|
+
* writes the trace JSON, and returns `{ path, bytes }`. This is the smoke
|
|
35
|
+
* path and the recommended entry point.
|
|
36
|
+
*/
|
|
37
|
+
import type { Target, TargetSelector } from "../types.ts";
|
|
38
|
+
import type { CdpConnection } from "../client.ts";
|
|
39
|
+
import { CdpError, openPage } from "../client.ts";
|
|
40
|
+
import { mkdir, readFile, writeFile, stat, unlink } from "node:fs/promises";
|
|
41
|
+
import { join } from "node:path";
|
|
42
|
+
|
|
43
|
+
const ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
44
|
+
const STATE_DIR = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
|
|
45
|
+
|
|
46
|
+
/** Default trace categories: timeline + user timing + loading + the disabled-by-default timeline track that carries LCP/LayoutShift/RunTask. */
|
|
47
|
+
const DEFAULT_CATEGORIES = [
|
|
48
|
+
"devtools.timeline",
|
|
49
|
+
"blink.user_timing",
|
|
50
|
+
"loading",
|
|
51
|
+
"disabled-by-default-devtools.timeline",
|
|
52
|
+
"disabled-by-default-devtools.timeline.frame",
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/** A single Chrome trace event (the shape we read from Tracing.dataCollected / saved JSON). */
|
|
56
|
+
interface TraceEvent {
|
|
57
|
+
cat?: string;
|
|
58
|
+
name?: string;
|
|
59
|
+
ph?: string;
|
|
60
|
+
ts?: number; // microseconds
|
|
61
|
+
dur?: number; // microseconds
|
|
62
|
+
pid?: number;
|
|
63
|
+
tid?: number;
|
|
64
|
+
args?: Record<string, unknown> & {
|
|
65
|
+
data?: Record<string, unknown> & {
|
|
66
|
+
score?: number;
|
|
67
|
+
had_recent_input?: boolean;
|
|
68
|
+
candidateIndex?: number;
|
|
69
|
+
size?: number;
|
|
70
|
+
navigationId?: string;
|
|
71
|
+
};
|
|
72
|
+
frame?: string;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A traced-page registry entry held in-process between start and stop. */
|
|
77
|
+
interface LiveTrace {
|
|
78
|
+
conn: CdpConnection;
|
|
79
|
+
target: Target;
|
|
80
|
+
events: TraceEvent[];
|
|
81
|
+
startedAt: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* In-process registry of live traces keyed by targetId. Survives only within a
|
|
86
|
+
* single Node/Bun process (see header note on cross-process infeasibility).
|
|
87
|
+
*/
|
|
88
|
+
const liveTraces = new Map<string, LiveTrace>();
|
|
89
|
+
|
|
90
|
+
interface TraceStateFile {
|
|
91
|
+
targetId: string;
|
|
92
|
+
url: string;
|
|
93
|
+
startedAt: number;
|
|
94
|
+
pid: number;
|
|
95
|
+
categories: string[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function stamp(): string {
|
|
99
|
+
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function shortId(id: string): string {
|
|
103
|
+
return id.slice(0, 8);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function stateFilePath(): string {
|
|
107
|
+
return join(STATE_DIR, "trace-state.json");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Subscribe to Tracing.dataCollected and buffer every event into `sink`. */
|
|
111
|
+
function bufferTraceData(conn: CdpConnection, sink: TraceEvent[]): () => void {
|
|
112
|
+
return conn.on("Tracing.dataCollected", (params: Record<string, unknown>) => {
|
|
113
|
+
const value = (params as { value?: TraceEvent[] }).value;
|
|
114
|
+
if (Array.isArray(value)) sink.push(...value);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Begin a trace on an already-open page connection. */
|
|
119
|
+
async function beginTrace(conn: CdpConnection, categories: string[]): Promise<void> {
|
|
120
|
+
await conn.send("Tracing.start", {
|
|
121
|
+
traceConfig: { includedCategories: categories },
|
|
122
|
+
transferMode: "ReportEvents",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* End a trace, drain all buffered Tracing.dataCollected, and resolve once
|
|
128
|
+
* Tracing.tracingComplete fires (or the timeout elapses). Returns the events.
|
|
129
|
+
*/
|
|
130
|
+
async function endTrace(conn: CdpConnection, sink: TraceEvent[], timeoutMs = 30_000): Promise<TraceEvent[]> {
|
|
131
|
+
const complete = conn.waitFor("Tracing.tracingComplete", undefined, timeoutMs);
|
|
132
|
+
await conn.send("Tracing.end");
|
|
133
|
+
await complete;
|
|
134
|
+
return sink;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Write a trace event array to ARTIFACT_DIR as a DevTools-loadable JSON file. */
|
|
138
|
+
async function writeTrace(events: TraceEvent[], targetId: string): Promise<{ path: string; bytes: number }> {
|
|
139
|
+
await mkdir(ARTIFACT_DIR, { recursive: true });
|
|
140
|
+
const path = join(ARTIFACT_DIR, `trace-${shortId(targetId)}-${stamp()}.json`);
|
|
141
|
+
// DevTools accepts either a bare array or { traceEvents: [...] }; use the wrapped
|
|
142
|
+
// form so the file is also openable in the Performance panel.
|
|
143
|
+
const body = JSON.stringify({ traceEvents: events, metadata: { source: "cdp-toolkit" } });
|
|
144
|
+
await writeFile(path, body, "utf8");
|
|
145
|
+
const { size } = await stat(path);
|
|
146
|
+
return { path, bytes: size };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* ------------------------------------------------------------------ */
|
|
150
|
+
/* performanceStartTrace / performanceStopTrace (in-process pair) */
|
|
151
|
+
/* ------------------------------------------------------------------ */
|
|
152
|
+
|
|
153
|
+
export interface PerformanceStartTraceArgs {
|
|
154
|
+
target?: TargetSelector;
|
|
155
|
+
categories?: string[];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Start a performance trace on the target page and keep the recording
|
|
160
|
+
* connection alive in-process. Pairs with `performanceStopTrace` WITHIN THE
|
|
161
|
+
* SAME PROCESS. For a robust cross-call trace, prefer `performanceTrace`.
|
|
162
|
+
*/
|
|
163
|
+
export async function performanceStartTrace(
|
|
164
|
+
args: PerformanceStartTraceArgs = {},
|
|
165
|
+
): Promise<{ target: { id: string; url: string; title: string }; categories: string[]; note: string }> {
|
|
166
|
+
const categories = args.categories?.length ? args.categories : DEFAULT_CATEGORIES;
|
|
167
|
+
const { conn, target } = await openPage(args.target);
|
|
168
|
+
|
|
169
|
+
if (liveTraces.has(target.id)) {
|
|
170
|
+
conn.close();
|
|
171
|
+
throw new CdpError(`a trace is already in progress for target ${shortId(target.id)}; call performanceStopTrace first`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const events: TraceEvent[] = [];
|
|
175
|
+
bufferTraceData(conn, events);
|
|
176
|
+
try {
|
|
177
|
+
await beginTrace(conn, categories);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
conn.close();
|
|
180
|
+
throw err;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
liveTraces.set(target.id, { conn, target, events, startedAt: Date.now() });
|
|
184
|
+
|
|
185
|
+
await mkdir(STATE_DIR, { recursive: true });
|
|
186
|
+
const state: TraceStateFile = {
|
|
187
|
+
targetId: target.id,
|
|
188
|
+
url: target.url,
|
|
189
|
+
startedAt: Date.now(),
|
|
190
|
+
pid: process.pid,
|
|
191
|
+
categories,
|
|
192
|
+
};
|
|
193
|
+
await writeFile(stateFilePath(), JSON.stringify(state), "utf8");
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
target: { id: target.id, url: target.url, title: target.title },
|
|
197
|
+
categories,
|
|
198
|
+
note: "Trace recording. Call performanceStopTrace in THIS process to finalize. Cross-process? use performanceTrace instead.",
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface PerformanceStopTraceArgs {
|
|
203
|
+
target?: TargetSelector;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Stop the in-process trace started by `performanceStartTrace`, drain the
|
|
208
|
+
* buffered events, write the trace JSON, and clear state. Throws if no live
|
|
209
|
+
* trace exists in this process (e.g. start ran in a different process).
|
|
210
|
+
*/
|
|
211
|
+
export async function performanceStopTrace(
|
|
212
|
+
args: PerformanceStopTraceArgs = {},
|
|
213
|
+
): Promise<{ path: string; bytes: number; events: number; metrics: TraceMetrics }> {
|
|
214
|
+
// CDP tracing is browser-GLOBAL: a second Tracing.start throws "Tracing has
|
|
215
|
+
// already been started", so at most one trace is ever live in this process.
|
|
216
|
+
// There is therefore nothing to disambiguate by target — take the single live
|
|
217
|
+
// trace if present. (`args.target` is accepted for API symmetry with start.)
|
|
218
|
+
void args.target;
|
|
219
|
+
const entry: LiveTrace | undefined = liveTraces.size === 1 ? [...liveTraces.values()][0] : undefined;
|
|
220
|
+
|
|
221
|
+
if (!entry) {
|
|
222
|
+
let hint = "";
|
|
223
|
+
try {
|
|
224
|
+
const raw = await readFile(stateFilePath(), "utf8");
|
|
225
|
+
const state = JSON.parse(raw) as TraceStateFile;
|
|
226
|
+
hint =
|
|
227
|
+
state.pid === process.pid
|
|
228
|
+
? ` (state file lists target ${shortId(state.targetId)} but its live connection is gone)`
|
|
229
|
+
: ` (trace was started by a DIFFERENT process pid=${state.pid}; live recordings cannot cross process boundaries)`;
|
|
230
|
+
} catch {
|
|
231
|
+
/* no state file */
|
|
232
|
+
}
|
|
233
|
+
throw new CdpError(
|
|
234
|
+
`no in-process trace to stop${hint}. Use performanceTrace for a robust single-call trace.`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const { conn, target, events } = entry;
|
|
239
|
+
try {
|
|
240
|
+
await endTrace(conn, events);
|
|
241
|
+
} finally {
|
|
242
|
+
conn.close();
|
|
243
|
+
liveTraces.delete(target.id);
|
|
244
|
+
await unlink(stateFilePath()).catch(() => {});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const written = await writeTrace(events, target.id);
|
|
248
|
+
const metrics = analyzeEvents(events);
|
|
249
|
+
return { ...written, events: events.length, metrics };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/* ------------------------------------------------------------------ */
|
|
253
|
+
/* performanceTrace (PRIMARY one-shot — the smoke path) */
|
|
254
|
+
/* ------------------------------------------------------------------ */
|
|
255
|
+
|
|
256
|
+
export interface PerformanceTraceArgs {
|
|
257
|
+
target?: TargetSelector;
|
|
258
|
+
/** how long to record after (optionally) reloading, ms. Default 3000. */
|
|
259
|
+
durationMs?: number;
|
|
260
|
+
/** reload the page after starting the trace (captures full nav timing). */
|
|
261
|
+
reload?: boolean;
|
|
262
|
+
/** navigate to this URL after starting the trace (alternative to reload). */
|
|
263
|
+
navigateTo?: string;
|
|
264
|
+
categories?: string[];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* One-shot trace: start → (reload/navigate) → wait → stop → write JSON. Holds a
|
|
269
|
+
* single connection open for the whole window, so it is immune to the
|
|
270
|
+
* cross-process limitation of start/stop. This is the recommended entry point.
|
|
271
|
+
*/
|
|
272
|
+
export async function performanceTrace(
|
|
273
|
+
args: PerformanceTraceArgs = {},
|
|
274
|
+
): Promise<{ path: string; bytes: number; events: number; metrics: TraceMetrics; target: { id: string; url: string } }> {
|
|
275
|
+
const durationMs = args.durationMs ?? 3_000;
|
|
276
|
+
const categories = args.categories?.length ? args.categories : DEFAULT_CATEGORIES;
|
|
277
|
+
const { conn, target } = await openPage(args.target);
|
|
278
|
+
const events: TraceEvent[] = [];
|
|
279
|
+
bufferTraceData(conn, events);
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
await conn.send("Page.enable");
|
|
283
|
+
await beginTrace(conn, categories);
|
|
284
|
+
|
|
285
|
+
if (args.navigateTo) {
|
|
286
|
+
const loaded = conn.waitFor("Page.loadEventFired", undefined, durationMs + 5_000);
|
|
287
|
+
await conn.send("Page.navigate", { url: args.navigateTo });
|
|
288
|
+
await loaded.catch(() => {});
|
|
289
|
+
} else if (args.reload) {
|
|
290
|
+
const loaded = conn.waitFor("Page.loadEventFired", undefined, durationMs + 5_000);
|
|
291
|
+
await conn.send("Page.reload", { ignoreCache: false });
|
|
292
|
+
await loaded.catch(() => {});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
await new Promise((r) => setTimeout(r, durationMs));
|
|
296
|
+
await endTrace(conn, events);
|
|
297
|
+
} finally {
|
|
298
|
+
conn.close();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const written = await writeTrace(events, target.id);
|
|
302
|
+
const metrics = analyzeEvents(events);
|
|
303
|
+
return { ...written, events: events.length, metrics, target: { id: target.id, url: target.url } };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/* ------------------------------------------------------------------ */
|
|
307
|
+
/* performanceAnalyzeInsight (CDP-native approximation) */
|
|
308
|
+
/* ------------------------------------------------------------------ */
|
|
309
|
+
|
|
310
|
+
export interface PerformanceMetrics {
|
|
311
|
+
/** navigationStart timestamp in trace-clock microseconds, if found. */
|
|
312
|
+
navigationStartUs?: number;
|
|
313
|
+
/** First Contentful Paint, ms after navigationStart. */
|
|
314
|
+
fcpMs?: number;
|
|
315
|
+
/** Largest Contentful Paint, ms after navigationStart. */
|
|
316
|
+
lcpMs?: number;
|
|
317
|
+
/** DOMContentLoaded, ms after navigationStart. */
|
|
318
|
+
domContentLoadedMs?: number;
|
|
319
|
+
/** load event, ms after navigationStart. */
|
|
320
|
+
loadMs?: number;
|
|
321
|
+
/** Cumulative Layout Shift score (sum of layout-shift scores w/o recent input). */
|
|
322
|
+
cls?: number;
|
|
323
|
+
/** Total Blocking Time: sum over long tasks of (dur_ms - 50), ms. */
|
|
324
|
+
totalBlockingTimeMs: number;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export interface TraceMetrics {
|
|
328
|
+
metrics: PerformanceMetrics;
|
|
329
|
+
longTasks: number;
|
|
330
|
+
longTasksTotalMs: number;
|
|
331
|
+
layoutShifts: number;
|
|
332
|
+
eventCount: number;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Parse a raw Chrome trace event array into headline metrics. Pure function so
|
|
337
|
+
* both the live tools and `performanceAnalyzeInsight` share one code path.
|
|
338
|
+
*/
|
|
339
|
+
export function analyzeEvents(events: TraceEvent[]): TraceMetrics {
|
|
340
|
+
let navigationStartUs: number | undefined;
|
|
341
|
+
let fcpUs: number | undefined;
|
|
342
|
+
let lcpUs: number | undefined;
|
|
343
|
+
let dclUs: number | undefined;
|
|
344
|
+
let loadUs: number | undefined;
|
|
345
|
+
let cls = 0;
|
|
346
|
+
let layoutShifts = 0;
|
|
347
|
+
let longTasks = 0;
|
|
348
|
+
let longTasksTotalMs = 0;
|
|
349
|
+
let totalBlockingTimeMs = 0;
|
|
350
|
+
|
|
351
|
+
for (const e of events) {
|
|
352
|
+
const name = e.name;
|
|
353
|
+
if (!name) continue;
|
|
354
|
+
|
|
355
|
+
// navigationStart: prefer the earliest marker we see.
|
|
356
|
+
if (name === "navigationStart") {
|
|
357
|
+
if (typeof e.ts === "number" && (navigationStartUs === undefined || e.ts < navigationStartUs)) {
|
|
358
|
+
navigationStartUs = e.ts;
|
|
359
|
+
}
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// First Contentful Paint marker.
|
|
364
|
+
if ((name === "firstContentfulPaint" || name === "FirstContentfulPaint") && typeof e.ts === "number") {
|
|
365
|
+
if (fcpUs === undefined || e.ts < fcpUs) fcpUs = e.ts;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// LCP candidate — the LAST candidate before user input is the real LCP.
|
|
370
|
+
if (name === "largestContentfulPaint::Candidate" && typeof e.ts === "number") {
|
|
371
|
+
// candidates are monotonic; keep the latest timestamp seen.
|
|
372
|
+
if (lcpUs === undefined || e.ts > lcpUs) lcpUs = e.ts;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if ((name === "domContentLoadedEventEnd" || name === "DOMContentLoaded") && typeof e.ts === "number") {
|
|
377
|
+
if (dclUs === undefined || e.ts > dclUs) dclUs = e.ts;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if ((name === "loadEventEnd" || name === "MarkLoad") && typeof e.ts === "number") {
|
|
382
|
+
if (loadUs === undefined || e.ts > loadUs) loadUs = e.ts;
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Layout shifts — sum scores that did NOT follow recent user input.
|
|
387
|
+
if (name === "LayoutShift") {
|
|
388
|
+
const data = e.args?.data;
|
|
389
|
+
const hadRecentInput = data?.had_recent_input === true;
|
|
390
|
+
const score = typeof data?.score === "number" ? data.score : undefined;
|
|
391
|
+
if (!hadRecentInput && typeof score === "number") {
|
|
392
|
+
cls += score;
|
|
393
|
+
layoutShifts += 1;
|
|
394
|
+
}
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Long tasks: complete ('X') RunTask events with dur > 50ms contribute to
|
|
399
|
+
// TBT (the portion of each task beyond 50ms).
|
|
400
|
+
if (name === "RunTask" && typeof e.dur === "number") {
|
|
401
|
+
const durMs = e.dur / 1000;
|
|
402
|
+
if (durMs > 50) {
|
|
403
|
+
longTasks += 1;
|
|
404
|
+
longTasksTotalMs += durMs;
|
|
405
|
+
totalBlockingTimeMs += durMs - 50;
|
|
406
|
+
}
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const relMs = (us: number | undefined): number | undefined =>
|
|
412
|
+
us !== undefined && navigationStartUs !== undefined ? (us - navigationStartUs) / 1000 : undefined;
|
|
413
|
+
|
|
414
|
+
const metrics: PerformanceMetrics = {
|
|
415
|
+
navigationStartUs,
|
|
416
|
+
fcpMs: relMs(fcpUs),
|
|
417
|
+
lcpMs: relMs(lcpUs),
|
|
418
|
+
domContentLoadedMs: relMs(dclUs),
|
|
419
|
+
loadMs: relMs(loadUs),
|
|
420
|
+
cls: layoutShifts > 0 ? Number(cls.toFixed(4)) : 0,
|
|
421
|
+
totalBlockingTimeMs: Number(totalBlockingTimeMs.toFixed(2)),
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
metrics,
|
|
426
|
+
longTasks,
|
|
427
|
+
longTasksTotalMs: Number(longTasksTotalMs.toFixed(2)),
|
|
428
|
+
layoutShifts,
|
|
429
|
+
eventCount: events.length,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export interface PerformanceAnalyzeInsightArgs {
|
|
434
|
+
/** Path to a trace JSON written by performanceTrace/performanceStopTrace. */
|
|
435
|
+
tracePath?: string;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* CDP-native approximation of the MCP insight analyzer. Reads a trace JSON file
|
|
440
|
+
* (bare array OR { traceEvents: [...] }) and returns structured headline
|
|
441
|
+
* metrics. If `tracePath` is omitted, throws — there is no implicit "latest
|
|
442
|
+
* trace" magic; callers pass the path returned by a trace tool.
|
|
443
|
+
*/
|
|
444
|
+
export async function performanceAnalyzeInsight(
|
|
445
|
+
args: PerformanceAnalyzeInsightArgs = {},
|
|
446
|
+
): Promise<TraceMetrics & { tracePath: string }> {
|
|
447
|
+
if (!args.tracePath) {
|
|
448
|
+
throw new CdpError("performanceAnalyzeInsight requires { tracePath } pointing at a trace JSON file");
|
|
449
|
+
}
|
|
450
|
+
let raw: string;
|
|
451
|
+
try {
|
|
452
|
+
raw = await readFile(args.tracePath, "utf8");
|
|
453
|
+
} catch (err) {
|
|
454
|
+
throw new CdpError(`cannot read trace file '${args.tracePath}': ${(err as Error).message}`);
|
|
455
|
+
}
|
|
456
|
+
let parsed: unknown;
|
|
457
|
+
try {
|
|
458
|
+
parsed = JSON.parse(raw);
|
|
459
|
+
} catch (err) {
|
|
460
|
+
throw new CdpError(`trace file '${args.tracePath}' is not valid JSON: ${(err as Error).message}`);
|
|
461
|
+
}
|
|
462
|
+
const events: TraceEvent[] = Array.isArray(parsed)
|
|
463
|
+
? (parsed as TraceEvent[])
|
|
464
|
+
: Array.isArray((parsed as { traceEvents?: unknown }).traceEvents)
|
|
465
|
+
? ((parsed as { traceEvents: TraceEvent[] }).traceEvents)
|
|
466
|
+
: (() => {
|
|
467
|
+
throw new CdpError(`trace file '${args.tracePath}' has no traceEvents array`);
|
|
468
|
+
})();
|
|
469
|
+
|
|
470
|
+
return { ...analyzeEvents(events), tracePath: args.tracePath };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/*
|
|
474
|
+
* CDP methods/domains used:
|
|
475
|
+
* - Tracing.start (traceConfig.includedCategories, transferMode:ReportEvents)
|
|
476
|
+
* - Tracing.dataCollected (event — buffered into the event array)
|
|
477
|
+
* - Tracing.end
|
|
478
|
+
* - Tracing.tracingComplete (event — awaited to know recording is flushed)
|
|
479
|
+
* - Page.enable / Page.navigate / Page.reload / Page.loadEventFired (one-shot reload path)
|
|
480
|
+
*
|
|
481
|
+
* Parity gaps vs chrome-devtools-mcp performance tools:
|
|
482
|
+
* - performance_start_trace/performance_stop_trace work only WITHIN one process
|
|
483
|
+
* (a live CDP trace buffer is bound to its connection and cannot be re-attached
|
|
484
|
+
* cross-process); performanceTrace is the robust single-call alternative.
|
|
485
|
+
* - performance_analyze_insight is a CDP-native APPROXIMATION: it parses raw trace
|
|
486
|
+
* events rather than running DevTools' Trace Engine, so FCP/LCP/CLS/TBT are close
|
|
487
|
+
* but not byte-identical (no main-thread attribution, frame-scoped LCP, or
|
|
488
|
+
* soft-navigation handling; no named "insights"/recommendations).
|
|
489
|
+
* - We do not auto-discover the "latest" trace; analyze requires an explicit tracePath.
|
|
490
|
+
* - No CPU/network throttle presets applied during the trace (the MCP tool can stage
|
|
491
|
+
* a Lighthouse-style throttled environment); emulation is left to emulation.ts.
|
|
492
|
+
*/
|