refactor(lighthouse): move Lighthouse runner methods to McpPage and add bundle types (#3283)

Moves Lighthouse runner invocations (`lighthouseNavigation` and
`lighthouseSnapshot`) onto `McpPage` / `ContextPage`, adds type
declarations for `src/third_party/lighthouse-devtools-mcp-bundle.js`,
and copies the bundle in `scripts/post-build.ts`. No user-visible
behavior change.
This commit is contained in:
Nikolay Vitkov authored and GitHub committed 2026-10-21 10:59:29 +00:00
1 parent b20f63b798
commit e1823a2f59
7 files changed
+86 -55

No files matched your search

+9
View File
@@ -178,6 +178,15 @@ export const ExperimentName = {
fs.copyFileSync(src, dest);
}
// Copy lighthouse bundle that tsc ignores due to lighthouse-devtools-mcp-bundle.d.ts
const lighthouseBundle = 'src/third_party/lighthouse-devtools-mcp-bundle.js';
const lighthouseBundleDest = path.join(BUILD_DIR, lighthouseBundle);
fs.mkdirSync(path.dirname(lighthouseBundleDest), {recursive: true});
fs.copyFileSync(
path.join(process.cwd(), lighthouseBundle),
lighthouseBundleDest,
);
copyDevToolsDescriptionFiles();
}
+16
View File
@@ -31,7 +31,11 @@ import {
type ConsoleMessage,
type HTTPRequest,
DevTools,
type Flags,
type JSONSchema7Definition,
lighthouseNavigation,
lighthouseSnapshot,
type RunnerResult,
} from './third_party/index.js';
import type {ToolGroups} from './tools/thirdPartyDeveloper.js';
import type {
@@ -1474,6 +1478,18 @@ export class McpPage implements ContextPage {
};
}
lighthouseNavigation(flags?: Flags): Promise<RunnerResult | undefined> {
return lighthouseNavigation(this.pptrPage, this.pptrPage.url(), {
flags,
});
}
lighthouseSnapshot(flags?: Flags): Promise<RunnerResult | undefined> {
return lighthouseSnapshot(this.pptrPage, {
flags,
});
}
/**
* We need to ignore favicon request as they make our test flaky
*/
+17 -15
View File
@@ -114,24 +114,26 @@ function loadLighthouse(): Promise<typeof LighthouseBundle> {
return lighthouseBundle;
}
export const lighthouseRunner = {
async snapshot(page: Page, options: {flags?: Flags}): Promise<RunnerResult> {
const {snapshot} = await loadLighthouse();
return await snapshot(page, options);
},
async navigation(
page: Page,
url: string,
options: {flags?: Flags},
): Promise<RunnerResult> {
const {navigation} = await loadLighthouse();
return await navigation(page, url, options);
},
};
export async function lighthouseSnapshot(
page: Page,
options: {flags?: Flags},
): Promise<RunnerResult | undefined> {
const {snapshot} = await loadLighthouse();
return await snapshot(page, options);
}
export async function lighthouseNavigation(
page: Page,
url: string,
options: {flags?: Flags},
): Promise<RunnerResult | undefined> {
const {navigation} = await loadLighthouse();
return await navigation(page, url, options);
}
export async function generateReport(
lhr: Result,
format: string,
format: OutputMode,
): Promise<string> {
const lighthouse = await loadLighthouse();
return lighthouse.generateReport(lhr, format);
+21
View File
@@ -0,0 +1,21 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {Flags, OutputMode, Result, RunnerResult} from 'lighthouse';
import type {Page} from 'puppeteer-core';
export declare function snapshot(
page: Page,
options: {flags?: Flags},
): Promise<RunnerResult | undefined>;
export declare function navigation(
page: Page,
url: string,
options: {flags?: Flags},
): Promise<RunnerResult | undefined>;
export declare function generateReport(lhr: Result, format: OutputMode): string;
+4
View File
@@ -35,8 +35,10 @@ import type {
UninstallPWAOptions,
Viewport,
DevTools,
Flags,
Protocol,
Page,
RunnerResult,
WebMCPTool,
} from '../third_party/index.js';
import type {InsightName, TraceResult} from '../processors/PerformanceTrace.js';
@@ -545,6 +547,8 @@ export type ContextPage = Readonly<{
ensureDevToolsCommentBridge(
devtoolsPage: Page,
): Promise<DevToolsCommentBridge | undefined>;
lighthouseNavigation(flags?: Flags): Promise<RunnerResult | undefined>;
lighthouseSnapshot(flags?: Flags): Promise<RunnerResult | undefined>;
}>;
export function defineTool<Schema extends zod.ZodRawShape>(
+4 -9
View File
@@ -9,7 +9,6 @@ import path from 'node:path';
import type {ParsedArguments} from '../config/ConfigParser.js';
import {
lighthouseRunner,
generateReport,
zod,
type Flags,
@@ -202,7 +201,7 @@ export const lighthouseAudit = definePageTool((args: ParsedArguments) => ({
'best-practices',
'agentic-browsing',
];
const formats = ['json', 'html'] as OutputMode[];
const formats: OutputMode[] = ['json', 'html'];
const {
mode = 'navigation',
device = 'desktop',
@@ -241,13 +240,11 @@ export const lighthouseAudit = definePageTool((args: ParsedArguments) => ({
let result: RunnerResult | undefined;
try {
if (mode === 'navigation') {
const url = page.pptrPage.url();
try {
result = await lighthouseRunner.navigation(page.pptrPage, url, {
flags,
});
result = await page.lighthouseNavigation(flags);
} catch (error) {
if (isInvalidUrlError(error)) {
const url = page.pptrPage.url();
throw new Error(
`Lighthouse "navigation" mode cannot audit ${url}: it only supports http:, https:, chrome: and chrome-extension: URLs. Use mode "snapshot" to audit the page in its current state.`,
{cause: error},
@@ -256,9 +253,7 @@ export const lighthouseAudit = definePageTool((args: ParsedArguments) => ({
throw error;
}
} else {
result = await lighthouseRunner.snapshot(page.pptrPage, {
flags,
});
result = await page.lighthouseSnapshot(flags);
}
if (!result) {
+15 -31
View File
@@ -11,7 +11,6 @@ import {describe, it} from 'node:test';
import sinon from 'sinon';
import {lighthouseRunner} from '../../src/third_party/index.js';
import {lighthouseAudit} from '../../src/tools/lighthouse.js';
import {createHandlerMocks, createMockRunnerResult} from '../mocks.js';
import {serverHooks} from '../server.js';
@@ -90,7 +89,7 @@ describe('lighthouse', () => {
const invalidUrl = Object.assign(new Error('INVALID_URL'), {
code: 'INVALID_URL',
});
sinon.stub(lighthouseRunner, 'navigation').rejects(invalidUrl);
page.lighthouseNavigation.rejects(invalidUrl);
await assert.rejects(
() =>
@@ -115,9 +114,7 @@ describe('lighthouse', () => {
it('passes other navigation errors through unchanged', async () => {
const {page, context, response, args} = createHandlerMocks();
sinon
.stub(lighthouseRunner, 'navigation')
.rejects(new Error('PROTOCOL_TIMEOUT'));
page.lighthouseNavigation.rejects(new Error('PROTOCOL_TIMEOUT'));
await assert.rejects(
() =>
@@ -139,9 +136,7 @@ describe('lighthouse', () => {
it('restores emulation', async () => {
const {page, context, response, args} = createHandlerMocks();
context.saveTemporaryFile.resolves({filepath: 'report.json'});
sinon
.stub(lighthouseRunner, 'snapshot')
.resolves(createMockRunnerResult());
page.lighthouseSnapshot.resolves(createMockRunnerResult());
await lighthouseAudit(args).handler(
{
@@ -160,9 +155,7 @@ describe('lighthouse', () => {
it('restores emulation even when audit fails', async () => {
const {page, context, response, args} = createHandlerMocks();
sinon
.stub(lighthouseRunner, 'snapshot')
.rejects(new Error('Audit failed'));
page.lighthouseSnapshot.rejects(new Error('Audit failed'));
await assert.rejects(
() =>
@@ -186,7 +179,7 @@ describe('lighthouse', () => {
it('fails when Lighthouse reports a runtime error', async () => {
const {page, context, response, args} = createHandlerMocks();
context.saveTemporaryFile.resolves({filepath: 'report.json'});
sinon.stub(lighthouseRunner, 'navigation').resolves(
page.lighthouseNavigation.resolves(
createMockRunnerResult({
runtimeError: {
code: 'ERRORED_DOCUMENT_REQUEST',
@@ -222,9 +215,7 @@ describe('lighthouse', () => {
it('emulates a desktop user agent for desktop audits', async () => {
const {page, context, response, args} = createHandlerMocks();
context.saveTemporaryFile.resolves({filepath: 'report.json'});
const navigation = sinon
.stub(lighthouseRunner, 'navigation')
.resolves(createMockRunnerResult());
page.lighthouseNavigation.resolves(createMockRunnerResult());
await lighthouseAudit(args).handler(
{
@@ -238,7 +229,7 @@ describe('lighthouse', () => {
context,
);
const {flags} = navigation.firstCall.args[2];
const [flags] = page.lighthouseNavigation.firstCall.args;
assert.equal(flags?.formFactor, 'desktop');
assert.match(String(flags?.emulatedUserAgent), /Macintosh/);
assert.doesNotMatch(String(flags?.emulatedUserAgent), /Mobile/);
@@ -247,9 +238,7 @@ describe('lighthouse', () => {
it('emulates a mobile user agent for mobile audits', async () => {
const {page, context, response, args} = createHandlerMocks();
context.saveTemporaryFile.resolves({filepath: 'report.json'});
const snapshot = sinon
.stub(lighthouseRunner, 'snapshot')
.resolves(createMockRunnerResult());
page.lighthouseSnapshot.resolves(createMockRunnerResult());
await lighthouseAudit(args).handler(
{
@@ -263,7 +252,7 @@ describe('lighthouse', () => {
context,
);
const {flags} = snapshot.firstCall.args[1];
const [flags] = page.lighthouseSnapshot.firstCall.args;
assert.equal(flags?.formFactor, 'mobile');
assert.match(String(flags?.emulatedUserAgent), /Mobile Safari/);
});
@@ -271,7 +260,7 @@ describe('lighthouse', () => {
it('reports the URL in snapshot mode, where mainDocumentUrl is unset', async () => {
const {page, context, response, args} = createHandlerMocks();
context.saveTemporaryFile.resolves({filepath: 'report.json'});
sinon.stub(lighthouseRunner, 'snapshot').resolves(
page.lighthouseSnapshot.resolves(
createMockRunnerResult({
mainDocumentUrl: undefined,
finalDisplayedUrl: 'https://example.com/page',
@@ -305,10 +294,7 @@ describe('lighthouse', () => {
context.saveTemporaryFile
.withArgs(sinon.match.instanceOf(Uint8Array), 'report.html')
.resolves({filepath: '/tmp/report.html'});
const snapshot = sinon
.stub(lighthouseRunner, 'snapshot')
.resolves(createMockRunnerResult());
const navigation = sinon.stub(lighthouseRunner, 'navigation');
page.lighthouseSnapshot.resolves(createMockRunnerResult());
await lighthouseAudit(args).handler(
{
@@ -322,8 +308,8 @@ describe('lighthouse', () => {
context,
);
sinon.assert.calledOnce(snapshot);
sinon.assert.notCalled(navigation);
sinon.assert.calledOnce(page.lighthouseSnapshot);
sinon.assert.notCalled(page.lighthouseNavigation);
sinon.assert.calledTwice(context.saveTemporaryFile);
sinon.assert.notCalled(context.saveFile);
sinon.assert.calledOnce(response.attachLighthouseResult);
@@ -344,9 +330,7 @@ describe('lighthouse', () => {
context.saveFile
.withArgs(sinon.match.instanceOf(Uint8Array), reportPath, '.html')
.resolves({filename: `${reportPath}.html`});
sinon
.stub(lighthouseRunner, 'snapshot')
.resolves(createMockRunnerResult());
page.lighthouseSnapshot.resolves(createMockRunnerResult());
await lighthouseAudit(args).handler(
{
@@ -389,7 +373,7 @@ describe('lighthouse', () => {
snippet: `<div class="item-${id}">`,
nodeLabel: `Item ${id}`,
});
sinon.stub(lighthouseRunner, 'snapshot').resolves(
page.lighthouseSnapshot.resolves(
createMockRunnerResult({
categories: {
accessibility: {