chrome-devtools-mcp 0.12.1 → 0.13.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/README.md CHANGED
@@ -91,10 +91,10 @@ Chrome DevTools MCP will not start the browser instance automatically using this
91
91
 
92
92
  <details>
93
93
  <summary>Claude Code</summary>
94
- Use the Claude Code CLI to add the Chrome DevTools MCP server (<a href="https://docs.anthropic.com/en/docs/claude-code/mcp">guide</a>):
94
+ Use the Claude Code CLI to add the Chrome DevTools MCP server (<a href="https://code.claude.com/docs/en/mcp">guide</a>):
95
95
 
96
96
  ```bash
97
- claude mcp add chrome-devtools npx chrome-devtools-mcp@latest
97
+ claude mcp add chrome-devtools --scope user npx chrome-devtools-mcp@latest
98
98
  ```
99
99
 
100
100
  </details>
@@ -241,6 +241,25 @@ Or, from the IDE **Activity Bar** > `Kiro` > `MCP Servers` > `Click Open MCP Con
241
241
 
242
242
  </details>
243
243
 
244
+ <details>
245
+ <summary>OpenCode</summary>
246
+
247
+ Add the following configuration to your `opencode.json` file. If you don't have one, create it at `~/.config/opencode/opencode.json` (<a href="https://opencode.ai/docs/mcp-servers">guide</a>):
248
+
249
+ ```json
250
+ {
251
+ "$schema": "https://opencode.ai/config.json",
252
+ "mcp": {
253
+ "chrome-devtools": {
254
+ "type": "local",
255
+ "command": ["npx", "-y", "chrome-devtools-mcp@latest"]
256
+ }
257
+ }
258
+ }
259
+ ```
260
+
261
+ </details>
262
+
244
263
  <details>
245
264
  <summary>Qoder</summary>
246
265
 
@@ -350,20 +369,20 @@ The Chrome DevTools MCP server supports the following configuration option:
350
369
 
351
370
  <!-- BEGIN AUTO GENERATED OPTIONS -->
352
371
 
353
- - **`--autoConnect`**
372
+ - **`--autoConnect`/ `--auto-connect`**
354
373
  If specified, automatically connects to a browser (Chrome 145+) running in the user data directory identified by the channel param. Requires remote debugging being enabled in Chrome here: chrome://inspect/#remote-debugging.
355
374
  - **Type:** boolean
356
375
  - **Default:** `false`
357
376
 
358
- - **`--browserUrl`, `-u`**
377
+ - **`--browserUrl`/ `--browser-url`, `-u`**
359
378
  Connect to a running, debuggable Chrome instance (e.g. `http://127.0.0.1:9222`). For more details see: https://github.com/ChromeDevTools/chrome-devtools-mcp#connecting-to-a-running-chrome-instance.
360
379
  - **Type:** string
361
380
 
362
- - **`--wsEndpoint`, `-w`**
381
+ - **`--wsEndpoint`/ `--ws-endpoint`, `-w`**
363
382
  WebSocket endpoint to connect to a running Chrome instance (e.g., ws://127.0.0.1:9222/devtools/browser/<id>). Alternative to --browserUrl.
364
383
  - **Type:** string
365
384
 
366
- - **`--wsHeaders`**
385
+ - **`--wsHeaders`/ `--ws-headers`**
367
386
  Custom headers for WebSocket connection in JSON format (e.g., '{"Authorization":"Bearer token"}'). Only works with --wsEndpoint.
368
387
  - **Type:** string
369
388
 
@@ -372,7 +391,7 @@ The Chrome DevTools MCP server supports the following configuration option:
372
391
  - **Type:** boolean
373
392
  - **Default:** `false`
374
393
 
375
- - **`--executablePath`, `-e`**
394
+ - **`--executablePath`/ `--executable-path`, `-e`**
376
395
  Path to custom Chrome executable.
377
396
  - **Type:** string
378
397
 
@@ -380,7 +399,7 @@ The Chrome DevTools MCP server supports the following configuration option:
380
399
  If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed. Defaults to false.
381
400
  - **Type:** boolean
382
401
 
383
- - **`--userDataDir`**
402
+ - **`--userDataDir`/ `--user-data-dir`**
384
403
  Path to the user data directory for Chrome. Default is $HOME/.cache/chrome-devtools-mcp/chrome-profile$CHANNEL_SUFFIX_IF_NON_STABLE
385
404
  - **Type:** string
386
405
 
@@ -389,7 +408,7 @@ The Chrome DevTools MCP server supports the following configuration option:
389
408
  - **Type:** string
390
409
  - **Choices:** `stable`, `canary`, `beta`, `dev`
391
410
 
392
- - **`--logFile`**
411
+ - **`--logFile`/ `--log-file`**
393
412
  Path to a file to write debug logs to. Set the env variable `DEBUG` to `*` to enable verbose logs. Useful for submitting bug reports.
394
413
  - **Type:** string
395
414
 
@@ -397,29 +416,33 @@ The Chrome DevTools MCP server supports the following configuration option:
397
416
  Initial viewport size for the Chrome instances started by the server. For example, `1280x720`. In headless mode, max size is 3840x2160px.
398
417
  - **Type:** string
399
418
 
400
- - **`--proxyServer`**
419
+ - **`--proxyServer`/ `--proxy-server`**
401
420
  Proxy server configuration for Chrome passed as --proxy-server when launching the browser. See https://www.chromium.org/developers/design-documents/network-settings/ for details.
402
421
  - **Type:** string
403
422
 
404
- - **`--acceptInsecureCerts`**
423
+ - **`--acceptInsecureCerts`/ `--accept-insecure-certs`**
405
424
  If enabled, ignores errors relative to self-signed and expired certificates. Use with caution.
406
425
  - **Type:** boolean
407
426
 
408
- - **`--chromeArg`**
427
+ - **`--chromeArg`/ `--chrome-arg`**
409
428
  Additional arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.
410
429
  - **Type:** array
411
430
 
412
- - **`--categoryEmulation`**
431
+ - **`--ignoreDefaultChromeArg`/ `--ignore-default-chrome-arg`**
432
+ Explicitly disable default arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.
433
+ - **Type:** array
434
+
435
+ - **`--categoryEmulation`/ `--category-emulation`**
413
436
  Set to false to exclude tools related to emulation.
414
437
  - **Type:** boolean
415
438
  - **Default:** `true`
416
439
 
417
- - **`--categoryPerformance`**
440
+ - **`--categoryPerformance`/ `--category-performance`**
418
441
  Set to false to exclude tools related to performance.
419
442
  - **Type:** boolean
420
443
  - **Default:** `true`
421
444
 
422
- - **`--categoryNetwork`**
445
+ - **`--categoryNetwork`/ `--category-network`**
423
446
  Set to false to exclude tools related to network.
424
447
  - **Type:** boolean
425
448
  - **Default:** `true`
@@ -499,7 +522,7 @@ In these cases, start Chrome first and let the Chrome DevTools MCP server connec
499
522
 
500
523
  **Step 1:** Set up remote debugging in Chrome
501
524
 
502
- In Chrome, do the following to set up remote debugging:
525
+ In Chrome (\>= M144), do the following to set up remote debugging:
503
526
 
504
527
  1. Navigate to `chrome://inspect/#remote-debugging` to enable remote debugging.
505
528
  2. Follow the dialog UI to allow or disallow incoming debugging connections.
@@ -516,17 +539,13 @@ The following code snippet is an example configuration for gemini-cli:
516
539
  "mcpServers": {
517
540
  "chrome-devtools": {
518
541
  "command": "npx",
519
- "args": [
520
- "chrome-devtools-mcp@latest",
521
- "--autoConnect",
522
- "--channel=canary"
523
- ]
542
+ "args": ["chrome-devtools-mcp@latest", "--autoConnect", "--channel=beta"]
524
543
  }
525
544
  }
526
545
  }
527
546
  ```
528
547
 
529
- Note: you have to specify `--channel=canary` until Chrome M144 has reached the
548
+ Note: you have to specify `--channel=beta` until Chrome M144 has reached the
530
549
  stable channel.
531
550
 
532
551
  **Step 3:** Test your setup
@@ -537,7 +556,8 @@ Make sure your browser is running. Open gemini-cli and run the following prompt:
537
556
  Check the performance of https://developers.chrome.com
538
557
  ```
539
558
 
540
- Note: The <code>autoConnect</code> option requires the user to start Chrome.
559
+ > [!NOTE]
560
+ > The <code>autoConnect</code> option requires the user to start Chrome. If the user has multiple active profiles, the MCP server will connect to the default profile (as determined by Chrome). The MCP server has access to all open windows for the selected profile.
541
561
 
542
562
  The Chrome DevTools MCP server will try to connect to your running Chrome
543
563
  instance. It shows a dialog asking for user permission.
@@ -56,6 +56,8 @@ export class McpContext {
56
56
  #cpuThrottlingRateMap = new WeakMap();
57
57
  #geolocationMap = new WeakMap();
58
58
  #dialog;
59
+ #pageIdMap = new WeakMap();
60
+ #nextPageId = 1;
59
61
  #nextSnapshotId = 1;
60
62
  #traceResults = [];
61
63
  #locatorClass;
@@ -163,11 +165,11 @@ export class McpContext {
163
165
  this.#consoleCollector.addPage(page);
164
166
  return page;
165
167
  }
166
- async closePage(pageIdx) {
168
+ async closePage(pageId) {
167
169
  if (this.#pages.length === 1) {
168
170
  throw new Error(CLOSE_PAGE_ERROR);
169
171
  }
170
- const page = this.getPageByIdx(pageIdx);
172
+ const page = this.getPageById(pageId);
171
173
  await page.close({ runBeforeUnload: false });
172
174
  }
173
175
  getNetworkRequestById(reqid) {
@@ -231,14 +233,16 @@ export class McpContext {
231
233
  }
232
234
  return page;
233
235
  }
234
- getPageByIdx(idx) {
235
- const pages = this.#pages;
236
- const page = pages[idx];
236
+ getPageById(pageId) {
237
+ const page = this.#pages.find(p => this.#pageIdMap.get(p) === pageId);
237
238
  if (!page) {
238
239
  throw new Error('No page found');
239
240
  }
240
241
  return page;
241
242
  }
243
+ getPageId(page) {
244
+ return this.#pageIdMap.get(page);
245
+ }
242
246
  #dialogHandler = (dialog) => {
243
247
  this.#dialog = dialog;
244
248
  };
@@ -302,6 +306,11 @@ export class McpContext {
302
306
  */
303
307
  async createPagesSnapshot() {
304
308
  const allPages = await this.browser.pages(this.#options.experimentalIncludeAllPages);
309
+ for (const page of allPages) {
310
+ if (!this.#pageIdMap.has(page)) {
311
+ this.#pageIdMap.set(page, this.#nextPageId++);
312
+ }
313
+ }
305
314
  this.#pages = allPages.filter(page => {
306
315
  // If we allow debugging DevTools windows, return all pages.
307
316
  // If we are in regular mode, the user should only see non-DevTools page.
@@ -6,7 +6,7 @@
6
6
  import { mapIssueToMessageObject } from './DevtoolsUtils.js';
7
7
  import { formatConsoleEventShort, formatConsoleEventVerbose, } from './formatters/consoleFormatter.js';
8
8
  import { getFormattedHeaderValue, getFormattedResponseBody, getFormattedRequestBody, getShortDescriptionForRequest, getStatusFromRequest, } from './formatters/networkFormatter.js';
9
- import { formatSnapshotNode } from './formatters/snapshotFormatter.js';
9
+ import { SnapshotFormatter } from './formatters/SnapshotFormatter.js';
10
10
  import { DevTools } from './third_party/index.js';
11
11
  import { handleDialog } from './tools/pages.js';
12
12
  import { paginate } from './utils/pagination.js';
@@ -20,9 +20,13 @@ export class McpResponse {
20
20
  #networkRequestsOptions;
21
21
  #consoleDataOptions;
22
22
  #devToolsData;
23
+ #tabId;
23
24
  attachDevToolsData(data) {
24
25
  this.#devToolsData = data;
25
26
  }
27
+ setTabId(tabId) {
28
+ this.#tabId = tabId;
29
+ }
26
30
  setIncludePages(value) {
27
31
  this.#includePages = value;
28
32
  }
@@ -112,17 +116,18 @@ export class McpResponse {
112
116
  if (this.#includePages) {
113
117
  await context.createPagesSnapshot();
114
118
  }
115
- let formattedSnapshot;
119
+ let snapshot;
116
120
  if (this.#snapshotParams) {
117
121
  await context.createTextSnapshot(this.#snapshotParams.verbose, this.#devToolsData);
118
- const snapshot = context.getTextSnapshot();
119
- if (snapshot) {
122
+ const textSnapshot = context.getTextSnapshot();
123
+ if (textSnapshot) {
124
+ const formatter = new SnapshotFormatter(textSnapshot);
120
125
  if (this.#snapshotParams.filePath) {
121
- await context.saveFile(new TextEncoder().encode(formatSnapshotNode(snapshot.root, snapshot)), this.#snapshotParams.filePath);
122
- formattedSnapshot = `Saved snapshot to ${this.#snapshotParams.filePath}.`;
126
+ await context.saveFile(new TextEncoder().encode(formatter.toString()), this.#snapshotParams.filePath);
127
+ snapshot = this.#snapshotParams.filePath;
123
128
  }
124
129
  else {
125
- formattedSnapshot = formatSnapshotNode(snapshot.root, snapshot);
130
+ snapshot = formatter;
126
131
  }
127
132
  }
128
133
  }
@@ -157,8 +162,9 @@ export class McpResponse {
157
162
  }
158
163
  else if (message instanceof DevTools.AggregatedIssue) {
159
164
  const mappedIssueMessage = mapIssueToMessageObject(message);
160
- if (!mappedIssueMessage)
165
+ if (!mappedIssueMessage) {
161
166
  throw new Error("Can't provide detals for the msgid " + consoleMessageStableId);
167
+ }
162
168
  consoleData = {
163
169
  consoleMessageStableId,
164
170
  ...mappedIssueMessage,
@@ -208,8 +214,9 @@ export class McpResponse {
208
214
  }
209
215
  if (item instanceof DevTools.AggregatedIssue) {
210
216
  const mappedIssueMessage = mapIssueToMessageObject(item);
211
- if (!mappedIssueMessage)
217
+ if (!mappedIssueMessage) {
212
218
  return null;
219
+ }
213
220
  return {
214
221
  consoleMessageStableId,
215
222
  ...mappedIssueMessage,
@@ -227,7 +234,7 @@ export class McpResponse {
227
234
  bodies,
228
235
  consoleData,
229
236
  consoleListData,
230
- formattedSnapshot,
237
+ snapshot,
231
238
  });
232
239
  }
233
240
  format(toolName, context, data) {
@@ -257,16 +264,25 @@ Call ${handleDialog.name} to handle it before continuing.`);
257
264
  }
258
265
  if (this.#includePages) {
259
266
  const parts = [`## Pages`];
260
- let idx = 0;
261
267
  for (const page of context.getPages()) {
262
- parts.push(`${idx}: ${page.url()}${context.isPageSelected(page) ? ' [selected]' : ''}`);
263
- idx++;
268
+ parts.push(`${context.getPageId(page)}: ${page.url()}${context.isPageSelected(page) ? ' [selected]' : ''}`);
264
269
  }
265
270
  response.push(...parts);
266
271
  }
267
- if (data.formattedSnapshot) {
268
- response.push('## Latest page snapshot');
269
- response.push(data.formattedSnapshot);
272
+ const structuredContent = {};
273
+ if (this.#tabId) {
274
+ structuredContent.tabId = this.#tabId;
275
+ }
276
+ if (data.snapshot) {
277
+ if (typeof data.snapshot === 'string') {
278
+ response.push(`Saved snapshot to ${data.snapshot}.`);
279
+ structuredContent.snapshotFilePath = data.snapshot;
280
+ }
281
+ else {
282
+ response.push('## Latest page snapshot');
283
+ response.push(data.snapshot.toString());
284
+ structuredContent.snapshot = data.snapshot.toJSON();
285
+ }
270
286
  }
271
287
  response.push(...this.#formatNetworkRequestData(context, data.bodies));
272
288
  response.push(...this.#formatConsoleData(context, data.consoleData));
@@ -315,7 +331,10 @@ Call ${handleDialog.name} to handle it before continuing.`);
315
331
  ...imageData,
316
332
  };
317
333
  });
318
- return [text, ...images];
334
+ return {
335
+ content: [text, ...images],
336
+ structuredContent,
337
+ };
319
338
  }
320
339
  #dataWithPagination(data, pagination) {
321
340
  const response = [];
@@ -116,9 +116,10 @@ export async function launch(options) {
116
116
  });
117
117
  }
118
118
  const args = [
119
- ...(options.args ?? []),
119
+ ...(options.chromeArgs ?? []),
120
120
  '--hide-crash-restore-bubble',
121
121
  ];
122
+ const ignoreDefaultArgs = options.ignoreDefaultChromeArgs ?? false;
122
123
  if (headless) {
123
124
  args.push('--screen-info={3840x2160}');
124
125
  }
@@ -142,6 +143,7 @@ export async function launch(options) {
142
143
  pipe: true,
143
144
  headless,
144
145
  args,
146
+ ignoreDefaultArgs: ignoreDefaultArgs,
145
147
  acceptInsecureCerts: options.acceptInsecureCerts,
146
148
  handleDevToolsAsPage: true,
147
149
  });
@@ -153,7 +155,6 @@ export async function launch(options) {
153
155
  }
154
156
  if (options.viewport) {
155
157
  const [page] = await browser.pages();
156
- // @ts-expect-error internal API for now.
157
158
  await page?.resize({
158
159
  contentWidth: options.viewport.width,
159
160
  contentHeight: options.viewport.height,
package/build/src/cli.js CHANGED
@@ -139,15 +139,34 @@ export const cliOptions = {
139
139
  describe: 'Whether to enable automation over DevTools targets',
140
140
  hidden: true,
141
141
  },
142
+ experimentalVision: {
143
+ type: 'boolean',
144
+ describe: 'Whether to enable vision tools',
145
+ hidden: true,
146
+ },
147
+ experimentalStructuredContent: {
148
+ type: 'boolean',
149
+ describe: 'Whether to output structured formatted content.',
150
+ hidden: true,
151
+ },
142
152
  experimentalIncludeAllPages: {
143
153
  type: 'boolean',
144
154
  describe: 'Whether to include all kinds of pages such as webviews or background pages as pages.',
145
155
  hidden: true,
146
156
  },
157
+ experimentalInteropTools: {
158
+ type: 'boolean',
159
+ describe: 'Whether to enable interoperability tools',
160
+ hidden: true,
161
+ },
147
162
  chromeArg: {
148
163
  type: 'array',
149
164
  describe: 'Additional arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.',
150
165
  },
166
+ ignoreDefaultChromeArg: {
167
+ type: 'array',
168
+ describe: 'Explicitly disable default arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.',
169
+ },
151
170
  categoryEmulation: {
152
171
  type: 'boolean',
153
172
  default: true,
@@ -163,6 +182,13 @@ export const cliOptions = {
163
182
  default: true,
164
183
  describe: 'Set to false to exclude tools related to network.',
165
184
  },
185
+ usageStatistics: {
186
+ type: 'boolean',
187
+ // Marked as `false` until the feature is ready to be enabled by default.
188
+ default: false,
189
+ hidden: true,
190
+ describe: 'Set to false to opt-out of usage statistics collection.',
191
+ },
166
192
  };
167
193
  export function parseArguments(version, argv = process.argv) {
168
194
  const yargsInstance = yargs(hideBin(argv))
@@ -206,6 +232,10 @@ export function parseArguments(version, argv = process.argv) {
206
232
  `$0 --chrome-arg='--no-sandbox' --chrome-arg='--disable-setuid-sandbox'`,
207
233
  'Launch Chrome without sandboxes. Use with caution.',
208
234
  ],
235
+ [
236
+ `$0 --ignore-default-chrome-arg='--disable-extensions'`,
237
+ 'Disable the default arguments provided by Puppeteer. Use with caution.',
238
+ ],
209
239
  ['$0 --no-category-emulation', 'Disable tools in the emulation category'],
210
240
  [
211
241
  '$0 --no-category-performance',
@@ -0,0 +1,128 @@
1
+ export class SnapshotFormatter {
2
+ #snapshot;
3
+ constructor(snapshot) {
4
+ this.#snapshot = snapshot;
5
+ }
6
+ toString() {
7
+ const chunks = [];
8
+ const root = this.#snapshot.root;
9
+ // Top-level content of the snapshot.
10
+ if (this.#snapshot.verbose &&
11
+ this.#snapshot.hasSelectedElement &&
12
+ !this.#snapshot.selectedElementUid) {
13
+ chunks.push(`Note: there is a selected element in the DevTools Elements panel but it is not included into the current a11y tree snapshot.
14
+ Get a verbose snapshot to include all elements if you are interested in the selected element.\n\n`);
15
+ }
16
+ chunks.push(this.#formatNode(root, 0));
17
+ return chunks.join('');
18
+ }
19
+ toJSON() {
20
+ return this.#nodeToJSON(this.#snapshot.root);
21
+ }
22
+ #formatNode(node, depth = 0) {
23
+ const chunks = [];
24
+ const attributes = this.#getAttributes(node);
25
+ const line = ' '.repeat(depth * 2) +
26
+ attributes.join(' ') +
27
+ (node.id === this.#snapshot.selectedElementUid
28
+ ? ' [selected in the DevTools Elements panel]'
29
+ : '') +
30
+ '\n';
31
+ chunks.push(line);
32
+ for (const child of node.children) {
33
+ chunks.push(this.#formatNode(child, depth + 1));
34
+ }
35
+ return chunks.join('');
36
+ }
37
+ #nodeToJSON(node) {
38
+ const rawAttrs = this.#getAttributesMap(node);
39
+ const children = node.children.map(child => this.#nodeToJSON(child));
40
+ const result = structuredClone(rawAttrs);
41
+ if (children.length > 0) {
42
+ result.children = children;
43
+ }
44
+ return result;
45
+ }
46
+ #getAttributes(serializedAXNodeRoot) {
47
+ const attributes = [`uid=${serializedAXNodeRoot.id}`];
48
+ if (serializedAXNodeRoot.role) {
49
+ attributes.push(serializedAXNodeRoot.role === 'none'
50
+ ? 'ignored'
51
+ : serializedAXNodeRoot.role);
52
+ }
53
+ if (serializedAXNodeRoot.name) {
54
+ attributes.push(`"${serializedAXNodeRoot.name}"`);
55
+ }
56
+ const simpleAttrs = this.#getAttributesMap(serializedAXNodeRoot,
57
+ /* excludeSpecial */ true);
58
+ for (const attr of Object.keys(serializedAXNodeRoot).sort()) {
59
+ if (excludedAttributes.has(attr)) {
60
+ continue;
61
+ }
62
+ const mapped = booleanPropertyMap[attr];
63
+ if (mapped && simpleAttrs[mapped]) {
64
+ attributes.push(mapped);
65
+ }
66
+ const val = simpleAttrs[attr];
67
+ if (val === true) {
68
+ attributes.push(attr);
69
+ }
70
+ else if (typeof val === 'string' || typeof val === 'number') {
71
+ attributes.push(`${attr}="${val}"`);
72
+ }
73
+ }
74
+ return attributes;
75
+ }
76
+ #getAttributesMap(node, excludeSpecial = false) {
77
+ const result = {};
78
+ if (!excludeSpecial) {
79
+ result.id = node.id;
80
+ if (node.role) {
81
+ result.role = node.role;
82
+ }
83
+ if (node.name) {
84
+ result.name = node.name;
85
+ }
86
+ }
87
+ // Re-implementing the exact logic from original function for #getAttributes to be safe:
88
+ return {
89
+ ...result,
90
+ ...this.#extractedAttributes(node),
91
+ };
92
+ }
93
+ #extractedAttributes(node) {
94
+ const result = {};
95
+ for (const attr of Object.keys(node).sort()) {
96
+ if (excludedAttributes.has(attr)) {
97
+ continue;
98
+ }
99
+ const value = node[attr];
100
+ if (typeof value === 'boolean') {
101
+ if (booleanPropertyMap[attr]) {
102
+ result[booleanPropertyMap[attr]] = true;
103
+ }
104
+ if (value) {
105
+ result[attr] = true;
106
+ }
107
+ }
108
+ else if (typeof value === 'string' || typeof value === 'number') {
109
+ result[attr] = value;
110
+ }
111
+ }
112
+ return result;
113
+ }
114
+ }
115
+ const booleanPropertyMap = {
116
+ disabled: 'disableable',
117
+ expanded: 'expandable',
118
+ focused: 'focusable',
119
+ selected: 'selectable',
120
+ };
121
+ const excludedAttributes = new Set([
122
+ 'id',
123
+ 'role',
124
+ 'name',
125
+ 'elementHandle',
126
+ 'children',
127
+ 'backendNodeId',
128
+ ]);
@@ -25,6 +25,7 @@ export function formatConsoleEventVerbose(msg, context) {
25
25
  `ID: ${msg.consoleMessageStableId}`,
26
26
  `Message: ${msg.type}> ${aggregatedIssue ? formatIssue(aggregatedIssue, msg.description, context) : msg.message}`,
27
27
  aggregatedIssue ? undefined : formatArgs(msg),
28
+ formatStackTrace(msg.stackTrace),
28
29
  ].filter(line => !!line);
29
30
  return result.join('\n');
30
31
  }
@@ -49,8 +50,9 @@ export function formatIssue(issue, description, context) {
49
50
  if (processedMarkdown?.startsWith('# ')) {
50
51
  processedMarkdown = processedMarkdown.substring(2).trimStart();
51
52
  }
52
- if (processedMarkdown)
53
+ if (processedMarkdown) {
53
54
  result.push(processedMarkdown);
55
+ }
54
56
  const links = issue.getDescription()?.links;
55
57
  if (links && links.length > 0) {
56
58
  result.push('Learn more:');
@@ -62,8 +64,9 @@ export function formatIssue(issue, description, context) {
62
64
  const affectedResources = [];
63
65
  for (const singleIssue of issues) {
64
66
  const details = singleIssue.details();
65
- if (!details)
67
+ if (!details) {
66
68
  continue;
69
+ }
67
70
  // We send the remaining details as untyped JSON because the DevTools
68
71
  // frontend code is currently not re-usable.
69
72
  // eslint-disable-next-line
@@ -106,16 +109,48 @@ export function formatIssue(issue, description, context) {
106
109
  }
107
110
  result.push(...affectedResources.map(item => {
108
111
  const details = [];
109
- if (item.uid)
112
+ if (item.uid) {
110
113
  details.push(`uid=${item.uid}`);
114
+ }
111
115
  if (item.request) {
112
116
  details.push((typeof item.request === 'number' ? `reqid=` : 'url=') + item.request);
113
117
  }
114
- if (item.data)
118
+ if (item.data) {
115
119
  details.push(`data=${JSON.stringify(item.data)}`);
120
+ }
116
121
  return details.join(' ');
117
122
  }));
118
- if (result.length === 0)
123
+ if (result.length === 0) {
119
124
  return 'No affected resources found';
125
+ }
120
126
  return result.join('\n');
121
127
  }
128
+ function formatStackTrace(stackTrace) {
129
+ if (!stackTrace) {
130
+ return '';
131
+ }
132
+ return [
133
+ '### Stack trace',
134
+ formatFragment(stackTrace.syncFragment),
135
+ ...stackTrace.asyncFragments.map(formatAsyncFragment),
136
+ ].join('\n');
137
+ }
138
+ function formatFragment(fragment) {
139
+ return fragment.frames.map(formatFrame).join('\n');
140
+ }
141
+ function formatAsyncFragment(fragment) {
142
+ const separatorLineLength = 40;
143
+ const prefix = `--- ${fragment.description || 'async'} `;
144
+ const separator = prefix + '-'.repeat(separatorLineLength - prefix.length);
145
+ return separator + '\n' + formatFragment(fragment);
146
+ }
147
+ function formatFrame(frame) {
148
+ let result = `at ${frame.name ?? '<anonymous>'}`;
149
+ if (frame.uiSourceCode) {
150
+ result += ` (${frame.uiSourceCode.displayName()}:${frame.line}:${frame.column})`;
151
+ }
152
+ else if (frame.url) {
153
+ result += ` (${frame.url}:${frame.line}:${frame.column})`;
154
+ }
155
+ return result;
156
+ }