Skip to content

Commit 76c7d57

Browse files
committed
feat: Lighthouse audits
1 parent 8e215e3 commit 76c7d57

File tree

8 files changed

+1909
-48
lines changed

8 files changed

+1909
-48
lines changed

package-lock.json

Lines changed: 1603 additions & 48 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"eslint-import-resolver-typescript": "^4.4.4",
6262
"eslint-plugin-import": "^2.32.0",
6363
"globals": "^17.0.0",
64+
"lighthouse": "^13.0.1",
6465
"prettier": "^3.6.2",
6566
"puppeteer": "24.36.0",
6667
"rollup": "4.56.0",

scripts/prepare.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7+
import {readFileSync, writeFileSync} from 'node:fs';
78
import {rm} from 'node:fs/promises';
89
import {resolve} from 'node:path';
910

@@ -15,6 +16,29 @@ const filesToRemove = [
1516
'node_modules/chrome-devtools-frontend/front_end/third_party/intl-messageformat/package/package.json',
1617
];
1718

19+
/**
20+
* Removes the conflicting global HTMLElementEventMap declaration from
21+
* @paulirish/trace_engine/models/trace/ModelImpl.d.ts to avoid TS2717 error
22+
* when both chrome-devtools-frontend and @paulirish/trace_engine declare
23+
* the same property.
24+
*/
25+
function removeConflictingGlobalDeclaration(): void {
26+
const filePath = resolve(
27+
projectRoot,
28+
'node_modules/@paulirish/trace_engine/models/trace/ModelImpl.d.ts',
29+
);
30+
console.log('Removing conflicting global declaration from @paulirish/trace_engine...');
31+
const content = readFileSync(filePath, 'utf-8');
32+
// Remove the declare global block using regex
33+
// Matches: declare global { ... interface HTMLElementEventMap { ... } ... }
34+
const newContent = content.replace(
35+
/declare global\s*\{\s*interface HTMLElementEventMap\s*\{[^}]*\[ModelUpdateEvent\.eventName\]:\s*ModelUpdateEvent;\s*\}\s*\}/s,
36+
'',
37+
);
38+
writeFileSync(filePath, newContent, 'utf-8');
39+
console.log('Successfully removed conflicting global declaration.');
40+
}
41+
1842
async function main() {
1943
console.log('Running prepare script to clean up chrome-devtools-frontend...');
2044
for (const file of filesToRemove) {
@@ -28,6 +52,8 @@ async function main() {
2852
}
2953
}
3054
console.log('Clean up of chrome-devtools-frontend complete.');
55+
56+
removeConflictingGlobalDeclaration();
3157
}
3258

3359
void main();

src/third_party/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,5 @@ export {
3838
type ChromeReleaseChannel as BrowsersChromeReleaseChannel,
3939
} from '@puppeteer/browsers';
4040

41+
export * as Lighthouse from 'lighthouse';
4142
export * as DevTools from '../../node_modules/chrome-devtools-frontend/mcp/mcp.js';

src/tools/lighthouse.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {Lighthouse, zod} from '../third_party/index.js';
8+
9+
import {ToolCategory} from './categories.js';
10+
import {defineTool} from './ToolDefinition.js';
11+
12+
export const lighthouseAudit = defineTool({
13+
name: 'lighthouse_audit',
14+
description: `Runs a Lighthouse accessibility audit on the currently selected page. This tool analyzes the page for accessibility issues and provides a detailed report with scores and recommendations.`,
15+
annotations: {
16+
category: ToolCategory.PERFORMANCE,
17+
readOnlyHint: true,
18+
},
19+
schema: {},
20+
handler: async (request, response, context) => {
21+
const page = context.getSelectedPage();
22+
const url = page.url();
23+
24+
const flags = {
25+
onlyCategories: ['accessibility'],
26+
};
27+
28+
const result = await Lighthouse.default(url, flags, undefined, page);
29+
const lhr = result!.lhr;
30+
const accessibilityCategory = lhr.categories.accessibility;
31+
32+
const failedAudits = Object.values(lhr.audits).filter(
33+
audit => audit.score !== null && audit.score < 1,
34+
);
35+
const passedAudits = Object.values(lhr.audits).filter(
36+
audit => audit.score === 1,
37+
);
38+
39+
const output = {
40+
Accessibility: {
41+
score: accessibilityCategory?.score ?? null,
42+
failedAudits,
43+
passedAudits,
44+
},
45+
};
46+
47+
response.appendResponseLine(JSON.stringify(output, null, 2));
48+
},
49+
});

src/tools/tools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as consoleTools from './console.js';
88
import * as emulationTools from './emulation.js';
99
import * as extensionTools from './extensions.js';
1010
import * as inputTools from './input.js';
11+
import * as lighthouseTools from './lighthouse.js';
1112
import * as networkTools from './network.js';
1213
import * as pagesTools from './pages.js';
1314
import * as performanceTools from './performance.js';
@@ -21,6 +22,7 @@ const tools = [
2122
...Object.values(emulationTools),
2223
...Object.values(extensionTools),
2324
...Object.values(inputTools),
25+
...Object.values(lighthouseTools),
2426
...Object.values(networkTools),
2527
...Object.values(pagesTools),
2628
...Object.values(performanceTools),
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
exports[`lighthouse > lighthouse_audit > runs Lighthouse accessibility audit 1`] = `
2+
{
3+
"Accessibility": {
4+
"score": 0.94,
5+
"failedAudits": [
6+
{
7+
"id": "landmark-one-main",
8+
"title": "Document does not have a main landmark.",
9+
"description": "One main landmark helps screen reader users navigate a web page. [Learn more about landmarks](https://dequeuniversity.com/rules/axe/4.11/landmark-one-main).",
10+
"score": 0,
11+
"scoreDisplayMode": "binary",
12+
"details": {
13+
"type": "table",
14+
"headings": [
15+
{
16+
"key": "node",
17+
"valueType": "node",
18+
"subItemsHeading": {
19+
"key": "relatedNode",
20+
"valueType": "node"
21+
},
22+
"label": "Failing Elements"
23+
}
24+
],
25+
"items": [
26+
{
27+
"node": {
28+
"type": "node",
29+
"lhId": "1-0-HTML",
30+
"path": "1,HTML",
31+
"selector": "html",
32+
"boundingRect": {
33+
"top": 0,
34+
"bottom": 34,
35+
"left": 0,
36+
"right": 412,
37+
"width": 412,
38+
"height": 34
39+
},
40+
"snippet": "<html lang=\\"en\\">",
41+
"nodeLabel": "html",
42+
"explanation": "Fix all of the following:\\n Document does not have a main landmark"
43+
}
44+
}
45+
],
46+
"debugData": {
47+
"type": "debugdata",
48+
"impact": "moderate",
49+
"tags": [
50+
"cat.semantics",
51+
"best-practice"
52+
]
53+
}
54+
}
55+
}
56+
],
57+
"passedAudits": [
58+
{
59+
"id": "aria-hidden-body",
60+
"title": "\`[aria-hidden=\\"true\\"]\` is not present on the document \`<body>\`",
61+
"description": "Assistive technologies, like screen readers, work inconsistently when \`aria-hidden=\\"true\\"\` is set on the document \`<body>\`. [Learn how \`aria-hidden\` affects the document body](https://dequeuniversity.com/rules/axe/4.11/aria-hidden-body).",
62+
"score": 1,
63+
"scoreDisplayMode": "binary",
64+
"details": {
65+
"type": "table",
66+
"headings": [
67+
{
68+
"key": "node",
69+
"valueType": "node",
70+
"subItemsHeading": {
71+
"key": "relatedNode",
72+
"valueType": "node"
73+
},
74+
"label": "Failing Elements"
75+
}
76+
],
77+
"items": []
78+
}
79+
},
80+
{
81+
"id": "color-contrast",
82+
"title": "Background and foreground colors have a sufficient contrast ratio",
83+
"description": "Low-contrast text is difficult or impossible for many users to read. [Learn how to provide sufficient color contrast](https://dequeuniversity.com/rules/axe/4.11/color-contrast).",
84+
"score": 1,
85+
"scoreDisplayMode": "binary",
86+
"details": {
87+
"type": "table",
88+
"headings": [
89+
{
90+
"key": "node",
91+
"valueType": "node",
92+
"subItemsHeading": {
93+
"key": "relatedNode",
94+
"valueType": "node"
95+
},
96+
"label": "Failing Elements"
97+
}
98+
],
99+
"items": []
100+
}
101+
},
102+
{
103+
"id": "document-title",
104+
"title": "Document has a \`<title>\` element",
105+
"description": "The title gives screen reader users an overview of the page, and search engine users rely on it heavily to determine if a page is relevant to their search. [Learn more about document titles](https://dequeuniversity.com/rules/axe/4.11/document-title).",
106+
"score": 1,
107+
"scoreDisplayMode": "binary",
108+
"details": {
109+
"type": "table",
110+
"headings": [
111+
{
112+
"key": "node",
113+
"valueType": "node",
114+
"subItemsHeading": {
115+
"key": "relatedNode",
116+
"valueType": "node"
117+
},
118+
"label": "Failing Elements"
119+
}
120+
],
121+
"items": []
122+
}
123+
},
124+
{
125+
"id": "html-has-lang",
126+
"title": "\`<html>\` element has a \`[lang]\` attribute",
127+
"description": "If a page doesn't specify a \`lang\` attribute, a screen reader assumes that the page is in the default language that the user chose when setting up the screen reader. If the page isn't actually in the default language, then the screen reader might not announce the page's text correctly. [Learn more about the \`lang\` attribute](https://dequeuniversity.com/rules/axe/4.11/html-has-lang).",
128+
"score": 1,
129+
"scoreDisplayMode": "binary",
130+
"details": {
131+
"type": "table",
132+
"headings": [
133+
{
134+
"key": "node",
135+
"valueType": "node",
136+
"subItemsHeading": {
137+
"key": "relatedNode",
138+
"valueType": "node"
139+
},
140+
"label": "Failing Elements"
141+
}
142+
],
143+
"items": []
144+
}
145+
},
146+
{
147+
"id": "html-lang-valid",
148+
"title": "\`<html>\` element has a valid value for its \`[lang]\` attribute",
149+
"description": "Specifying a valid [BCP 47 language](https://www.w3.org/International/questions/qa-choosing-language-tags#question) helps screen readers announce text properly. [Learn how to use the \`lang\` attribute](https://dequeuniversity.com/rules/axe/4.11/html-lang-valid).",
150+
"score": 1,
151+
"scoreDisplayMode": "binary",
152+
"details": {
153+
"type": "table",
154+
"headings": [
155+
{
156+
"key": "node",
157+
"valueType": "node",
158+
"subItemsHeading": {
159+
"key": "relatedNode",
160+
"valueType": "node"
161+
},
162+
"label": "Failing Elements"
163+
}
164+
],
165+
"items": []
166+
}
167+
},
168+
{
169+
"id": "meta-viewport",
170+
"title": "\`[user-scalable=\\"no\\"]\` is not used in the \`<meta name=\\"viewport\\">\` element and the \`[maximum-scale]\` attribute is not less than 5.",
171+
"description": "Disabling zooming is problematic for users with low vision who rely on screen magnification to properly see the contents of a web page. [Learn more about the viewport meta tag](https://dequeuniversity.com/rules/axe/4.11/meta-viewport).",
172+
"score": 1,
173+
"scoreDisplayMode": "binary",
174+
"details": {
175+
"type": "table",
176+
"headings": [
177+
{
178+
"key": "node",
179+
"valueType": "node",
180+
"subItemsHeading": {
181+
"key": "relatedNode",
182+
"valueType": "node"
183+
},
184+
"label": "Failing Elements"
185+
}
186+
],
187+
"items": []
188+
}
189+
}
190+
]
191+
}
192+
}
193+
`;

tests/tools/lighthouse.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import assert from 'node:assert';
8+
import {describe, it} from 'node:test';
9+
10+
import {lighthouseAudit} from '../../src/tools/lighthouse.js';
11+
import {serverHooks} from '../server.js';
12+
import {html, withMcpContext} from '../utils.js';
13+
14+
describe('lighthouse', () => {
15+
const server = serverHooks();
16+
describe('lighthouse_audit', () => {
17+
it(
18+
'runs Lighthouse accessibility audit',
19+
async (t) => {
20+
server.addHtmlRoute('/test', html`<div>Test</div>`);
21+
22+
await withMcpContext(async (response, context) => {
23+
const page = context.getSelectedPage();
24+
await page.goto(server.getRoute('/test'));
25+
26+
await lighthouseAudit.handler({params: {}}, response, context);
27+
28+
const responseText = response.responseLines.join('\n');
29+
t.assert.snapshot?.(responseText);
30+
});
31+
},
32+
);
33+
});
34+
});

0 commit comments

Comments
 (0)