chrome-devtools-mcp 0.2.7 → 0.3.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.
Files changed (40) hide show
  1. package/README.md +10 -2
  2. package/build/node_modules/chrome-devtools-frontend/front_end/core/host/GdpClient.js +18 -3
  3. package/build/node_modules/chrome-devtools-frontend/front_end/core/host/UserMetrics.js +3 -2
  4. package/build/node_modules/chrome-devtools-frontend/front_end/core/i18n/i18n.js +24 -0
  5. package/build/node_modules/chrome-devtools-frontend/front_end/core/sdk/CSSMatchedStyles.js +5 -1
  6. package/build/node_modules/chrome-devtools-frontend/front_end/core/sdk/EnhancedTracesParser.js +4 -5
  7. package/build/node_modules/chrome-devtools-frontend/front_end/core/sdk/NetworkManager.js +1 -0
  8. package/build/node_modules/chrome-devtools-frontend/front_end/core/sdk/NetworkRequest.js +8 -0
  9. package/build/node_modules/chrome-devtools-frontend/front_end/core/sdk/TargetManager.js +4 -0
  10. package/build/node_modules/chrome-devtools-frontend/front_end/generated/InspectorBackendCommands.js +10 -7
  11. package/build/node_modules/chrome-devtools-frontend/front_end/generated/SupportedCSSProperties.js +40 -11
  12. package/build/node_modules/chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceInsightFormatter.js +95 -283
  13. package/build/node_modules/chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceTraceFormatter.js +256 -22
  14. package/build/node_modules/chrome-devtools-frontend/front_end/models/ai_assistance/performance/AICallTree.js +12 -6
  15. package/build/node_modules/chrome-devtools-frontend/front_end/models/ai_assistance/performance/AIContext.js +64 -7
  16. package/build/node_modules/chrome-devtools-frontend/front_end/models/trace/EventsSerializer.js +3 -3
  17. package/build/node_modules/chrome-devtools-frontend/front_end/models/trace/handlers/UserInteractionsHandler.js +91 -63
  18. package/build/node_modules/chrome-devtools-frontend/front_end/models/trace/handlers/UserTimingsHandler.js +1 -1
  19. package/build/node_modules/chrome-devtools-frontend/front_end/models/trace/helpers/Timing.js +1 -1
  20. package/build/node_modules/chrome-devtools-frontend/front_end/models/trace/helpers/Trace.js +98 -36
  21. package/build/node_modules/chrome-devtools-frontend/front_end/models/trace/types/TraceEvents.js +9 -0
  22. package/build/src/McpContext.js +8 -2
  23. package/build/src/McpResponse.js +30 -3
  24. package/build/src/browser.js +2 -2
  25. package/build/src/cli.js +82 -0
  26. package/build/src/formatters/consoleFormatter.js +3 -1
  27. package/build/src/index.js +1 -1
  28. package/build/src/main.js +11 -84
  29. package/build/src/tools/ToolDefinition.js +1 -0
  30. package/build/src/tools/emulation.js +2 -2
  31. package/build/src/tools/input.js +8 -8
  32. package/build/src/tools/network.js +20 -4
  33. package/build/src/tools/pages.js +12 -2
  34. package/build/src/tools/performance.js +2 -2
  35. package/build/src/tools/screenshot.js +1 -1
  36. package/build/src/tools/script.js +4 -7
  37. package/build/src/tools/snapshot.js +2 -2
  38. package/build/src/trace-processing/parse.js +5 -6
  39. package/build/src/utils/pagination.js +49 -0
  40. package/package.json +9 -6
@@ -1,6 +1,7 @@
1
1
  // Copyright 2022 The Chromium Authors
2
2
  // Use of this source code is governed by a BSD-style license that can be
3
3
  // found in the LICENSE file.
4
+ import * as Platform from '../../../core/platform/platform.js';
4
5
  import * as Helpers from '../helpers/helpers.js';
5
6
  import * as Types from '../types/types.js';
6
7
  import { data as metaHandlerData } from './MetaHandler.js';
@@ -14,14 +15,14 @@ const INP_MEDIUM_TIMING = Helpers.Timing.milliToMicro(Types.Timing.Milli(500));
14
15
  let longestInteractionEvent = null;
15
16
  let interactionEvents = [];
16
17
  let interactionEventsWithNoNesting = [];
17
- let eventTimingEndEventsById = new Map();
18
18
  let eventTimingStartEventsForInteractions = [];
19
+ let eventTimingEndEventsForInteractions = [];
19
20
  export function reset() {
20
21
  beginCommitCompositorFrameEvents = [];
21
22
  parseMetaViewportEvents = [];
22
23
  interactionEvents = [];
23
24
  eventTimingStartEventsForInteractions = [];
24
- eventTimingEndEventsById = new Map();
25
+ eventTimingEndEventsForInteractions = [];
25
26
  interactionEventsWithNoNesting = [];
26
27
  longestInteractionEvent = null;
27
28
  }
@@ -39,7 +40,7 @@ export function handleEvent(event) {
39
40
  }
40
41
  if (Types.Events.isEventTimingEnd(event)) {
41
42
  // Store the end event; for each start event that is an interaction, we need the matching end event to calculate the duration correctly.
42
- eventTimingEndEventsById.set(event.id, event);
43
+ eventTimingEndEventsForInteractions.push(event);
43
44
  }
44
45
  // From this point on we want to find events that represent interactions.
45
46
  // These events are always start events - those are the ones that contain all
@@ -111,8 +112,17 @@ export function categoryOfInteraction(interaction) {
111
112
  * =======B=[keyup]=====
112
113
  * ====C=[pointerdown]=
113
114
  * =D=[pointerup]=
115
+ *
116
+ * Additionally, this method will also maximise the processing duration of the
117
+ * events that we keep as non-nested. We want to make sure we give an accurate
118
+ * representation of main thread activity, so if we keep an event + hide its
119
+ * nested children, we set the top level event's processing start &
120
+ * processing end to be the earliest processing start & the latest processing
121
+ * end of its children. This ensures we report a more accurate main thread
122
+ * activity time which is important as we want developers to focus on fixing
123
+ * this.
114
124
  **/
115
- export function removeNestedInteractions(interactions) {
125
+ export function removeNestedInteractionsAndSetProcessingTime(interactions) {
116
126
  /**
117
127
  * Because we nest events only that are in the same category, we store the
118
128
  * longest event for a given end time by category.
@@ -189,68 +199,86 @@ function writeSyntheticTimespans(event) {
189
199
  }
190
200
  export async function finalize() {
191
201
  const { navigationsByFrameId } = metaHandlerData();
192
- // For each interaction start event, find the async end event by the ID, and then create the Synthetic Interaction event.
193
- for (const interactionStartEvent of eventTimingStartEventsForInteractions) {
194
- const endEvent = eventTimingEndEventsById.get(interactionStartEvent.id);
195
- if (!endEvent) {
196
- // If we cannot find an end event, bail and drop this event.
197
- continue;
198
- }
199
- const { type, interactionId, timeStamp, processingStart, processingEnd } = interactionStartEvent.args.data;
200
- if (!type || !interactionId || !timeStamp || !processingStart || !processingEnd) {
201
- // A valid interaction event that we care about has to have a type (e.g. pointerdown, keyup).
202
- // We also need to ensure it has an interactionId and various timings. There are edge cases where these aren't included in the trace event.
203
- continue;
202
+ const beginAndEndEvents = Platform.ArrayUtilities.mergeOrdered(eventTimingStartEventsForInteractions, eventTimingEndEventsForInteractions, Helpers.Trace.eventTimeComparator);
203
+ // Pair up the begin & end events and create synthetic user timing events.
204
+ const beginEventById = new Map();
205
+ for (const event of beginAndEndEvents) {
206
+ if (Types.Events.isEventTimingStart(event)) {
207
+ const forId = beginEventById.get(event.id) ?? [];
208
+ forId.push(event);
209
+ beginEventById.set(event.id, forId);
204
210
  }
205
- // In the future we will add microsecond timestamps to the trace events…
206
- // (See https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/timing/window_performance.cc;l=900-901;drc=b503c262e425eae59ced4a80d59d176ed07152c7 )
207
- // …but until then we can use the millisecond precision values that are in
208
- // the trace event. To adjust them to be relative to the event.ts and the
209
- // trace timestamps, for both processingStart and processingEnd we subtract
210
- // the event timestamp (NOT event.ts, but the timeStamp millisecond value
211
- // emitted in args.data), and then add that value to the event.ts. This
212
- // will give us a processingStart and processingEnd time in microseconds
213
- // that is relative to event.ts, and can be used when drawing boxes.
214
- // There is some inaccuracy here as we are converting milliseconds to microseconds, but it is good enough until the backend emits more accurate numbers.
215
- const processingStartRelativeToTraceTime = Types.Timing.Micro(Helpers.Timing.milliToMicro(processingStart) - Helpers.Timing.milliToMicro(timeStamp) +
216
- interactionStartEvent.ts);
217
- const processingEndRelativeToTraceTime = Types.Timing.Micro((Helpers.Timing.milliToMicro(processingEnd) - Helpers.Timing.milliToMicro(timeStamp)) +
218
- interactionStartEvent.ts);
219
- // Ultimate frameId fallback only needed for TSC, see comments in the type.
220
- const frameId = interactionStartEvent.args.frame ?? interactionStartEvent.args.data.frame ?? '';
221
- const navigation = Helpers.Trace.getNavigationForTraceEvent(interactionStartEvent, frameId, navigationsByFrameId);
222
- const navigationId = navigation?.args.data?.navigationId;
223
- const interactionEvent = Helpers.SyntheticEvents.SyntheticEventsManager.registerSyntheticEvent({
224
- // Use the start event to define the common fields.
225
- rawSourceEvent: interactionStartEvent,
226
- cat: interactionStartEvent.cat,
227
- name: interactionStartEvent.name,
228
- pid: interactionStartEvent.pid,
229
- tid: interactionStartEvent.tid,
230
- ph: interactionStartEvent.ph,
231
- processingStart: processingStartRelativeToTraceTime,
232
- processingEnd: processingEndRelativeToTraceTime,
233
- // These will be set in writeSyntheticTimespans()
234
- inputDelay: Types.Timing.Micro(-1),
235
- mainThreadHandling: Types.Timing.Micro(-1),
236
- presentationDelay: Types.Timing.Micro(-1),
237
- args: {
238
- data: {
239
- beginEvent: interactionStartEvent,
240
- endEvent,
241
- frame: frameId,
242
- navigationId,
211
+ else if (Types.Events.isEventTimingEnd(event)) {
212
+ const beginEvents = beginEventById.get(event.id) ?? [];
213
+ const beginEvent = beginEvents.pop();
214
+ if (!beginEvent) {
215
+ continue;
216
+ }
217
+ const { type, interactionId, timeStamp, processingStart, processingEnd } = beginEvent.args.data;
218
+ if (!type || !interactionId || !timeStamp || !processingStart || !processingEnd) {
219
+ // A valid interaction event that we care about has to have a type (e.g. pointerdown, keyup).
220
+ // We also need to ensure it has an interactionId and various timings. There are edge cases where these aren't included in the trace event.
221
+ continue;
222
+ }
223
+ // In the future we will add microsecond timestamps to the trace events…
224
+ // (See https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/timing/window_performance.cc;l=900-901;drc=b503c262e425eae59ced4a80d59d176ed07152c7 )
225
+ // …but until then we can use the millisecond precision values that are in
226
+ // the trace event. To adjust them to be relative to the event.ts and the
227
+ // trace timestamps, for both processingStart and processingEnd we subtract
228
+ // the event timestamp (NOT event.ts, but the timeStamp millisecond value
229
+ // emitted in args.data), and then add that value to the event.ts. This
230
+ // will give us a processingStart and processingEnd time in microseconds
231
+ // that is relative to event.ts, and can be used when drawing boxes.
232
+ // There is some inaccuracy here as we are converting milliseconds to
233
+ // microseconds, but it is good enough until the backend emits more
234
+ // accurate numbers.
235
+ const processingStartRelativeToTraceTime = Types.Timing.Micro(Helpers.Timing.milliToMicro(processingStart) - Helpers.Timing.milliToMicro(timeStamp) + beginEvent.ts);
236
+ const processingEndRelativeToTraceTime = Types.Timing.Micro((Helpers.Timing.milliToMicro(processingEnd) - Helpers.Timing.milliToMicro(timeStamp)) + beginEvent.ts);
237
+ // Ultimate frameId fallback only needed for TSC, see comments in the type.
238
+ const frameId = beginEvent.args.frame ?? beginEvent.args.data.frame ?? '';
239
+ const navigation = Helpers.Trace.getNavigationForTraceEvent(beginEvent, frameId, navigationsByFrameId);
240
+ const navigationId = navigation?.args.data?.navigationId;
241
+ const interactionEvent = Helpers.SyntheticEvents.SyntheticEventsManager.registerSyntheticEvent({
242
+ // Use the start event to define the common fields.
243
+ rawSourceEvent: beginEvent,
244
+ cat: beginEvent.cat,
245
+ name: beginEvent.name,
246
+ pid: beginEvent.pid,
247
+ tid: beginEvent.tid,
248
+ ph: beginEvent.ph,
249
+ processingStart: processingStartRelativeToTraceTime,
250
+ processingEnd: processingEndRelativeToTraceTime,
251
+ // These will be set in writeSyntheticTimespans()
252
+ inputDelay: Types.Timing.Micro(-1),
253
+ mainThreadHandling: Types.Timing.Micro(-1),
254
+ presentationDelay: Types.Timing.Micro(-1),
255
+ args: {
256
+ data: {
257
+ beginEvent,
258
+ endEvent: event,
259
+ frame: frameId,
260
+ navigationId,
261
+ },
243
262
  },
244
- },
245
- ts: interactionStartEvent.ts,
246
- dur: Types.Timing.Micro(endEvent.ts - interactionStartEvent.ts),
247
- type: interactionStartEvent.args.data.type,
248
- interactionId: interactionStartEvent.args.data.interactionId,
249
- });
250
- writeSyntheticTimespans(interactionEvent);
251
- interactionEvents.push(interactionEvent);
263
+ ts: beginEvent.ts,
264
+ dur: Types.Timing.Micro(event.ts - beginEvent.ts),
265
+ type: beginEvent.args.data.type,
266
+ interactionId: beginEvent.args.data.interactionId,
267
+ });
268
+ writeSyntheticTimespans(interactionEvent);
269
+ interactionEvents.push(interactionEvent);
270
+ }
252
271
  }
253
- interactionEventsWithNoNesting.push(...removeNestedInteractions(interactionEvents));
272
+ // Once we gather up all the interactions, we want to remove nested
273
+ // interactions. Interactions can be nested because one user action (e.g. a
274
+ // click) will cause a pointerdown, pointerup and click. But we don't want to
275
+ // fill the interactions track with lots of noise. To fix this, we go through
276
+ // all the events and remove any nested ones so on the timeline we focus the
277
+ // user on the most important events, which we define as the longest one. But
278
+ // this algorithm assumes the events are in ASC order, so we first sort the
279
+ // set of interactions.
280
+ Helpers.Trace.sortTraceEventsInPlace(interactionEvents);
281
+ interactionEventsWithNoNesting.push(...removeNestedInteractionsAndSetProcessingTime(interactionEvents));
254
282
  // Pick the longest interactions from the set that were not nested, as we
255
283
  // know those are the set of the largest interactions.
256
284
  for (const interactionEvent of interactionEventsWithNoNesting) {
@@ -174,8 +174,8 @@ export async function finalize() {
174
174
  }
175
175
  export function data() {
176
176
  return {
177
- performanceMeasures: syntheticEvents.filter(e => e.cat === 'blink.user_timing'),
178
177
  consoleTimings: syntheticEvents.filter(e => e.cat === 'blink.console'),
178
+ performanceMeasures: syntheticEvents.filter(e => e.cat === 'blink.user_timing'),
179
179
  performanceMarks: performanceMarkEvents,
180
180
  timestampEvents,
181
181
  measureTraceByTraceId,
@@ -167,7 +167,7 @@ export function boundsIncludeTimeRange(data) {
167
167
  /** Checks to see if the event is within or overlaps the bounds */
168
168
  export function eventIsInBounds(event, bounds) {
169
169
  const startTime = event.ts;
170
- return startTime <= bounds.max && bounds.min <= (startTime + (event.dur ?? 0));
170
+ return startTime <= bounds.max && bounds.min < (startTime + (event.dur ?? 0));
171
171
  }
172
172
  export function timestampIsInBounds(bounds, timestamp) {
173
173
  return timestamp >= bounds.min && timestamp <= bounds.max;
@@ -242,52 +242,109 @@ export function makeProfileCall(node, profileId, sampleIndex, ts, pid, tid) {
242
242
  };
243
243
  }
244
244
  /**
245
- * Matches beginning events with PairableAsyncEnd and PairableAsyncInstant (ASYNC_NESTABLE_INSTANT)
246
- * if provided, though currently only coming from Animations. Traces may contain multiple instant events so we need to
247
- * account for that.
245
+ * Matches beginning events with PairableAsyncEnd and PairableAsyncInstant
246
+ * if provided. Traces may contain multiple instant events so we need to
247
+ * account for that. Additionally we have seen cases where we might only have a
248
+ * begin event & instant event(s), with no end event. So we account for that
249
+ * situation also.
248
250
  *
249
- * @returns Map of the animation's ID to it's matching events.
251
+ * You might also like to read the models/trace/README.md which has some
252
+ * documentation on trace IDs. This is important as Perfetto will reuse trace
253
+ * IDs when emitting events (if they do not overlap). This means it's not as
254
+ * simple as grouping events by IDs. Instead, we group begin & instant events
255
+ * by ID as we find them. When we find end events, we then pop any matching
256
+ * begin/instant events off the stack and group those. That way, if we meet the
257
+ * same ID later on it doesn't cause us collisions.
258
+ *
259
+ * @returns An array of all the matched event groups, along with their ID. Note
260
+ * that two event groups can have the same ID if they were non-overlapping
261
+ * events. You cannot rely on ID being unique across a trace. The returned set
262
+ * of groups are NOT SORTED in any order.
250
263
  */
251
- export function matchEvents(unpairedEvents) {
264
+ function matchEvents(unpairedEvents) {
265
+ sortTraceEventsInPlace(unpairedEvents);
252
266
  // map to store begin and end of the event
253
- const matchedPairs = new Map();
254
- // looking for start and end
267
+ const matches = [];
268
+ const beginEventsById = new Map();
269
+ const instantEventsById = new Map();
255
270
  for (const event of unpairedEvents) {
256
- const syntheticId = getSyntheticId(event);
257
- if (syntheticId === undefined) {
271
+ const id = getSyntheticId(event);
272
+ if (id === undefined) {
258
273
  continue;
259
274
  }
260
- // Create a synthetic id to prevent collisions across categories.
261
- // Console timings can be dispatched with the same id, so use the
262
- // event name as well to generate unique ids.
263
- const otherEventsWithID = Platform.MapUtilities.getWithDefault(matchedPairs, syntheticId, () => {
264
- return { begin: null, end: null, instant: [] };
265
- });
266
- const isStartEvent = event.ph === "b" /* Types.Events.Phase.ASYNC_NESTABLE_START */;
267
- const isEndEvent = event.ph === "e" /* Types.Events.Phase.ASYNC_NESTABLE_END */;
268
- const isInstantEvent = event.ph === "n" /* Types.Events.Phase.ASYNC_NESTABLE_INSTANT */;
269
- if (isStartEvent) {
270
- otherEventsWithID.begin = event;
271
- }
272
- else if (isEndEvent) {
273
- otherEventsWithID.end = event;
274
- }
275
- else if (isInstantEvent) {
276
- if (!otherEventsWithID.instant) {
277
- otherEventsWithID.instant = [];
275
+ if (Types.Events.isPairableAsyncBegin(event)) {
276
+ const existingEvents = beginEventsById.get(id) ?? [];
277
+ existingEvents.push(event);
278
+ beginEventsById.set(id, existingEvents);
279
+ }
280
+ else if (Types.Events.isPairableAsyncInstant(event)) {
281
+ const existingEvents = instantEventsById.get(id) ?? [];
282
+ existingEvents.push(event);
283
+ instantEventsById.set(id, existingEvents);
284
+ }
285
+ else if (Types.Events.isPairableAsyncEnd(event)) {
286
+ // Find matching begin event by ID
287
+ const beginEventsWithMatchingId = beginEventsById.get(id) ?? [];
288
+ const beginEvent = beginEventsWithMatchingId.pop();
289
+ if (!beginEvent) {
290
+ continue;
278
291
  }
279
- otherEventsWithID.instant.push(event);
292
+ const instantEventsWithMatchingId = instantEventsById.get(id) ?? [];
293
+ // Find all instant events after the begin event ts.
294
+ const instantEventsForThisGroup = [];
295
+ while (instantEventsWithMatchingId.length > 0) {
296
+ if (instantEventsWithMatchingId[0].ts >= beginEvent.ts) {
297
+ const event = instantEventsWithMatchingId.pop();
298
+ if (event) {
299
+ instantEventsForThisGroup.push(event);
300
+ }
301
+ }
302
+ else {
303
+ break;
304
+ }
305
+ }
306
+ const matchingGroup = {
307
+ begin: beginEvent,
308
+ end: event,
309
+ instant: instantEventsForThisGroup,
310
+ syntheticId: id,
311
+ };
312
+ matches.push(matchingGroup);
313
+ }
314
+ }
315
+ // At this point we know we have paired up all the Begin & End & Instant
316
+ // events. But it is possible to see only begin & instant events with the
317
+ // same ID, and no end event. So now we do a second pass through our begin
318
+ // events to find any that did not have an end event. If we find some
319
+ // instant events for the begin event, we create a new group.
320
+ // Also, because there were no end events, we know that the IDs will be
321
+ // unique now; e.g. each key in the map should have no more than one item in
322
+ // it.
323
+ for (const [id, beginEvents] of beginEventsById) {
324
+ const beginEvent = beginEvents.pop();
325
+ if (!beginEvent) {
326
+ continue;
327
+ }
328
+ const matchingInstantEvents = instantEventsById.get(id);
329
+ if (matchingInstantEvents?.length) {
330
+ matches.push({
331
+ syntheticId: id,
332
+ begin: beginEvent,
333
+ end: null,
334
+ instant: matchingInstantEvents,
335
+ });
280
336
  }
281
337
  }
282
- return matchedPairs;
338
+ return matches;
283
339
  }
284
- function getSyntheticId(event) {
340
+ export function getSyntheticId(event) {
285
341
  const id = extractId(event);
286
342
  return id && `${event.cat}:${id}:${event.name}`;
287
343
  }
288
- export function createSortedSyntheticEvents(matchedPairs, syntheticEventCallback) {
344
+ function createSortedSyntheticEvents(matchedPairs) {
289
345
  const syntheticEvents = [];
290
- for (const [id, eventsTriplet] of matchedPairs.entries()) {
346
+ for (const eventsTriplet of matchedPairs) {
347
+ const id = eventsTriplet.syntheticId;
291
348
  const beginEvent = eventsTriplet.begin;
292
349
  const endEvent = eventsTriplet.end;
293
350
  const instantEvents = eventsTriplet.instant;
@@ -335,14 +392,19 @@ export function createSortedSyntheticEvents(matchedPairs, syntheticEventCallback
335
392
  // crbug.com/1472375
336
393
  continue;
337
394
  }
338
- syntheticEventCallback?.(event);
339
395
  syntheticEvents.push(event);
340
396
  }
341
- return syntheticEvents.sort((a, b) => a.ts - b.ts);
397
+ sortTraceEventsInPlace(syntheticEvents);
398
+ return syntheticEvents;
342
399
  }
343
- export function createMatchedSortedSyntheticEvents(unpairedAsyncEvents, syntheticEventCallback) {
400
+ /**
401
+ * Groups up sets of async events into synthetic events.
402
+ * @param unpairedAsyncEvents the raw array of begin, end and async instant
403
+ * events. These MUST be sorted in timestamp ASC order.
404
+ */
405
+ export function createMatchedSortedSyntheticEvents(unpairedAsyncEvents) {
344
406
  const matchedPairs = matchEvents(unpairedAsyncEvents);
345
- const syntheticEvents = createSortedSyntheticEvents(matchedPairs, syntheticEventCallback);
407
+ const syntheticEvents = createSortedSyntheticEvents(matchedPairs);
346
408
  return syntheticEvents;
347
409
  }
348
410
  /**
@@ -93,6 +93,15 @@ export function isRenderFrameImplCreateChildFrame(event) {
93
93
  export function isLayoutImageUnsized(event) {
94
94
  return event.name === "LayoutImageUnsized" /* Name.LAYOUT_IMAGE_UNSIZED */;
95
95
  }
96
+ export function isPairableAsyncBegin(e) {
97
+ return e.ph === "b" /* Phase.ASYNC_NESTABLE_START */;
98
+ }
99
+ export function isPairableAsyncEnd(e) {
100
+ return e.ph === "e" /* Phase.ASYNC_NESTABLE_END */;
101
+ }
102
+ export function isPairableAsyncInstant(e) {
103
+ return e.ph === "n" /* Phase.ASYNC_NESTABLE_INSTANT */;
104
+ }
96
105
  export function isAnimationFrameAsyncStart(data) {
97
106
  return data.name === "AnimationFrame" /* Name.ANIMATION_FRAME */ && data.ph === "b" /* Phase.ASYNC_NESTABLE_START */;
98
107
  }
@@ -1,8 +1,14 @@
1
- import { NetworkCollector, PageCollector } from './PageCollector.js';
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
2
6
  import fs from 'node:fs/promises';
3
7
  import os from 'node:os';
4
8
  import path from 'node:path';
9
+ import { NetworkCollector, PageCollector } from './PageCollector.js';
5
10
  import { listPages } from './tools/pages.js';
11
+ import { CLOSE_PAGE_ERROR } from './tools/ToolDefinition.js';
6
12
  import { WaitForHelper } from './WaitForHelper.js';
7
13
  const DEFAULT_TIMEOUT = 5_000;
8
14
  const NAVIGATION_TIMEOUT = 10_000;
@@ -82,7 +88,7 @@ export class McpContext {
82
88
  }
83
89
  async closePage(pageIdx) {
84
90
  if (this.#pages.length === 1) {
85
- throw new Error('Unable to close the last page in the browser. It is fine to keep the last page open.');
91
+ throw new Error(CLOSE_PAGE_ERROR);
86
92
  }
87
93
  const page = this.getPageByIdx(pageIdx);
88
94
  this.setSelectedPageIdx(0);
@@ -1,6 +1,7 @@
1
+ import { formatConsoleEvent } from './formatters/consoleFormatter.js';
1
2
  import { getFormattedHeaderValue, getShortDescriptionForRequest, getStatusFromRequest, } from './formatters/networkFormatter.js';
2
3
  import { formatA11ySnapshot } from './formatters/snapshotFormatter.js';
3
- import { formatConsoleEvent } from './formatters/consoleFormatter.js';
4
+ import { paginate } from './utils/pagination.js';
4
5
  export class McpResponse {
5
6
  #includePages = false;
6
7
  #includeSnapshot = false;
@@ -10,14 +11,23 @@ export class McpResponse {
10
11
  #textResponseLines = [];
11
12
  #formattedConsoleData;
12
13
  #images = [];
14
+ #networkRequestsPaginationOptions;
13
15
  setIncludePages(value) {
14
16
  this.#includePages = value;
15
17
  }
16
18
  setIncludeSnapshot(value) {
17
19
  this.#includeSnapshot = value;
18
20
  }
19
- setIncludeNetworkRequests(value) {
21
+ setIncludeNetworkRequests(value, options) {
20
22
  this.#includeNetworkRequests = value;
23
+ if (!value || !options) {
24
+ this.#networkRequestsPaginationOptions = undefined;
25
+ return;
26
+ }
27
+ this.#networkRequestsPaginationOptions = {
28
+ pageSize: options.pageSize,
29
+ pageIdx: options.pageIdx,
30
+ };
21
31
  }
22
32
  setIncludeConsoleData(value) {
23
33
  this.#includeConsoleData = value;
@@ -37,6 +47,9 @@ export class McpResponse {
37
47
  get attachedNetworkRequestUrl() {
38
48
  return this.#attachedNetworkRequestUrl;
39
49
  }
50
+ get networkRequestsPageIdx() {
51
+ return this.#networkRequestsPaginationOptions?.pageIdx;
52
+ }
40
53
  appendResponseLine(value) {
41
54
  this.#textResponseLines.push(value);
42
55
  }
@@ -113,7 +126,21 @@ Call browser_handle_dialog to handle it before continuing.`);
113
126
  const requests = context.getNetworkRequests();
114
127
  response.push('## Network requests');
115
128
  if (requests.length) {
116
- for (const request of requests) {
129
+ const paginationResult = paginate(requests, this.#networkRequestsPaginationOptions);
130
+ if (paginationResult.invalidPage) {
131
+ response.push('Invalid page number provided. Showing first page.');
132
+ }
133
+ const { startIndex, endIndex, currentPage, totalPages } = paginationResult;
134
+ response.push(`Showing ${startIndex + 1}-${endIndex} of ${requests.length} (Page ${currentPage + 1} of ${totalPages}).`);
135
+ if (this.#networkRequestsPaginationOptions) {
136
+ if (paginationResult.hasNextPage) {
137
+ response.push(`Next page: ${currentPage + 1}`);
138
+ }
139
+ if (paginationResult.hasPreviousPage) {
140
+ response.push(`Previous page: ${currentPage - 1}`);
141
+ }
142
+ }
143
+ for (const request of paginationResult.items) {
117
144
  response.push(getShortDescriptionForRequest(request));
118
145
  }
119
146
  }
@@ -3,10 +3,10 @@
3
3
  * Copyright 2025 Google LLC
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
- import puppeteer from 'puppeteer-core';
6
+ import fs from 'node:fs';
7
7
  import os from 'node:os';
8
8
  import path from 'node:path';
9
- import fs from 'fs';
9
+ import puppeteer from 'puppeteer-core';
10
10
  let browser;
11
11
  const ignoredPrefixes = new Set([
12
12
  'chrome://',
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+ import yargs from 'yargs';
7
+ import { hideBin } from 'yargs/helpers';
8
+ export const cliOptions = {
9
+ browserUrl: {
10
+ type: 'string',
11
+ description: 'Connect to a running Chrome instance using port forwarding. For more details see: https://developer.chrome.com/docs/devtools/remote-debugging/local-server.',
12
+ alias: 'u',
13
+ coerce: (url) => {
14
+ new URL(url);
15
+ return url;
16
+ },
17
+ },
18
+ headless: {
19
+ type: 'boolean',
20
+ description: 'Whether to run in headless (no UI) mode.',
21
+ default: false,
22
+ },
23
+ executablePath: {
24
+ type: 'string',
25
+ description: 'Path to custom Chrome executable.',
26
+ conflicts: 'browserUrl',
27
+ alias: 'e',
28
+ },
29
+ isolated: {
30
+ type: 'boolean',
31
+ description: 'If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed.',
32
+ default: false,
33
+ },
34
+ customDevtools: {
35
+ type: 'string',
36
+ description: 'Path to custom DevTools.',
37
+ hidden: true,
38
+ conflicts: 'browserUrl',
39
+ alias: 'd',
40
+ },
41
+ channel: {
42
+ type: 'string',
43
+ description: 'Specify a different Chrome channel that should be used. The default is the stable channel version.',
44
+ choices: ['stable', 'canary', 'beta', 'dev'],
45
+ conflicts: ['browserUrl', 'executablePath'],
46
+ },
47
+ logFile: {
48
+ type: 'string',
49
+ describe: 'Save the logs to file.',
50
+ hidden: true,
51
+ },
52
+ };
53
+ export function parseArguments(version, argv = process.argv) {
54
+ const yargsInstance = yargs(hideBin(argv))
55
+ .scriptName('npx chrome-devtools-mcp@latest')
56
+ .options(cliOptions)
57
+ .check(args => {
58
+ // We can't set default in the options else
59
+ // Yargs will complain
60
+ if (!args.channel && !args.browserUrl && !args.executablePath) {
61
+ args.channel = 'stable';
62
+ }
63
+ return true;
64
+ })
65
+ .example([
66
+ [
67
+ '$0 --browserUrl http://127.0.0.1:9222',
68
+ 'Connect to an existing browser instance',
69
+ ],
70
+ ['$0 --channel beta', 'Use Chrome Beta installed on this system'],
71
+ ['$0 --channel canary', 'Use Chrome Canary installed on this system'],
72
+ ['$0 --channel dev', 'Use Chrome Dev installed on this system'],
73
+ ['$0 --channel stable', 'Use stable Chrome installed on this system'],
74
+ ['$0 --logFile /tmp/log.txt', 'Save logs to a file'],
75
+ ['$0 --help', 'Print CLI options'],
76
+ ]);
77
+ return yargsInstance
78
+ .wrap(Math.min(120, yargsInstance.terminalWidth()))
79
+ .help()
80
+ .version(version)
81
+ .parseSync();
82
+ }
@@ -55,7 +55,9 @@ async function formatConsoleMessage(msg) {
55
55
  return `${logLevel}> ${formatStackFrame(msg.location())}: ${text} ${formattedArgs}`.trim();
56
56
  }
57
57
  async function formatArgs(args) {
58
- const argValues = await Promise.all(args.map(arg => arg.jsonValue().catch(() => { })));
58
+ const argValues = await Promise.all(args.map(arg => arg.jsonValue().catch(() => {
59
+ // Ignore errors
60
+ })));
59
61
  return argValues
60
62
  .map(value => {
61
63
  return typeof value === 'object' ? JSON.stringify(value) : String(value);
@@ -6,7 +6,7 @@
6
6
  */
7
7
  const [major, minor] = process.version.substring(1).split('.').map(Number);
8
8
  if (major < 22 || (major === 22 && minor < 12)) {
9
- console.error(`ERROR: \`chrome-devtools-mcp\` does not support Node ${process.version}. Please upgrade to Node 22+.`);
9
+ console.error(`ERROR: \`chrome-devtools-mcp\` does not support Node ${process.version}. Please upgrade to Node 22.12.0 or newer.`);
10
10
  process.exit(1);
11
11
  }
12
12
  await import('./main.js');