From e1823a2f59072598ced3b2a1a5f7dc3caa1404dd Mon Sep 17 00:00:00 2001 From: Nikolay Vitkov <34244704+Lightning00Blade@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:59:29 +0000 Subject: [PATCH] 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. --- scripts/post-build.ts | 9 ++++ src/McpPage.ts | 16 +++++++ src/third_party/index.ts | 32 +++++++------ .../lighthouse-devtools-mcp-bundle.d.ts | 21 +++++++++ src/tools/ToolDefinition.ts | 4 ++ src/tools/lighthouse.ts | 13 ++---- tests/tools/lighthouse.test.ts | 46 ++++++------------- 7 files changed, 86 insertions(+), 55 deletions(-) create mode 100644 src/third_party/lighthouse-devtools-mcp-bundle.d.ts diff --git a/scripts/post-build.ts b/scripts/post-build.ts index 8bf7fcdb..026dad9e 100644 --- a/scripts/post-build.ts +++ b/scripts/post-build.ts @@ -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(); } diff --git a/src/McpPage.ts b/src/McpPage.ts index 329407be..cabd02d6 100644 --- a/src/McpPage.ts +++ b/src/McpPage.ts @@ -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 { + return lighthouseNavigation(this.pptrPage, this.pptrPage.url(), { + flags, + }); + } + + lighthouseSnapshot(flags?: Flags): Promise { + return lighthouseSnapshot(this.pptrPage, { + flags, + }); + } + /** * We need to ignore favicon request as they make our test flaky */ diff --git a/src/third_party/index.ts b/src/third_party/index.ts index 6dee7b17..3b9a6558 100644 --- a/src/third_party/index.ts +++ b/src/third_party/index.ts @@ -114,24 +114,26 @@ function loadLighthouse(): Promise { return lighthouseBundle; } -export const lighthouseRunner = { - async snapshot(page: Page, options: {flags?: Flags}): Promise { - const {snapshot} = await loadLighthouse(); - return await snapshot(page, options); - }, - async navigation( - page: Page, - url: string, - options: {flags?: Flags}, - ): Promise { - const {navigation} = await loadLighthouse(); - return await navigation(page, url, options); - }, -}; +export async function lighthouseSnapshot( + page: Page, + options: {flags?: Flags}, +): Promise { + const {snapshot} = await loadLighthouse(); + return await snapshot(page, options); +} + +export async function lighthouseNavigation( + page: Page, + url: string, + options: {flags?: Flags}, +): Promise { + const {navigation} = await loadLighthouse(); + return await navigation(page, url, options); +} export async function generateReport( lhr: Result, - format: string, + format: OutputMode, ): Promise { const lighthouse = await loadLighthouse(); return lighthouse.generateReport(lhr, format); diff --git a/src/third_party/lighthouse-devtools-mcp-bundle.d.ts b/src/third_party/lighthouse-devtools-mcp-bundle.d.ts new file mode 100644 index 00000000..e9118609 --- /dev/null +++ b/src/third_party/lighthouse-devtools-mcp-bundle.d.ts @@ -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; + +export declare function navigation( + page: Page, + url: string, + options: {flags?: Flags}, +): Promise; + +export declare function generateReport(lhr: Result, format: OutputMode): string; diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 3b5e9bd8..b0a83b56 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -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; + lighthouseNavigation(flags?: Flags): Promise; + lighthouseSnapshot(flags?: Flags): Promise; }>; export function defineTool( diff --git a/src/tools/lighthouse.ts b/src/tools/lighthouse.ts index a8507c6b..ac8f32eb 100644 --- a/src/tools/lighthouse.ts +++ b/src/tools/lighthouse.ts @@ -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) { diff --git a/tests/tools/lighthouse.test.ts b/tests/tools/lighthouse.test.ts index 8f5ffa24..874fdecd 100644 --- a/tests/tools/lighthouse.test.ts +++ b/tests/tools/lighthouse.test.ts @@ -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: `
`, nodeLabel: `Item ${id}`, }); - sinon.stub(lighthouseRunner, 'snapshot').resolves( + page.lighthouseSnapshot.resolves( createMockRunnerResult({ categories: { accessibility: {