feat: integrate Lighthouse audits (#831)

This PR adds tool to perform navigation and snapshot audits using
Lighthouse for Accessibility, SEO and Best practices. For performance
audits existing tools can be used.

Closes https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/473
This commit is contained in:
Alex Rudenko authored and GitHub committed 2026-02-26 12:30:45 +00:00
1 parent caf601a328
commit dfdac2648e
28 files changed
+59411 -42

No files matched your search

+1
View File
@@ -1,2 +1,3 @@
# Prettier-only ignores.
CHANGELOG.md
src/third_party/lighthouse-devtools-mcp-bundle.js
+2 -1
View File
@@ -442,9 +442,10 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles
- **Network** (2 tools)
- [`get_network_request`](docs/tool-reference.md#get_network_request)
- [`list_network_requests`](docs/tool-reference.md#list_network_requests)
- **Debugging** (5 tools)
- **Debugging** (6 tools)
- [`evaluate_script`](docs/tool-reference.md#evaluate_script)
- [`get_console_message`](docs/tool-reference.md#get_console_message)
- [`lighthouse_audit`](docs/tool-reference.md#lighthouse_audit)
- [`list_console_messages`](docs/tool-reference.md#list_console_messages)
- [`take_screenshot`](docs/tool-reference.md#take_screenshot)
- [`take_snapshot`](docs/tool-reference.md#take_snapshot)
+15 -2
View File
@@ -1,6 +1,6 @@
<!-- AUTO GENERATED DO NOT EDIT - run 'npm run docs' to update-->
# Chrome DevTools MCP Tool Reference (~7084 cl100k_base tokens)
# Chrome DevTools MCP Tool Reference (~7324 cl100k_base tokens)
- **[Input automation](#input-automation)** (9 tools)
- [`click`](#click)
@@ -30,9 +30,10 @@
- **[Network](#network)** (2 tools)
- [`get_network_request`](#get_network_request)
- [`list_network_requests`](#list_network_requests)
- **[Debugging](#debugging)** (5 tools)
- **[Debugging](#debugging)** (6 tools)
- [`evaluate_script`](#evaluate_script)
- [`get_console_message`](#get_console_message)
- [`lighthouse_audit`](#lighthouse_audit)
- [`list_console_messages`](#list_console_messages)
- [`take_screenshot`](#take_screenshot)
- [`take_snapshot`](#take_snapshot)
@@ -345,6 +346,18 @@ so returned values have to be JSON-serializable.
---
### `lighthouse_audit`
**Description:** Get Lighthouse score and reports for accessibility, SEO and best practices.
**Parameters:**
- **device** (enum: "desktop", "mobile") _(optional)_: Device to [`emulate`](#emulate).
- **mode** (enum: "navigation", "snapshot") _(optional)_: "navigation" reloads &amp; audits. "snapshot" analyzes current state.
- **outputDirPath** (string) _(optional)_: Directory for reports. If omitted, uses temporary files.
---
### `list_console_messages`
**Description:** List all console messages for the currently selected page since the last navigation.
+6 -1
View File
@@ -14,7 +14,12 @@ import tseslint from 'typescript-eslint';
import localPlugin from './scripts/eslint_rules/local-plugin.js';
export default defineConfig([
globalIgnores(['**/node_modules', '**/build/', 'tests/tools/fixtures/']),
globalIgnores([
'**/node_modules',
'**/build/',
'tests/tools/fixtures/',
'src/third_party/lighthouse-devtools-mcp-bundle.js',
]),
importPlugin.flatConfigs.typescript,
{
languageOptions: {
+1537
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -7,7 +7,7 @@
"main": "./build/src/server.js",
"scripts": {
"clean": "node -e \"require('fs').rmSync('build', {recursive: true, force: true})\"",
"bundle": "npm run clean && npm run build && rollup -c rollup.config.mjs && node -e \"require('fs').rmSync('build/node_modules', {recursive: true, force: true})\"",
"bundle": "npm run clean && npm run build && rollup -c rollup.config.mjs && node -e \"require('fs').rmSync('build/node_modules', {recursive: true, force: true})\" && node --experimental-strip-types scripts/append-lighthouse-notices.ts",
"build": "tsc && node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/post-build.ts",
"typecheck": "tsc --noEmit",
"format": "eslint --cache --fix . && prettier --write --cache .",
@@ -22,6 +22,7 @@
"test:update-snapshots": "npm run build && node scripts/test.mjs --test-update-snapshots",
"prepare": "node --experimental-strip-types scripts/prepare.ts",
"verify-server-json-version": "node --experimental-strip-types scripts/verify-server-json-version.ts",
"update-lighthouse": "node --experimental-strip-types scripts/update-lighthouse.ts",
"verify-npm-package": "node scripts/verify-npm-package.mjs",
"eval": "npm run build && node --experimental-strip-types scripts/eval_gemini.ts",
"count-tokens": "node --experimental-strip-types scripts/count_tokens.ts"
@@ -61,6 +62,7 @@
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"globals": "^17.0.0",
"lighthouse": "13.0.2",
"prettier": "^3.6.2",
"puppeteer": "24.37.5",
"rollup": "4.59.0",
+24 -3
View File
@@ -107,7 +107,8 @@ function listBundledDeps() {
Object.entries(devDependencies).filter(
([name]) =>
aggregatedStats.bundledPackages.has(name) ||
name === 'chrome-devtools-frontend',
name === 'chrome-devtools-frontend' ||
name === 'lighthouse',
),
);
@@ -210,6 +211,22 @@ const bundleDependency = (
);
}
// Add chrome-devtools-frontend main license
const lighthouseLicensePath = path.join(
process.cwd(),
'node_modules/lighthouse/LICENSE',
);
if (fs.existsSync(lighthouseLicensePath)) {
manualLicenses.push(
[
'Name: lighthouse',
'License: Apache-2.0',
'',
fs.readFileSync(lighthouseLicensePath, 'utf-8'),
].join('\n'),
);
}
for (const thirdPartyDir of thirdPartyDirectories) {
const fullPath = path.join(process.cwd(), thirdPartyDir);
const licenseFile = path.join(fullPath, 'LICENSE');
@@ -260,11 +277,15 @@ export default [
return true;
}
const existingExternals = ['./bidi.js', '../bidi/bidi.js'];
const existingExternals = [
'./bidi.js',
'../bidi/bidi.js',
'./lighthouse-devtools-mcp-bundle.js',
];
if (existingExternals.includes(source)) {
return true;
}
return false;
},
),
+32
View File
@@ -0,0 +1,32 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import path from 'node:path';
const ROOT_DIR = process.cwd();
const TARGET_DIR = path.join(ROOT_DIR, 'build/src/third_party');
const SOURCE_DIR = path.join(ROOT_DIR, 'src/third_party');
function main() {
const lighthouseNotices = fs.readFileSync(
path.join(SOURCE_DIR, 'LIGHTHOUSE_MCP_BUNDLE_THIRD_PARTY_NOTICES'),
'utf8',
);
const bundledNotices = fs.readFileSync(
path.join(TARGET_DIR, 'THIRD_PARTY_NOTICES'),
'utf8',
);
fs.writeFileSync(
path.join(TARGET_DIR, 'THIRD_PARTY_NOTICES'),
bundledNotices +
'\n\n-------------------- DEPENDENCY DIVIDER --------------------\n\n' +
lighthouseNotices,
);
console.log('Done.');
}
main();
@@ -0,0 +1,21 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import type {TestScenario} from '../eval_gemini.ts';
export const scenario: TestScenario = {
prompt: 'Check a11y issues on the current page',
maxTurns: 1,
expectations: calls => {
assert.strictEqual(calls.length, 1);
assert.ok(
calls[0].name === 'lighthouse_audit',
'First call should be lighthouse_audit',
);
},
};
@@ -0,0 +1,21 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import type {TestScenario} from '../eval_gemini.ts';
export const scenario: TestScenario = {
prompt: 'Check for best practices on the current page',
maxTurns: 1,
expectations: calls => {
assert.strictEqual(calls.length, 1);
assert.ok(
calls[0].name === 'lighthouse_audit',
'First call should be lighthouse_audit',
);
},
};
+28
View File
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {readFileSync, writeFileSync} from 'node:fs';
import {rm} from 'node:fs/promises';
import {resolve} from 'node:path';
@@ -15,6 +16,31 @@ const filesToRemove = [
'node_modules/chrome-devtools-frontend/front_end/third_party/intl-messageformat/package/package.json',
];
/**
* Removes the conflicting global HTMLElementEventMap declaration from
* @paulirish/trace_engine/models/trace/ModelImpl.d.ts to avoid TS2717 error
* when both chrome-devtools-frontend and @paulirish/trace_engine declare
* the same property.
*/
function removeConflictingGlobalDeclaration(): void {
const filePath = resolve(
projectRoot,
'node_modules/@paulirish/trace_engine/models/trace/ModelImpl.d.ts',
);
console.log(
'Removing conflicting global declaration from @paulirish/trace_engine...',
);
const content = readFileSync(filePath, 'utf-8');
// Remove the declare global block using regex
// Matches: declare global { ... interface HTMLElementEventMap { ... } ... }
const newContent = content.replace(
/declare global\s*\{\s*interface HTMLElementEventMap\s*\{[^}]*\[ModelUpdateEvent\.eventName\]:\s*ModelUpdateEvent;\s*\}\s*\}/s,
'',
);
writeFileSync(filePath, newContent, 'utf-8');
console.log('Successfully removed conflicting global declaration.');
}
async function main() {
console.log('Running prepare script to clean up chrome-devtools-frontend...');
for (const file of filesToRemove) {
@@ -28,6 +54,8 @@ async function main() {
}
}
console.log('Clean up of chrome-devtools-frontend complete.');
removeConflictingGlobalDeclaration();
}
void main();
+54
View File
@@ -0,0 +1,54 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {execSync} from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const ROOT_DIR = process.cwd();
const LIGHTHOUSE_DIR = path.resolve(ROOT_DIR, '../lighthouse');
const DEST_DIR = path.join(ROOT_DIR, 'src/third_party');
function main() {
if (!fs.existsSync(LIGHTHOUSE_DIR)) {
console.error(`Lighthouse directory not found at ${LIGHTHOUSE_DIR}`);
process.exit(1);
}
console.log('Running yarn in lighthouse directory...');
execSync('yarn', {cwd: LIGHTHOUSE_DIR, stdio: 'inherit'});
console.log('Building lighthouse-devtools-mcp bundle...');
execSync('yarn build-devtools-mcp', {cwd: LIGHTHOUSE_DIR, stdio: 'inherit'});
const bundlePath = path.join(
LIGHTHOUSE_DIR,
'dist',
'lighthouse-devtools-mcp-bundle.js',
);
console.log(`Copying bundle from ${bundlePath} to ${DEST_DIR}...`);
fs.copyFileSync(
bundlePath,
path.join(DEST_DIR, 'lighthouse-devtools-mcp-bundle.js'),
);
const noticesPath = path.join(
LIGHTHOUSE_DIR,
'dist',
'LIGHTHOUSE_MCP_BUNDLE_THIRD_PARTY_NOTICES',
);
console.log(`Copying notices from ${noticesPath} to ${DEST_DIR}...`);
fs.copyFileSync(
noticesPath,
path.join(DEST_DIR, 'LIGHTHOUSE_MCP_BUNDLE_THIRD_PARTY_NOTICES'),
);
console.log('Done.');
}
main();
+16 -23
View File
@@ -93,18 +93,6 @@ function getNetworkMultiplierFromString(condition: string | null): number {
return 1;
}
function getExtensionFromMimeType(mimeType: string) {
switch (mimeType) {
case 'image/png':
return 'png';
case 'image/jpeg':
return 'jpeg';
case 'image/webp':
return 'webp';
}
throw new Error(`No mapping for Mime type ${mimeType}.`);
}
export class McpContext implements Context {
browser: Browser;
logger: Debugger;
@@ -334,6 +322,13 @@ export class McpContext implements Context {
return this.#networkCollector.getById(this.#resolveTargetPage(), reqid);
}
async restoreEmulation(targetPage?: Page) {
const page = targetPage ?? this.getSelectedPage();
const mcpPage = this.#getMcpPage(page);
const currentSetting = mcpPage.emulationSettings;
await this.emulate(currentSetting, targetPage);
}
async emulate(
options: {
networkConditions?: string | null;
@@ -1002,22 +997,19 @@ export class McpContext implements Context {
async saveTemporaryFile(
data: Uint8Array<ArrayBufferLike>,
mimeType: 'image/png' | 'image/jpeg' | 'image/webp',
): Promise<{filename: string}> {
filename: string,
): Promise<{filepath: string}> {
try {
const dir = await fs.mkdtemp(
path.join(os.tmpdir(), 'chrome-devtools-mcp-'),
);
const filename = path.join(
dir,
`screenshot.${getExtensionFromMimeType(mimeType)}`,
);
await fs.writeFile(filename, data);
return {filename};
const filepath = path.join(dir, filename);
await fs.writeFile(filepath, data);
return {filepath};
} catch (err) {
this.logger(err);
throw new Error('Could not save a screenshot to a file', {cause: err});
throw new Error('Could not save a file', {cause: err});
}
}
async saveFile(
@@ -1026,11 +1018,12 @@ export class McpContext implements Context {
): Promise<{filename: string}> {
try {
const filePath = path.resolve(filename);
await fs.mkdir(path.dirname(filePath), {recursive: true});
await fs.writeFile(filePath, data);
return {filename};
return {filename: filePath};
} catch (err) {
this.logger(err);
throw new Error('Could not save a screenshot to a file', {cause: err});
throw new Error('Could not save a file', {cause: err});
}
}
+36
View File
@@ -22,6 +22,7 @@ import {handleDialog} from './tools/pages.js';
import type {
DevToolsData,
ImageContentData,
LighthouseData,
Response,
SnapshotParams,
} from './tools/ToolDefinition.js';
@@ -49,6 +50,7 @@ export class McpResponse implements Response {
#attachedConsoleMessageId?: number;
#attachedTraceSummary?: TraceResult;
#attachedTraceInsight?: TraceInsightData;
#attachedLighthouseResult?: LighthouseData;
#textResponseLines: string[] = [];
#images: ImageContentData[] = [];
#networkRequestsOptions?: {
@@ -181,6 +183,10 @@ export class McpResponse implements Response {
};
}
attachLighthouseResult(result: LighthouseData): void {
this.#attachedLighthouseResult = result;
}
get includePages(): boolean {
return this.#includePages;
}
@@ -193,6 +199,10 @@ export class McpResponse implements Response {
return this.#attachedTraceInsight;
}
get attachedLighthouseResult(): LighthouseData | undefined {
return this.#attachedLighthouseResult;
}
get includeNetworkRequests(): boolean {
return this.#networkRequestsOptions?.include ?? false;
}
@@ -412,6 +422,7 @@ export class McpResponse implements Response {
traceInsight: this.#attachedTraceInsight,
traceSummary: this.#attachedTraceSummary,
extensions,
lighthouseResult: this.#attachedLighthouseResult,
});
}
@@ -427,6 +438,7 @@ export class McpResponse implements Response {
traceSummary?: TraceResult;
traceInsight?: TraceInsightData;
extensions?: InstalledExtension[];
lighthouseResult?: LighthouseData;
},
): {content: Array<TextContent | ImageContent>; structuredContent: object} {
const structuredContent: {
@@ -439,6 +451,7 @@ export class McpResponse implements Response {
consoleMessages?: object[];
traceSummary?: string;
traceInsights?: Array<{insightName: string; insightKey: string}>;
lighthouseResult?: object;
extensions?: object[];
message?: string;
networkConditions?: string;
@@ -601,6 +614,29 @@ Call ${handleDialog.name} to handle it before continuing.`);
}
}
if (data.lighthouseResult) {
structuredContent.lighthouseResult = data.lighthouseResult;
const {summary, reports} = data.lighthouseResult;
response.push('## Lighthouse Audit Results');
response.push(`Mode: ${summary.mode}`);
response.push(`Device: ${summary.device}`);
response.push(`URL: ${summary.url}`);
response.push('### Category Scores');
for (const score of summary.scores) {
response.push(
`- ${score.title}: ${(score.score ?? 0) * 100} (${score.id})`,
);
}
response.push('### Audit Summary');
response.push(`Passed: ${summary.audits.passed}`);
response.push(`Failed: ${summary.audits.failed}`);
response.push(`Total Timing: ${summary.timing.total}ms`);
response.push('### Reports');
for (const report of reports) {
response.push(`- ${report}`);
}
}
if (data.snapshot) {
if (typeof data.snapshot === 'string') {
response.push(`Saved snapshot to ${data.snapshot}.`);
File diff suppressed because it is too large. Load diff
+25
View File
@@ -8,6 +8,11 @@ import 'core-js/modules/es.promise.with-resolvers.js';
import 'core-js/modules/es.set.union.v2.js';
import 'core-js/proposals/iterator-helpers.js';
import type {Flags, OutputMode, Result, RunnerResult} from 'lighthouse';
import type {Page} from 'puppeteer-core';
export type {Flags, Result, RunnerResult, OutputMode};
export type {Options as YargsOptions} from 'yargs';
export {default as yargs} from 'yargs';
export {hideBin} from 'yargs/helpers';
@@ -39,4 +44,24 @@ export {
type ChromeReleaseChannel as BrowsersChromeReleaseChannel,
} from '@puppeteer/browsers';
import {
snapshot as snapshotImpl,
navigation as navigationImpl,
generateReport as generateReportImpl,
} from './lighthouse-devtools-mcp-bundle.js';
export const snapshot = snapshotImpl as (
page: Page,
options: {flags?: Flags},
) => Promise<RunnerResult>;
export const navigation = navigationImpl as (
page: Page,
url: string,
options: {flags?: Flags},
) => Promise<RunnerResult>;
export const generateReport = generateReportImpl as (
lhr: Result,
format: string,
) => string;
export * as DevTools from '../../node_modules/chrome-devtools-frontend/mcp/mcp.js';
File diff suppressed because it is too large. Load diff
+25 -2
View File
@@ -65,6 +65,27 @@ export interface SnapshotParams {
page?: Page;
}
export interface LighthouseData {
summary: {
mode: string;
device: string;
url?: string;
scores: Array<{
id: string;
title: string;
score: number | null;
}>;
audits: {
failed: number;
passed: number;
};
timing: {
total: number;
};
};
reports: string[];
}
export interface DevToolsData {
cdpRequestId?: string;
cdpBackendNodeId?: number;
@@ -105,6 +126,7 @@ export interface Response {
insightName: InsightName,
): void;
setListExtensions(): void;
attachLighthouseResult(result: LighthouseData): void;
}
/**
@@ -127,6 +149,7 @@ export type Context = Readonly<{
assertPageIsFocused(page: Page): void;
getElementByUid(uid: string, page?: Page): Promise<ElementHandle<Element>>;
getAXNodeByUid(uid: string): TextSnapshotNode | undefined;
restoreEmulation(): Promise<void>;
emulate(
options: {
networkConditions?: string | null;
@@ -140,8 +163,8 @@ export type Context = Readonly<{
): Promise<void>;
saveTemporaryFile(
data: Uint8Array<ArrayBufferLike>,
mimeType: 'image/png' | 'image/jpeg' | 'image/webp',
): Promise<{filename: string}>;
filename: string,
): Promise<{filepath: string}>;
saveFile(
data: Uint8Array<ArrayBufferLike>,
filename: string,
+155
View File
@@ -0,0 +1,155 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import {
snapshot,
navigation,
generateReport,
zod,
type Flags,
type RunnerResult,
type OutputMode,
} from '../third_party/index.js';
import {ToolCategory} from './categories.js';
import {defineTool} from './ToolDefinition.js';
export const lighthouseAudit = defineTool({
name: 'lighthouse_audit',
description: `Get Lighthouse score and reports for accessibility, SEO and best practices.`,
annotations: {
category: ToolCategory.DEBUGGING,
readOnlyHint: true,
pageScoped: true,
},
schema: {
mode: zod
.enum(['navigation', 'snapshot'])
.default('navigation')
.describe(
'"navigation" reloads & audits. "snapshot" analyzes current state.',
),
device: zod
.enum(['desktop', 'mobile'])
.default('desktop')
.describe('Device to emulate.'),
outputDirPath: zod
.string()
.optional()
.describe('Directory for reports. If omitted, uses temporary files.'),
},
handler: async (request, response, context) => {
const page = request.page!;
const categories = ['accessibility', 'seo', 'best-practices'];
const formats = ['json', 'html'] as OutputMode[];
const {
mode = 'navigation',
device = 'desktop',
outputDirPath,
} = request.params;
const flags: Flags = {
onlyCategories: categories,
output: formats,
// Default 30 second timeout for page load.
maxWaitForLoad: 30_000,
};
if (device === 'desktop') {
flags.formFactor = 'desktop';
flags.screenEmulation = {
mobile: false,
width: 1350,
height: 940,
deviceScaleFactor: 1,
disabled: false,
};
} else {
flags.formFactor = 'mobile';
flags.screenEmulation = {
mobile: true,
width: 412,
height: 823,
deviceScaleFactor: 1.75,
disabled: false,
};
}
let result: RunnerResult | undefined;
try {
if (mode === 'navigation') {
result = await navigation(page, page.url(), {
flags,
});
} else {
result = await snapshot(page, {
flags,
});
}
if (!result) {
throw new Error('Lighthouse audit failed.');
}
} finally {
await context.restoreEmulation();
}
const lhr = result.lhr;
const reportPaths: string[] = [];
const encoder = new TextEncoder();
for (const format of formats) {
const report = generateReport(lhr, format);
const data = encoder.encode(report);
if (outputDirPath) {
const reportPath = path.join(outputDirPath, `report.${format}`);
const {filename} = await context.saveFile(data, reportPath);
reportPaths.push(filename);
} else {
const {filepath} = await context.saveTemporaryFile(
data,
`report.${format}`,
);
reportPaths.push(filepath);
}
}
const categoryScores = Object.values(lhr.categories).map(c => ({
id: c.id,
title: c.title,
score: c.score,
}));
const failedAudits = Object.values(lhr.audits).filter(
a => a.score !== null && a.score < 1,
).length;
const passedAudits = Object.values(lhr.audits).filter(
a => a.score === 1,
).length;
const output = {
summary: {
mode,
device,
url: lhr.mainDocumentUrl,
scores: categoryScores,
audits: {
failed: failedAudits,
passed: passedAudits,
},
timing: {
total: lhr.timing.total,
},
},
reports: reportPaths,
};
response.attachLighthouseResult(output);
},
});
+3 -3
View File
@@ -94,11 +94,11 @@ export const screenshot = defineTool({
const file = await context.saveFile(screenshot, request.params.filePath);
response.appendResponseLine(`Saved screenshot to ${file.filename}.`);
} else if (screenshot.length >= 2_000_000) {
const {filename} = await context.saveTemporaryFile(
const {filepath} = await context.saveTemporaryFile(
screenshot,
`image/${request.params.format}`,
`screenshot.${request.params.format}`,
);
response.appendResponseLine(`Saved screenshot to ${filename}.`);
response.appendResponseLine(`Saved screenshot to ${filepath}.`);
} else {
response.attachImage({
mimeType: `image/${request.params.format}`,
+5 -2
View File
@@ -24,8 +24,11 @@ export const screenshot = defineTool({
type: 'png',
optimizeForSpeed: true,
});
const {filename} = await context.saveTemporaryFile(screenshot, `image/png`);
response.appendResponseLine(filename);
const {filepath} = await context.saveTemporaryFile(
screenshot,
`screenshot.png`,
);
response.appendResponseLine(filepath);
},
});
+2
View File
@@ -10,6 +10,7 @@ import * as consoleTools from './console.js';
import * as emulationTools from './emulation.js';
import * as extensionTools from './extensions.js';
import * as inputTools from './input.js';
import * as lighthouseTools from './lighthouse.js';
import * as memoryTools from './memory.js';
import * as networkTools from './network.js';
import * as pagesTools from './pages.js';
@@ -29,6 +30,7 @@ export const createTools = (args: ParsedArguments) => {
...Object.values(emulationTools),
...Object.values(extensionTools),
...Object.values(inputTools),
...Object.values(lighthouseTools),
...Object.values(memoryTools),
...Object.values(networkTools),
...Object.values(pagesTools),
+47
View File
@@ -1208,3 +1208,50 @@ exports[`extensions > lists extensions 2`] = `
]
}
`;
exports[`lighthouse > includes lighthouse report paths 1`] = `
# test response
## Lighthouse Audit Results
Mode: navigation
Device: desktop
URL: https://example.com
### Category Scores
- Performance: 90 (performance)
### Audit Summary
Passed: 10
Failed: 1
Total Timing: 1000ms
### Reports
- /tmp/report.json
- /tmp/report.html
`;
exports[`lighthouse > includes lighthouse report paths 2`] = `
{
"lighthouseResult": {
"summary": {
"mode": "navigation",
"device": "desktop",
"url": "https://example.com",
"scores": [
{
"id": "performance",
"title": "Performance",
"score": 0.9
}
],
"audits": {
"failed": 1,
"passed": 10
},
"timing": {
"total": 1000
}
},
"reports": [
"/tmp/report.json",
"/tmp/report.html"
]
}
}
`;
+45
View File
@@ -971,3 +971,48 @@ describe('extensions', () => {
});
});
});
describe('lighthouse', () => {
it('includes lighthouse report paths', async t => {
await withMcpContext(async (response, context) => {
const lighthouseResult = {
summary: {
mode: 'navigation',
device: 'desktop',
url: 'https://example.com',
scores: [
{
id: 'performance',
title: 'Performance',
score: 0.9,
},
],
audits: {
failed: 1,
passed: 10,
},
timing: {
total: 1000,
},
},
reports: ['/tmp/report.json', '/tmp/report.html'],
};
response.attachLighthouseResult(lighthouseResult);
const {content, structuredContent} = await response.handle(
'test',
context,
);
const text = getTextContent(content[0]);
assert.ok(text.includes('### Reports'));
assert.ok(text.includes('- /tmp/report.json'));
assert.ok(text.includes('- /tmp/report.html'));
t.assert.snapshot?.(getTextContent(content[0]));
t.assert.snapshot?.(
JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2),
);
});
});
});
File diff suppressed because it is too large. Load diff
+3 -4
View File
@@ -16,10 +16,9 @@ describe('THIRD_PARTY_NOTICES', () => {
);
if (fs.existsSync(noticesPath)) {
const content = fs.readFileSync(noticesPath, 'utf-8');
const normalizedContent = content.replace(
/^Version: .*$/gm,
'Version: <VERSION>',
);
const normalizedContent = content
.replace(/^Version: .*$/gm, 'Version: <VERSION>')
.replaceAll('\r', '');
t.assert.snapshot?.(normalizedContent);
}
});
+193
View File
@@ -0,0 +1,193 @@
exports[`lighthouse > lighthouse_audit > runs Lighthouse accessibility audit 1`] = `
{
"Accessibility": {
"score": 0.94,
"failedAudits": [
{
"id": "landmark-one-main",
"title": "Document does not have a main landmark.",
"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).",
"score": 0,
"scoreDisplayMode": "binary",
"details": {
"type": "table",
"headings": [
{
"key": "node",
"valueType": "node",
"subItemsHeading": {
"key": "relatedNode",
"valueType": "node"
},
"label": "Failing Elements"
}
],
"items": [
{
"node": {
"type": "node",
"lhId": "1-0-HTML",
"path": "1,HTML",
"selector": "html",
"boundingRect": {
"top": 0,
"bottom": 34,
"left": 0,
"right": 1200,
"width": 1200,
"height": 34
},
"snippet": "<html lang=\\"en\\">",
"nodeLabel": "html",
"explanation": "Fix all of the following:\\n Document does not have a main landmark"
}
}
],
"debugData": {
"type": "debugdata",
"impact": "moderate",
"tags": [
"cat.semantics",
"best-practice"
]
}
}
}
],
"passedAudits": [
{
"id": "aria-hidden-body",
"title": "\`[aria-hidden=\\"true\\"]\` is not present on the document \`<body>\`",
"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).",
"score": 1,
"scoreDisplayMode": "binary",
"details": {
"type": "table",
"headings": [
{
"key": "node",
"valueType": "node",
"subItemsHeading": {
"key": "relatedNode",
"valueType": "node"
},
"label": "Failing Elements"
}
],
"items": []
}
},
{
"id": "color-contrast",
"title": "Background and foreground colors have a sufficient contrast ratio",
"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).",
"score": 1,
"scoreDisplayMode": "binary",
"details": {
"type": "table",
"headings": [
{
"key": "node",
"valueType": "node",
"subItemsHeading": {
"key": "relatedNode",
"valueType": "node"
},
"label": "Failing Elements"
}
],
"items": []
}
},
{
"id": "document-title",
"title": "Document has a \`<title>\` element",
"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).",
"score": 1,
"scoreDisplayMode": "binary",
"details": {
"type": "table",
"headings": [
{
"key": "node",
"valueType": "node",
"subItemsHeading": {
"key": "relatedNode",
"valueType": "node"
},
"label": "Failing Elements"
}
],
"items": []
}
},
{
"id": "html-has-lang",
"title": "\`<html>\` element has a \`[lang]\` attribute",
"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).",
"score": 1,
"scoreDisplayMode": "binary",
"details": {
"type": "table",
"headings": [
{
"key": "node",
"valueType": "node",
"subItemsHeading": {
"key": "relatedNode",
"valueType": "node"
},
"label": "Failing Elements"
}
],
"items": []
}
},
{
"id": "html-lang-valid",
"title": "\`<html>\` element has a valid value for its \`[lang]\` attribute",
"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).",
"score": 1,
"scoreDisplayMode": "binary",
"details": {
"type": "table",
"headings": [
{
"key": "node",
"valueType": "node",
"subItemsHeading": {
"key": "relatedNode",
"valueType": "node"
},
"label": "Failing Elements"
}
],
"items": []
}
},
{
"id": "meta-viewport",
"title": "\`[user-scalable=\\"no\\"]\` is not used in the \`<meta name=\\"viewport\\">\` element and the \`[maximum-scale]\` attribute is not less than 5.",
"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).",
"score": 1,
"scoreDisplayMode": "binary",
"details": {
"type": "table",
"headings": [
{
"key": "node",
"valueType": "node",
"subItemsHeading": {
"key": "relatedNode",
"valueType": "node"
},
"label": "Failing Elements"
}
],
"items": []
}
}
]
}
}
`;
+185
View File
@@ -0,0 +1,185 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {describe, it} from 'node:test';
import {lighthouseAudit} from '../../src/tools/lighthouse.js';
import {serverHooks} from '../server.js';
import {html, withMcpContext} from '../utils.js';
describe('lighthouse', () => {
const server = serverHooks();
describe('lighthouse_audit', () => {
it('runs Lighthouse audit by default (navigation, desktop)', async () => {
server.addHtmlRoute('/test', html`<div>Test</div>`);
await withMcpContext(async (response, context) => {
const page = context.getSelectedPage();
await page.goto(server.getRoute('/test'));
await lighthouseAudit.handler(
{
params: {
mode: 'navigation',
device: 'desktop',
},
},
response,
context,
);
const data = response.attachedLighthouseResult;
assert.ok(data);
assert.ok(data.summary);
assert.equal(data.summary.mode, 'navigation');
assert.equal(data.summary.device, 'desktop');
assert.ok(data.reports.length === 2); // json, html
// Verify files exist
for (const reportPath of data.reports) {
const stats = await fs.stat(reportPath);
assert.ok(stats.isFile());
}
});
});
it('restores emulation', async () => {
server.addHtmlRoute('/test-mobile', html`<div>Test Mobile</div>`);
await withMcpContext(async (response, context) => {
const page = context.getSelectedPage();
await page.goto(server.getRoute('/test-mobile'));
await context.emulate({
viewport: {
width: 400,
height: 400,
deviceScaleFactor: 1,
hasTouch: true,
},
});
{
const viewportData = await page.evaluate(() => {
return {
width: window.innerWidth,
height: window.innerHeight,
deviceScaleFactor: window.devicePixelRatio,
hasTouch: navigator.maxTouchPoints > 0,
};
});
assert.deepStrictEqual(viewportData, {
width: 400,
height: 400,
deviceScaleFactor: 1,
hasTouch: true,
});
}
await lighthouseAudit.handler(
{
params: {
mode: 'snapshot',
device: 'mobile',
},
},
response,
context,
);
{
const viewportData = await page.evaluate(() => {
return {
width: window.innerWidth,
height: window.innerHeight,
deviceScaleFactor: window.devicePixelRatio,
hasTouch: navigator.maxTouchPoints > 0,
};
});
assert.deepStrictEqual(viewportData, {
width: 400,
height: 400,
deviceScaleFactor: 1,
hasTouch: true,
});
}
});
});
it('runs Lighthouse in snapshot mode with mobile device', async () => {
server.addHtmlRoute('/test-mobile', html`<div>Test Mobile</div>`);
await withMcpContext(async (response, context) => {
const page = context.getSelectedPage();
await page.goto(server.getRoute('/test-mobile'));
await lighthouseAudit.handler(
{
params: {
mode: 'snapshot',
device: 'mobile',
},
},
response,
context,
);
const data = response.attachedLighthouseResult;
assert.ok(data);
assert.equal(data.summary.mode, 'snapshot');
assert.equal(data.summary.device, 'mobile');
assert.ok(data.reports.length === 2);
});
});
it('runs Lighthouse with custom output dir', async () => {
server.addHtmlRoute('/test-mobile', html`<div>Test Mobile</div>`);
const tmpDir = os.tmpdir();
const folderPath = path.join(
tmpDir,
`temp-folder-${crypto.randomUUID()}`,
);
try {
await withMcpContext(async (response, context) => {
const page = context.getSelectedPage();
await page.goto(server.getRoute('/test-mobile'));
await lighthouseAudit.handler(
{
params: {
mode: 'snapshot',
device: 'mobile',
outputDirPath: folderPath,
},
},
response,
context,
);
const data = response.attachedLighthouseResult;
assert.ok(data);
assert.equal(data.summary.mode, 'snapshot');
assert.equal(data.summary.device, 'mobile');
assert.ok(data.reports.length === 2);
for (const report of data.reports) {
assert.ok(report.startsWith(folderPath));
}
});
} finally {
await fs.rm(folderPath, {recursive: true, force: true});
}
});
});
});