mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-11-04 15:00:11 +00:00
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:
1 parent
caf601a328
commit
dfdac2648e
28 files changed
+59411
-42
No files matched your search
@@ -1,2 +1,3 @@
|
||||
# Prettier-only ignores.
|
||||
CHANGELOG.md
|
||||
src/third_party/lighthouse-devtools-mcp-bundle.js
|
||||
@@ -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
@@ -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 & 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
@@ -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: {
|
||||
|
||||
Generated
+1537
File diff suppressed because it is too large.
Load diff
+3
-1
@@ -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
@@ -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;
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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
@@ -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});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Vendored
+25
@@ -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';
|
||||
+54649
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user