chrome-devtools-mcp 1.2.0 → 1.4.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 +59 -4
  2. package/build/src/HeapSnapshotManager.js +25 -16
  3. package/build/src/McpContext.js +35 -1
  4. package/build/src/McpResponse.js +77 -7
  5. package/build/src/PageCollector.js +1 -1
  6. package/build/src/bin/check-latest-version.js +1 -0
  7. package/build/src/bin/chrome-devtools-cli-options.js +84 -0
  8. package/build/src/bin/chrome-devtools-mcp-cli-options.js +47 -3
  9. package/build/src/{DevToolsConnectionAdapter.js → devtools/DevToolsConnectionAdapter.js} +1 -1
  10. package/build/src/{DevtoolsUtils.js → devtools/DevtoolsUtils.js} +75 -71
  11. package/build/src/devtools/McpHostBindingAdapter.js +165 -0
  12. package/build/src/formatters/ConsoleFormatter.js +1 -1
  13. package/build/src/formatters/HeapSnapshotFormatter.js +22 -0
  14. package/build/src/formatters/NetworkFormatter.js +3 -1
  15. package/build/src/third_party/THIRD_PARTY_NOTICES +4 -4
  16. package/build/src/third_party/bundled-packages.json +3 -3
  17. package/build/src/third_party/devtools-formatter-worker.js +0 -1
  18. package/build/src/third_party/devtools-heap-snapshot-worker.js +67 -3
  19. package/build/src/third_party/index.js +1971 -426
  20. package/build/src/tools/memory.js +76 -0
  21. package/build/src/tools/screencast.js +30 -9
  22. package/build/src/tools/screenshot.js +158 -76
  23. package/build/src/utils/check-for-updates.js +1 -0
  24. package/build/src/version.js +1 -1
  25. package/package.json +7 -6
  26. package/skills/a11y-debugging/SKILL.md +89 -0
  27. package/skills/a11y-debugging/references/a11y-snippets.md +92 -0
  28. package/skills/chrome-devtools/SKILL.md +72 -0
  29. package/skills/chrome-devtools-cli/SKILL.md +153 -0
  30. package/skills/chrome-devtools-cli/references/installation.md +14 -0
  31. package/skills/debug-optimize-lcp/SKILL.md +121 -0
  32. package/skills/debug-optimize-lcp/references/elements-and-size.md +27 -0
  33. package/skills/debug-optimize-lcp/references/lcp-breakdown.md +23 -0
  34. package/skills/debug-optimize-lcp/references/lcp-snippets.md +79 -0
  35. package/skills/debug-optimize-lcp/references/optimization-strategies.md +38 -0
  36. package/skills/memory-leak-debugging/SKILL.md +50 -0
  37. package/skills/memory-leak-debugging/references/common-leaks.md +33 -0
  38. package/skills/memory-leak-debugging/references/compare_snapshots.js +109 -0
  39. package/skills/memory-leak-debugging/references/memlab.md +29 -0
  40. package/skills/troubleshooting/SKILL.md +98 -0
@@ -148,4 +148,80 @@ export const closeHeapSnapshot = defineTool({
148
148
  response.appendResponseLine(`Closed heap snapshot: ${request.params.filePath}`);
149
149
  },
150
150
  });
151
+ export const getHeapSnapshotRetainingPaths = defineTool({
152
+ name: 'get_heapsnapshot_retaining_paths',
153
+ description: 'Loads a memory heapsnapshot and returns retaining paths for a specific node ID. This helps to understand why a node is not being garbage collected.',
154
+ annotations: {
155
+ category: ToolCategory.MEMORY,
156
+ readOnlyHint: true,
157
+ conditions: ['memoryDebugging'],
158
+ },
159
+ verifyFilesSchema: ['filePath'],
160
+ blockedByDialog: false,
161
+ schema: {
162
+ filePath: zod.string().describe('A path to a .heapsnapshot file to read.'),
163
+ nodeId: zod.number().describe('The node ID to get retaining paths for.'),
164
+ maxDepth: zod
165
+ .number()
166
+ .optional()
167
+ .describe('The maximum depth to search for retaining paths.'),
168
+ maxNodes: zod
169
+ .number()
170
+ .optional()
171
+ .describe('The maximum number of nodes to return.'),
172
+ maxSiblings: zod
173
+ .number()
174
+ .optional()
175
+ .describe('The maximum number of siblings to return.'),
176
+ },
177
+ handler: async (request, response, context) => {
178
+ const retainingPaths = await context.getHeapSnapshotRetainingPaths(request.params.filePath, request.params.nodeId, request.params.maxDepth, request.params.maxNodes, request.params.maxSiblings);
179
+ response.setHeapSnapshotRetainingPaths(retainingPaths);
180
+ },
181
+ });
182
+ export const getHeapSnapshotEdges = defineTool({
183
+ name: 'get_heapsnapshot_edges',
184
+ description: 'Loads a memory heapsnapshot and returns outgoing edges (references) for a specific node ID.',
185
+ annotations: {
186
+ category: ToolCategory.MEMORY,
187
+ readOnlyHint: true,
188
+ conditions: ['memoryDebugging'],
189
+ },
190
+ blockedByDialog: false,
191
+ verifyFilesSchema: ['filePath'],
192
+ schema: {
193
+ filePath: zod.string().describe('A path to a .heapsnapshot file to read.'),
194
+ nodeId: zod.number().describe('The node ID to get outgoing edges for.'),
195
+ pageIdx: zod.number().optional().describe('The page index for pagination.'),
196
+ pageSize: zod.number().optional().describe('The page size for pagination.'),
197
+ },
198
+ handler: async (request, response, context) => {
199
+ const edges = await context.getHeapSnapshotEdges(request.params.filePath, request.params.nodeId);
200
+ response.setHeapSnapshotNodes(edges, {
201
+ pageIdx: request.params.pageIdx,
202
+ pageSize: request.params.pageSize,
203
+ });
204
+ },
205
+ });
206
+ export const getHeapSnapshotDominators = defineTool({
207
+ name: 'get_heapsnapshot_dominators',
208
+ description: 'Loads a memory heapsnapshot and returns the dominator chain for a specific node ID. This helps to identify which objects are keeping the target node alive.',
209
+ annotations: {
210
+ category: ToolCategory.MEMORY,
211
+ readOnlyHint: true,
212
+ conditions: ['memoryDebugging'],
213
+ },
214
+ blockedByDialog: false,
215
+ verifyFilesSchema: ['filePath'],
216
+ schema: {
217
+ filePath: zod.string().describe('A path to a .heapsnapshot file to read.'),
218
+ nodeId: zod
219
+ .number()
220
+ .describe('The node ID to get the dominator chain for.'),
221
+ },
222
+ handler: async (request, response, context) => {
223
+ const dominators = await context.getHeapSnapshotDominators(request.params.filePath, request.params.nodeId);
224
+ response.setHeapSnapshotDominators(dominators);
225
+ },
226
+ });
151
227
  //# sourceMappingURL=memory.js.map
@@ -36,16 +36,23 @@ export const startScreencast = definePageTool(args => ({
36
36
  response.appendResponseLine('Error: a screencast recording is already in progress. Use screencast_stop to stop it before starting a new one.');
37
37
  return;
38
38
  }
39
- const filePath = request.params.filePath ?? (await generateTempFilePath());
40
- let enforcedExtension = '.mp4';
41
- let format = 'mp4';
42
- for (const supportedExtension of supportedExtensions) {
43
- if (filePath.endsWith(supportedExtension)) {
44
- enforcedExtension = supportedExtension;
45
- format = supportedExtension.substring(1);
46
- break;
47
- }
39
+ const requestedFilePath = request.params.filePath;
40
+ const filePath = requestedFilePath ?? (await generateTempFilePath());
41
+ // Match the extension case-insensitively so e.g. `.WEBM` is recognized as
42
+ // WebM. An explicitly requested but unsupported extension is rejected
43
+ // rather than being silently rewritten to `.mp4` (which would change both
44
+ // the format and the output path from what was requested). A missing
45
+ // extension falls back to `.mp4`. The matched extension is normalized to
46
+ // lower case.
47
+ const requestedExtension = path.extname(filePath);
48
+ const matchedExtension = supportedExtensions.find(supportedExtension => supportedExtension === requestedExtension.toLowerCase());
49
+ if (!matchedExtension && requestedExtension !== '') {
50
+ throw new Error(`Unsupported screencast file extension "${requestedExtension}". ` +
51
+ `Supported formats: ${supportedExtensions.join(', ')} (case-insensitive).`);
48
52
  }
53
+ const enforcedExtension = matchedExtension ?? '.mp4';
54
+ const format = (matchedExtension?.substring(1) ??
55
+ 'mp4');
49
56
  const resolvedPath = ensureExtension(path.resolve(filePath), enforcedExtension);
50
57
  const page = request.page;
51
58
  let recorder;
@@ -57,6 +64,19 @@ export const startScreencast = definePageTool(args => ({
57
64
  });
58
65
  }
59
66
  catch (err) {
67
+ // If we generated a temporary directory for this recording, remove it so
68
+ // a failed start (e.g. ffmpeg missing) does not leak an empty directory.
69
+ if (requestedFilePath === undefined) {
70
+ try {
71
+ await fs.rm(path.dirname(resolvedPath), {
72
+ recursive: true,
73
+ force: true,
74
+ });
75
+ }
76
+ catch {
77
+ // no-op
78
+ }
79
+ }
60
80
  const message = err instanceof Error ? err.message : String(err);
61
81
  if (message.includes('ENOENT') && message.includes('ffmpeg')) {
62
82
  throw new Error('ffmpeg is required for screencast recording but was not found. ' +
@@ -82,6 +102,7 @@ export const stopScreencast = definePageTool({
82
102
  handler: async (_request, response, context) => {
83
103
  const data = context.getScreenRecorder();
84
104
  if (!data) {
105
+ response.appendResponseLine('Error: no active screencast recording to stop.');
85
106
  return;
86
107
  }
87
108
  try {
@@ -6,82 +6,164 @@
6
6
  import { zod } from '../third_party/index.js';
7
7
  import { ToolCategory } from './categories.js';
8
8
  import { definePageTool } from './ToolDefinition.js';
9
- export const screenshot = definePageTool({
10
- name: 'take_screenshot',
11
- description: `Take a screenshot of the page or element.`,
12
- annotations: {
13
- category: ToolCategory.DEBUGGING,
14
- // Not read-only due to filePath param.
15
- readOnlyHint: false,
16
- },
17
- schema: {
18
- format: zod
19
- .enum(['png', 'jpeg', 'webp'])
20
- .default('png')
21
- .describe('Type of format to save the screenshot as. Default is "png"'),
22
- quality: zod
23
- .number()
24
- .min(0)
25
- .max(100)
26
- .optional()
27
- .describe('Compression quality for JPEG and WebP formats (0-100). Higher values mean better quality but larger file sizes. Ignored for PNG format.'),
28
- uid: zod
29
- .string()
30
- .optional()
31
- .describe('The uid of an element on the page from the page content snapshot. If omitted, takes a page screenshot.'),
32
- fullPage: zod
33
- .boolean()
34
- .optional()
35
- .describe('If set to true takes a screenshot of the full page instead of the currently visible viewport. Incompatible with uid.'),
36
- filePath: zod
37
- .string()
38
- .optional()
39
- .describe('The absolute path, or a path relative to the current working directory, to save the screenshot to instead of attaching it to the response.'),
40
- },
41
- blockedByDialog: true,
42
- verifyFilesSchema: ['filePath'],
43
- handler: async (request, response, context) => {
44
- if (request.params.uid && request.params.fullPage) {
45
- throw new Error('Providing both "uid" and "fullPage" is not allowed.');
9
+ async function getSourceBox(page, element, fullPage) {
10
+ if (element) {
11
+ const box = await element.boundingBox();
12
+ return box ?? undefined;
13
+ }
14
+ if (fullPage) {
15
+ const dims = await page.evaluate(() => ({
16
+ width: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
17
+ height: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight ?? 0),
18
+ }));
19
+ if (dims.width <= 0 || dims.height <= 0) {
20
+ return undefined;
46
21
  }
47
- let pageOrHandle;
48
- if (request.params.uid) {
49
- pageOrHandle = await request.page.getElementByUid(request.params.uid);
50
- }
51
- else {
52
- pageOrHandle = request.page.pptrPage;
53
- }
54
- const format = request.params.format;
55
- const quality = format === 'png' ? undefined : request.params.quality;
56
- const screenshot = await pageOrHandle.screenshot({
57
- type: format,
58
- fullPage: request.params.fullPage,
59
- quality,
60
- optimizeForSpeed: true, // Bonus: optimize encoding for speed
61
- });
62
- if (request.params.uid) {
63
- response.appendResponseLine(`Took a screenshot of node with uid "${request.params.uid}".`);
64
- }
65
- else if (request.params.fullPage) {
66
- response.appendResponseLine('Took a screenshot of the full current page.');
67
- }
68
- else {
69
- response.appendResponseLine("Took a screenshot of the current page's viewport.");
70
- }
71
- if (request.params.filePath) {
72
- const result = await context.saveFile(screenshot, request.params.filePath, `.${format}`);
73
- response.appendResponseLine(`Saved screenshot to ${result.filename}.`);
74
- }
75
- else if (screenshot.length >= 2_000_000) {
76
- const { filepath } = await context.saveTemporaryFile(screenshot, `screenshot.${request.params.format}`);
77
- response.appendResponseLine(`Saved screenshot to ${filepath}.`);
78
- }
79
- else {
80
- response.attachImage({
81
- mimeType: `image/${request.params.format}`,
82
- data: Buffer.from(screenshot).toString('base64'),
83
- });
84
- }
85
- },
22
+ return { x: 0, y: 0, width: dims.width, height: dims.height };
23
+ }
24
+ const viewport = page.viewport();
25
+ if (!viewport) {
26
+ return undefined;
27
+ }
28
+ return { x: 0, y: 0, width: viewport.width, height: viewport.height };
29
+ }
30
+ function computeDownscaleClip(box, maxWidth, maxHeight) {
31
+ const widthScale = maxWidth !== undefined ? Math.min(1, maxWidth / box.width) : 1;
32
+ const heightScale = maxHeight !== undefined ? Math.min(1, maxHeight / box.height) : 1;
33
+ const scale = Math.min(widthScale, heightScale);
34
+ if (scale >= 1) {
35
+ return undefined;
36
+ }
37
+ // Skip degenerate sub-pixel results.
38
+ if (Math.round(box.width * scale) < 1 || Math.round(box.height * scale) < 1) {
39
+ return undefined;
40
+ }
41
+ return {
42
+ x: box.x,
43
+ y: box.y,
44
+ width: box.width,
45
+ height: box.height,
46
+ scale,
47
+ };
48
+ }
49
+ export const screenshot = definePageTool(args => {
50
+ const { screenshotFormat, screenshotQuality, screenshotMaxWidth, screenshotMaxHeight, } = args ?? {};
51
+ const defaultFormat = screenshotFormat ?? 'png';
52
+ return {
53
+ name: 'take_screenshot',
54
+ description: `Take a screenshot of the page or element.`,
55
+ annotations: {
56
+ category: ToolCategory.DEBUGGING,
57
+ // Not read-only due to filePath param.
58
+ readOnlyHint: false,
59
+ },
60
+ schema: {
61
+ format: zod
62
+ .enum(['png', 'jpeg', 'webp'])
63
+ .default(defaultFormat)
64
+ .describe(`Type of format to save the screenshot as. Default is "${defaultFormat}"`),
65
+ quality: zod
66
+ .number()
67
+ .min(0)
68
+ .max(100)
69
+ .optional()
70
+ .describe('Compression quality for JPEG and WebP formats (0-100). Higher values mean better quality but larger file sizes. Ignored for PNG format.'),
71
+ uid: zod
72
+ .string()
73
+ .optional()
74
+ .describe('The uid of an element on the page from the page content snapshot. If omitted, takes a page screenshot.'),
75
+ fullPage: zod
76
+ .boolean()
77
+ .optional()
78
+ .describe('If set to true takes a screenshot of the full page instead of the currently visible viewport. Incompatible with uid.'),
79
+ filePath: zod
80
+ .string()
81
+ .optional()
82
+ .describe('The absolute path, or a path relative to the current working directory, to save the screenshot to instead of attaching it to the response.'),
83
+ },
84
+ blockedByDialog: true,
85
+ verifyFilesSchema: ['filePath'],
86
+ handler: async (request, response, context) => {
87
+ if (request.params.uid && request.params.fullPage) {
88
+ throw new Error('Providing both "uid" and "fullPage" is not allowed.');
89
+ }
90
+ const page = request.page.pptrPage;
91
+ const element = request.params.uid
92
+ ? await request.page.getElementByUid(request.params.uid)
93
+ : undefined;
94
+ const format = request.params.format;
95
+ const quality = format === 'png'
96
+ ? undefined
97
+ : (request.params.quality ?? screenshotQuality);
98
+ const fullPage = request.params.fullPage ?? false;
99
+ // Compute a downscale clip when --screenshot-max-width or
100
+ // --screenshot-max-height is set and the source exceeds either bound.
101
+ // The smaller scale factor wins so both bounds are respected while
102
+ // preserving aspect ratio.
103
+ let clip;
104
+ if (screenshotMaxWidth !== undefined ||
105
+ screenshotMaxHeight !== undefined) {
106
+ const box = await getSourceBox(page, element, fullPage);
107
+ if (box) {
108
+ clip = computeDownscaleClip(box, screenshotMaxWidth, screenshotMaxHeight);
109
+ }
110
+ }
111
+ let screenshot;
112
+ if (clip) {
113
+ // page.screenshot with clip lets the CDP scale param downscale the
114
+ // capture for viewport, full-page and element shots alike. We rely on
115
+ // Puppeteer's default of captureBeyondViewport=true when a clip is
116
+ // present so element/full-page captures below the fold still work.
117
+ screenshot = await page.screenshot({
118
+ type: format,
119
+ quality,
120
+ optimizeForSpeed: true,
121
+ clip,
122
+ });
123
+ }
124
+ else if (element) {
125
+ screenshot = await element.screenshot({
126
+ type: format,
127
+ quality,
128
+ optimizeForSpeed: true,
129
+ });
130
+ }
131
+ else {
132
+ screenshot = await page.screenshot({
133
+ type: format,
134
+ fullPage,
135
+ quality,
136
+ optimizeForSpeed: true,
137
+ });
138
+ }
139
+ if (request.params.uid) {
140
+ response.appendResponseLine(`Took a screenshot of node with uid "${request.params.uid}".`);
141
+ }
142
+ else if (fullPage) {
143
+ response.appendResponseLine('Took a screenshot of the full current page.');
144
+ }
145
+ else {
146
+ response.appendResponseLine("Took a screenshot of the current page's viewport.");
147
+ }
148
+ // Narrow `format` at the point of use: in the factory form of
149
+ // definePageTool TS widens the Schema generic, which loses the literal
150
+ // union from zod.enum on request.params.format.
151
+ const extension = format === 'jpeg' ? '.jpeg' : format === 'webp' ? '.webp' : '.png';
152
+ if (request.params.filePath) {
153
+ const result = await context.saveFile(screenshot, request.params.filePath, extension);
154
+ response.appendResponseLine(`Saved screenshot to ${result.filename}.`);
155
+ }
156
+ else if (screenshot.length >= 2_000_000) {
157
+ const { filepath } = await context.saveTemporaryFile(screenshot, `screenshot${extension}`);
158
+ response.appendResponseLine(`Saved screenshot to ${filepath}.`);
159
+ }
160
+ else {
161
+ response.attachImage({
162
+ mimeType: `image/${format}`,
163
+ data: Buffer.from(screenshot).toString('base64'),
164
+ });
165
+ }
166
+ },
167
+ };
86
168
  });
87
169
  //# sourceMappingURL=screenshot.js.map
@@ -64,6 +64,7 @@ export async function checkForUpdates(message) {
64
64
  const child = child_process.spawn(process.execPath, [scriptPath, cachePath], {
65
65
  detached: true,
66
66
  stdio: 'ignore',
67
+ windowsHide: true,
67
68
  });
68
69
  child.unref();
69
70
  }
@@ -5,6 +5,6 @@
5
5
  */
6
6
  // If moved update release-please config
7
7
  // x-release-please-start-version
8
- export const VERSION = '1.2.0';
8
+ export const VERSION = '1.4.0';
9
9
  // x-release-please-end
10
10
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chrome-devtools-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "MCP server for Chrome DevTools",
5
5
  "type": "module",
6
6
  "bin": {
@@ -35,6 +35,7 @@
35
35
  "files": [
36
36
  "build/src",
37
37
  "LICENSE",
38
+ "skills",
38
39
  "!*.tsbuildinfo",
39
40
  "!*.js.map"
40
41
  ],
@@ -57,23 +58,23 @@
57
58
  "@toon-format/toon": "^2.2.0",
58
59
  "@types/debug": "^4.1.12",
59
60
  "@types/filesystem": "^0.0.36",
60
- "@types/node": "^25.0.0",
61
+ "@types/node": "^26.0.0",
61
62
  "@types/semver": "^7.7.1",
62
63
  "@types/sinon": "^21.0.0",
63
64
  "@types/yargs": "^17.0.33",
64
65
  "@typescript-eslint/eslint-plugin": "^8.43.0",
65
66
  "@typescript-eslint/parser": "^8.43.0",
66
- "chrome-devtools-frontend": "1.0.1641723",
67
+ "chrome-devtools-frontend": "1.0.1650035",
67
68
  "core-js": "3.49.0",
68
69
  "debug": "4.4.3",
69
70
  "eslint": "^9.35.0",
70
71
  "eslint-import-resolver-typescript": "^4.4.4",
71
72
  "eslint-plugin-import": "^2.32.0",
72
73
  "globals": "^17.0.0",
73
- "lighthouse": "13.3.0",
74
+ "lighthouse": "13.4.0",
74
75
  "prettier": "^3.6.2",
75
- "puppeteer": "25.1.0",
76
- "rollup": "4.61.0",
76
+ "puppeteer": "25.2.0",
77
+ "rollup": "4.62.2",
77
78
  "rollup-plugin-cleanup": "^3.2.1",
78
79
  "rollup-plugin-license": "^3.6.0",
79
80
  "semver": "^7.7.4",
@@ -0,0 +1,89 @@
1
+ ---
2
+ name: a11y-debugging
3
+ description: Uses Chrome DevTools MCP for accessibility (a11y) debugging and auditing based on web.dev guidelines. Use when testing semantic HTML, ARIA labels, focus states, keyboard navigation, tap targets, and color contrast.
4
+ ---
5
+
6
+ ## Core Concepts
7
+
8
+ **Accessibility Tree vs DOM**: Visually hiding an element (e.g., `CSS opacity: 0`) behaves differently for screen readers than `display: none` or `aria-hidden="true"`. The `take_snapshot` tool returns the accessibility tree of the page, which represents what assistive technologies "see", making it the most reliable source of truth for semantic structure.
9
+
10
+ **Reading web.dev documentation**: If you need to research specific accessibility guidelines (like `https://web.dev/articles/accessible-tap-targets`), you can append `.md.txt` to the URL (e.g., `https://web.dev/articles/accessible-tap-targets.md.txt`) to fetch the clean, raw markdown version. This is much easier to read!
11
+
12
+ ## Workflow Patterns
13
+
14
+ ### 1. Automated Audit (Lighthouse)
15
+
16
+ Start by running a Lighthouse accessibility audit to get a comprehensive baseline. This tool provides a high-level score and lists specific failing elements with remediation advice.
17
+
18
+ 1. Run the audit:
19
+ - Set `mode` to `"navigation"` to refresh the page and capture load issues.
20
+ - Set `outputDirPath` (e.g., `/tmp/lh-report`) to save the full JSON report.
21
+ 2. **Analyze the Summary**:
22
+ - Check `scores` (0-1 scale). A score < 1 indicates violations.
23
+ - Review `audits.failed` count.
24
+ 3. **Review the Report (CRITICAL)**:
25
+ - **Parsing**: Do not read the entire file line-by-line. Use a CLI tool like `jq` or a Node.js one-liner to filter for failures:
26
+ ```bash
27
+ # Extract failing audits with their details
28
+ node -e "const r=require('./report.json'); Object.values(r.audits).filter(a=>a.score!==null && a.score<1).forEach(a=>console.log(JSON.stringify({id:a.id, title:a.title, items:a.details?.items})))"
29
+ ```
30
+ - This efficiently extracts the `selector` and `snippet` of failing elements without loading the full report into context.
31
+
32
+ ### 2. Browser Issues & Audits
33
+
34
+ Chrome automatically checks for common accessibility problems. Use `list_console_messages` to check for these native audits:
35
+
36
+ - `types`: `["issue"]`
37
+ - `includePreservedMessages`: `true` (to catch issues that occurred during page load)
38
+
39
+ This often reveals missing labels, invalid ARIA attributes, and other critical errors without manual investigation.
40
+
41
+ ### 3. Semantics & Structure
42
+
43
+ The accessibility tree exposes the heading hierarchy and semantic landmarks.
44
+
45
+ 1. Navigate to the page.
46
+ 2. Use `take_snapshot` to capture the accessibility tree.
47
+ 3. **Check Heading Levels**: Ensure heading levels (`h1`, `h2`, `h3`, etc.) are logical and do not skip levels. The snapshot will include heading roles.
48
+ 4. **Content Reordering**: Verify that the DOM order (which drives the accessibility tree) matches the visual reading order. Use `take_screenshot` to inspect the visual layout and compare it against the snapshot structure to catch CSS floats or absolute positioning that jumbles the logical flow.
49
+
50
+ ### 4. Labels, Forms & Text Alternatives
51
+
52
+ 1. Locate buttons, inputs, and images in the `take_snapshot` output.
53
+ 2. Ensure interactive elements have an accessible name (e.g., a button should not just say `""` if it only contains an icon).
54
+ 3. **Orphaned Inputs**: Verify that all form inputs have associated labels. Use `evaluate_script` with the **"Find Orphaned Form Inputs" snippet** found in [references/a11y-snippets.md](references/a11y-snippets.md).
55
+ 4. Check images for `alt` text.
56
+
57
+ ### 5. Focus & Keyboard Navigation
58
+
59
+ Testing "keyboard traps" and proper focus management without visual feedback relies on tracking the focused element.
60
+
61
+ 1. Use the `press_key` tool with `"Tab"` or `"Shift+Tab"` to move focus.
62
+ 2. Use `take_snapshot` to capture the updated accessibility tree.
63
+ 3. Locate the element marked as focused in the snapshot to verify focus moved to the expected interactive element.
64
+ 4. If a modal opens, focus must move into the modal and "trap" within it until closed.
65
+
66
+ ### 6. Tap Targets and Visuals
67
+
68
+ According to web.dev, tap targets should be at least 48x48 pixels with sufficient spacing. Since the accessibility tree doesn't show sizes, use `evaluate_script` with the **"Measure Tap Target Size" snippet** found in [references/a11y-snippets.md](references/a11y-snippets.md).
69
+
70
+ _Pass the element's `uid` from the snapshot as an argument to `evaluate_script`._
71
+
72
+ ### 7. Color Contrast
73
+
74
+ To verify color contrast ratios, start by checking for native accessibility issues:
75
+
76
+ 1. Call `list_console_messages` with `types: ["issue"]`.
77
+ 2. Look for "Low Contrast" issues in the output.
78
+
79
+ If native audits do not report issues (which may happen in some headless environments) or if you need to check a specific element manually, use `evaluate_script` with the **"Check Color Contrast" snippet** found in [references/a11y-snippets.md](references/a11y-snippets.md).
80
+
81
+ ### 8. Global Page Checks
82
+
83
+ Verify document-level accessibility settings often missed in component testing using the **"Global Page Checks" snippet** found in [references/a11y-snippets.md](references/a11y-snippets.md).
84
+
85
+ ## Troubleshooting
86
+
87
+ If standard a11y queries fail or the `evaluate_script` snippets return unexpected results:
88
+
89
+ - **Visual Inspection**: If automated scripts cannot determine contrast (e.g., text over gradient images or complex backgrounds), use `take_screenshot` to capture the element. While models cannot measure exact contrast ratios from images, they can visually assess legibility and identify obvious issues.
@@ -0,0 +1,92 @@
1
+ # Accessibility Debugging Snippets
2
+
3
+ Use these JavaScript snippets with the `evaluate_script` tool.
4
+
5
+ ## 1. Find Orphaned Form Inputs
6
+
7
+ Finds form inputs that lack an associated label (no `label[for]`, `aria-label`, `aria-labelledby`, or wrapping `<label>`).
8
+
9
+ ```js
10
+ () =>
11
+ Array.from(document.querySelectorAll('input, select, textarea'))
12
+ .filter(i => {
13
+ const hasId = i.id && document.querySelector(`label[for="${i.id}"]`);
14
+ const hasAria =
15
+ i.getAttribute('aria-label') || i.getAttribute('aria-labelledby');
16
+ return !hasId && !hasAria && !i.closest('label');
17
+ })
18
+ .map(i => ({
19
+ tag: i.tagName,
20
+ id: i.id,
21
+ name: i.name,
22
+ placeholder: i.placeholder,
23
+ }));
24
+ ```
25
+
26
+ ## 2. Measure Tap Target Size
27
+
28
+ Returns the bounding box dimensions of an element. Pass the element's `uid` from the snapshot as an argument to `evaluate_script`.
29
+
30
+ ```js
31
+ el => {
32
+ const rect = el.getBoundingClientRect();
33
+ return {width: rect.width, height: rect.height};
34
+ };
35
+ ```
36
+
37
+ ## 3. Check Color Contrast
38
+
39
+ Approximates the contrast ratio between an element's text color and background color. Pass the element's `uid` to test against WCAG AA (4.5:1 for normal text, 3:1 for large text).
40
+
41
+ **Note**: This uses a simplified algorithm and may not account for transparency, gradients, or background images. For production-grade auditing, consider injecting `axe-core`.
42
+
43
+ ```js
44
+ el => {
45
+ function getRGB(colorStr) {
46
+ const match = colorStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
47
+ return match
48
+ ? [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])]
49
+ : [255, 255, 255];
50
+ }
51
+ function luminance(r, g, b) {
52
+ const a = [r, g, b].map(function (v) {
53
+ v /= 255;
54
+ return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
55
+ });
56
+ return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
57
+ }
58
+
59
+ const style = window.getComputedStyle(el);
60
+ const fg = getRGB(style.color);
61
+ let bg = getRGB(style.backgroundColor);
62
+
63
+ const l1 = luminance(fg[0], fg[1], fg[2]);
64
+ const l2 = luminance(bg[0], bg[1], bg[2]);
65
+ const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
66
+
67
+ return {
68
+ color: style.color,
69
+ bg: style.backgroundColor,
70
+ contrastRatio: ratio.toFixed(2),
71
+ };
72
+ };
73
+ ```
74
+
75
+ ## 4. Global Page Checks
76
+
77
+ Checks document-level accessibility settings often missed in component testing.
78
+
79
+ ```js
80
+ () => ({
81
+ lang:
82
+ document.documentElement.lang ||
83
+ 'MISSING - Screen readers need this for pronunciation',
84
+ title: document.title || 'MISSING - Required for context',
85
+ viewport:
86
+ document.querySelector('meta[name="viewport"]')?.content ||
87
+ 'MISSING - Check for user-scalable=no (bad practice)',
88
+ reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches
89
+ ? 'Enabled'
90
+ : 'Disabled',
91
+ });
92
+ ```