diff --git a/scripts/post-build.ts b/scripts/post-build.ts index 5fa66d5b..b520d6a0 100644 --- a/scripts/post-build.ts +++ b/scripts/post-build.ts @@ -63,7 +63,7 @@ export const i18n = { return str; }, lockedLazyString: () => {}, - getLazilyComputedLocalizedString: () => {}, + getLazilyComputedLocalizedString: () => ()=>{}, }; // TODO(jacktfranklin): once the DocumentLatency insight does not depend on @@ -169,6 +169,15 @@ export const hostConfig = {}; fs.copyFileSync(devtoolsLicenseFileSource, devtoolsLicenseFileDestination); copyThirdPartyLicenseFiles(); + copyDevToolsDescriptionFiles(); +} + +function copyDevToolsDescriptionFiles() { + const devtoolsIssuesDescriptionPath = + 'node_modules/chrome-devtools-frontend/front_end/models/issues_manager/descriptions'; + const sourceDir = path.join(process.cwd(), devtoolsIssuesDescriptionPath); + const destDir = path.join(BUILD_DIR, devtoolsIssuesDescriptionPath); + fs.cpSync(sourceDir, destDir, {recursive: true}); } main(); diff --git a/src/DevtoolsUtils.ts b/src/DevtoolsUtils.ts index 8b12b3a9..27067ec0 100644 --- a/src/DevtoolsUtils.ts +++ b/src/DevtoolsUtils.ts @@ -3,6 +3,13 @@ * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ + +import { + type Issue, + type IssuesManagerEventTypes, + Common, +} from '../node_modules/chrome-devtools-frontend/mcp/mcp.js'; + export function extractUrlLikeFromDevToolsTitle( title: string, ): string | undefined { @@ -49,3 +56,14 @@ function normalizeUrl(url: string): string { return result; } + +/** + * A mock implementation of an issues manager that only implements the methods + * that are actually used by the IssuesAggregator + */ +export class FakeIssuesManager extends Common.ObjectWrapper + .ObjectWrapper { + issues(): Issue[] { + return []; + } +} diff --git a/src/McpContext.ts b/src/McpContext.ts index 5853b718..76b70515 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -7,9 +7,11 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import {type AggregatedIssue} from '../node_modules/chrome-devtools-frontend/mcp/mcp.js'; + import {extractUrlLikeFromDevToolsTitle, urlsEqual} from './DevtoolsUtils.js'; import type {ListenerMap} from './PageCollector.js'; -import {NetworkCollector, PageCollector} from './PageCollector.js'; +import {NetworkCollector, ConsoleCollector} from './PageCollector.js'; import {Locator} from './third_party/index.js'; import type { Browser, @@ -92,7 +94,7 @@ export class McpContext implements Context { // The most recent snapshot. #textSnapshot: TextSnapshot | null = null; #networkCollector: NetworkCollector; - #consoleCollector: PageCollector; + #consoleCollector: ConsoleCollector; #isRunningTrace = false; #networkConditionsMap = new WeakMap(); @@ -122,7 +124,7 @@ export class McpContext implements Context { this.#options.experimentalIncludeAllPages, ); - this.#consoleCollector = new PageCollector( + this.#consoleCollector = new ConsoleCollector( this.browser, collect => { return { @@ -138,6 +140,9 @@ export class McpContext implements Context { collect(error); } }, + issue: event => { + collect(event); + }, } as ListenerMap; }, this.#options.experimentalIncludeAllPages, @@ -205,16 +210,18 @@ export class McpContext implements Context { getConsoleData( includePreservedMessages?: boolean, - ): Array { + ): Array { const page = this.getSelectedPage(); return this.#consoleCollector.getData(page, includePreservedMessages); } - getConsoleMessageStableId(message: ConsoleMessage | Error): number { + getConsoleMessageStableId( + message: ConsoleMessage | Error | AggregatedIssue, + ): number { return this.#consoleCollector.getIdForResource(message); } - getConsoleMessageById(id: number): ConsoleMessage | Error { + getConsoleMessageById(id: number): ConsoleMessage | Error | AggregatedIssue { return this.#consoleCollector.getById(this.getSelectedPage(), id); } diff --git a/src/McpResponse.ts b/src/McpResponse.ts index da3d01df..737456d3 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -3,6 +3,12 @@ * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ +import { + AggregatedIssue, + Marked, + findTitleFromMarkdownAst, +} from '../node_modules/chrome-devtools-frontend/mcp/mcp.js'; + import type {ConsoleMessageData} from './formatters/consoleFormatter.js'; import { formatConsoleEventShort, @@ -16,6 +22,8 @@ import { getStatusFromRequest, } from './formatters/networkFormatter.js'; import {formatSnapshotNode} from './formatters/snapshotFormatter.js'; +import {getIssueDescription} from './issue-descriptions.js'; +import {logger} from './logger.js'; import type {McpContext} from './McpContext.js'; import type { ConsoleMessage, @@ -269,40 +277,70 @@ export class McpResponse implements Response { if ('type' in message) { return normalizedTypes.has(message.type()); } + if (message instanceof AggregatedIssue) { + return normalizedTypes.has('issue'); + } return normalizedTypes.has('error'); }); } - consoleListData = await Promise.all( - messages.map(async (item): Promise => { - const consoleMessageStableId = - context.getConsoleMessageStableId(item); - if ('args' in item) { - const consoleMessage = item as ConsoleMessage; + consoleListData = ( + await Promise.all( + messages.map(async (item): Promise => { + const consoleMessageStableId = + context.getConsoleMessageStableId(item); + if ('args' in item) { + const consoleMessage = item as ConsoleMessage; + return { + consoleMessageStableId, + type: consoleMessage.type(), + message: consoleMessage.text(), + args: await Promise.all( + consoleMessage.args().map(async arg => { + const stringArg = await arg.jsonValue().catch(() => { + // Ignore errors. + }); + return typeof stringArg === 'object' + ? JSON.stringify(stringArg) + : String(stringArg); + }), + ), + }; + } + if (item instanceof AggregatedIssue) { + const count = item.getAggregatedIssuesCount(); + const filename = item.getDescription()?.file; + const rawMarkdown = filename + ? getIssueDescription(filename) + : null; + if (!rawMarkdown) { + logger(`no markdown ${filename} found for issue:` + item.code); + return null; + } + const markdownAst = Marked.Marked.lexer(rawMarkdown); + const title = findTitleFromMarkdownAst(markdownAst); + if (!title) { + logger('cannot read issue title from ' + filename); + return null; + } + return { + consoleMessageStableId, + type: 'issue', + item, + message: title, + count, + args: [], + }; + } return { consoleMessageStableId, - type: consoleMessage.type(), - message: consoleMessage.text(), - args: await Promise.all( - consoleMessage.args().map(async arg => { - const stringArg = await arg.jsonValue().catch(() => { - // Ignore errors. - }); - return typeof stringArg === 'object' - ? JSON.stringify(stringArg) - : String(stringArg); - }), - ), + type: 'error', + message: (item as Error).message, + args: [], }; - } - return { - consoleMessageStableId, - type: 'error', - message: (item as Error).message, - args: [], - }; - }), - ); + }), + ) + ).filter(item => item !== null); } return this.format(toolName, context, { diff --git a/src/PageCollector.ts b/src/PageCollector.ts index 56d29c51..1212f996 100644 --- a/src/PageCollector.ts +++ b/src/PageCollector.ts @@ -4,15 +4,30 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { + type AggregatedIssue, + IssueAggregatorEvents, + IssuesManagerEvents, + createIssuesFromProtocolIssue, + IssueAggregator, +} from '../node_modules/chrome-devtools-frontend/mcp/mcp.js'; + +import {FakeIssuesManager} from './DevtoolsUtils.js'; +import {logger} from './logger.js'; +import type {ConsoleMessage, Protocol} from './third_party/index.js'; import { type Browser, type Frame, type Handler, type HTTPRequest, type Page, - type PageEvents, + type PageEvents as PuppeteerPageEvents, } from './third_party/index.js'; +interface PageEvents extends PuppeteerPageEvents { + issue: AggregatedIssue; +} + export type ListenerMap = { [K in keyof EventMap]?: (event: EventMap[K]) => void; }; @@ -61,7 +76,7 @@ export class PageCollector { async init() { const pages = await this.#browser.pages(this.#includeAllPages); for (const page of pages) { - this.#initializePage(page); + this.addPage(page); } this.#browser.on('targetcreated', async target => { @@ -69,14 +84,14 @@ export class PageCollector { if (!page) { return; } - this.#initializePage(page); + this.addPage(page); }); this.#browser.on('targetdestroyed', async target => { const page = await target.page(); if (!page) { return; } - this.#cleanupPageDestroyed(page); + this.cleanupPageDestroyed(page); }); } @@ -88,7 +103,6 @@ export class PageCollector { if (this.storage.has(page)) { return; } - const idGenerator = createIdGenerator(); const storedLists: Array>> = [[]]; this.storage.set(page, storedLists); @@ -126,7 +140,7 @@ export class PageCollector { navigations.splice(this.#maxNavigationSaved); } - #cleanupPageDestroyed(page: Page) { + protected cleanupPageDestroyed(page: Page) { const listeners = this.#listeners.get(page); if (listeners) { for (const [name, listener] of Object.entries(listeners)) { @@ -147,7 +161,6 @@ export class PageCollector { } const data: T[] = []; - for (let index = this.#maxNavigationSaved; index >= 0; index--) { if (navigations[index]) { data.push(...navigations[index]); @@ -194,6 +207,78 @@ export class PageCollector { } } +export class ConsoleCollector extends PageCollector< + ConsoleMessage | Error | AggregatedIssue +> { + #seenIssueKeys = new WeakMap>(); + #issuesAggregators = new WeakMap(); + #mockIssuesManagers = new WeakMap(); + + override addPage(page: Page): void { + super.addPage(page); + void this.subscribeForIssues(page); + } + async subscribeForIssues(page: Page) { + if (this.#seenIssueKeys.has(page)) return; + + this.#seenIssueKeys.set(page, new Set()); + const mockManager = new FakeIssuesManager(); + const aggregator = new IssueAggregator(mockManager); + this.#mockIssuesManagers.set(page, mockManager); + this.#issuesAggregators.set(page, aggregator); + + aggregator.addEventListener( + IssueAggregatorEvents.AGGREGATED_ISSUE_UPDATED, + event => { + const withId = event.data as WithSymbolId; + // Emit aggregated issue only if it's a new one + if (withId[stableIdSymbol]) { + return; + } + page.emit('issue', event.data); + }, + ); + try { + const session = await page.createCDPSession(); + session.on('Audits.issueAdded', data => { + const inspectorIssue = + data.issue satisfies Protocol.Audits.InspectorIssue; + // @ts-expect-error Types of protocol from Puppeteer and CDP are incomparable for InspectorIssueCode, one is union, other is enum + const issue = createIssuesFromProtocolIssue(null, inspectorIssue)[0]; + if (!issue) { + logger('No issue mapping for for the issue: ', inspectorIssue.code); + return; + } + + const seenKeys = this.#seenIssueKeys.get(page)!; + const primaryKey = issue.primaryKey(); + if (seenKeys.has(primaryKey)) return; + seenKeys.add(primaryKey); + + const mockManager = this.#mockIssuesManagers.get(page); + if (!mockManager) return; + + mockManager.dispatchEventToListeners(IssuesManagerEvents.ISSUE_ADDED, { + issue, + // @ts-expect-error We don't care that issues model is null + issuesModel: null, + }); + }); + + await session.send('Audits.enable'); + } catch (e) { + logger('Error subscribing to issues', e); + } + } + + override cleanupPageDestroyed(page: Page) { + super.cleanupPageDestroyed(page); + this.#seenIssueKeys.delete(page); + this.#issuesAggregators.delete(page); + this.#mockIssuesManagers.delete(page); + } +} + export class NetworkCollector extends PageCollector { constructor( browser: Browser, diff --git a/src/formatters/consoleFormatter.ts b/src/formatters/consoleFormatter.ts index d8080456..e4eca9b1 100644 --- a/src/formatters/consoleFormatter.ts +++ b/src/formatters/consoleFormatter.ts @@ -7,12 +7,17 @@ export interface ConsoleMessageData { consoleMessageStableId: number; type?: string; + item?: unknown; message?: string; + count?: number; args?: string[]; } // The short format for a console message, based on a previous format. export function formatConsoleEventShort(msg: ConsoleMessageData): string { + if (msg.type === 'issue') { + return `msgid=${msg.consoleMessageStableId} [${msg.type}] ${msg.message} (count: ${msg.count})`; + } return `msgid=${msg.consoleMessageStableId} [${msg.type}] ${msg.message} (${msg.args?.length ?? 0} args)`; } diff --git a/src/issue-descriptions.ts b/src/issue-descriptions.ts new file mode 100644 index 00000000..21485e4b --- /dev/null +++ b/src/issue-descriptions.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const DESCRIPTIONS_PATH = path.join( + import.meta.dirname, + '../node_modules/chrome-devtools-frontend/front_end/models/issues_manager/descriptions', +); + +let issueDescriptions: Record = {}; + +/** + * Reads all issue descriptions from the filesystem into memory. + */ +export async function loadIssueDescriptions(): Promise { + if (Object.keys(issueDescriptions).length > 0) { + return; + } + + const files = await fs.promises.readdir(DESCRIPTIONS_PATH); + const descriptions: Record = {}; + + for (const file of files) { + if (!file.endsWith('.md')) { + continue; + } + const content = await fs.promises.readFile( + path.join(DESCRIPTIONS_PATH, file), + 'utf-8', + ); + descriptions[file] = content; + } + + issueDescriptions = descriptions; +} + +/** + * Gets an issue description from the in-memory cache. + * @param fileName The file name of the issue description. + * @returns The description of the issue, or null if it doesn't exist. + */ +export function getIssueDescription(fileName: string): string | null { + return issueDescriptions[fileName] ?? null; +} diff --git a/src/main.ts b/src/main.ts index 80db16b6..77956876 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,6 +9,7 @@ import './polyfill.js'; import type {Channel} from './browser.js'; import {ensureBrowserConnected, ensureBrowserLaunched} from './browser.js'; import {parseArguments} from './cli.js'; +import {loadIssueDescriptions} from './issue-descriptions.js'; import {logger, saveLogsToFile} from './logger.js'; import {McpContext} from './McpContext.js'; import {McpResponse} from './McpResponse.js'; @@ -189,6 +190,7 @@ for (const tool of tools) { registerTool(tool); } +await loadIssueDescriptions(); const transport = new StdioServerTransport(); await server.connect(transport); logger('Chrome DevTools MCP Server connected'); diff --git a/src/tools/console.ts b/src/tools/console.ts index c45571e0..ee672018 100644 --- a/src/tools/console.ts +++ b/src/tools/console.ts @@ -9,10 +9,11 @@ import type {ConsoleMessageType} from '../third_party/index.js'; import {ToolCategory} from './categories.js'; import {defineTool} from './ToolDefinition.js'; +type ConsoleResponseType = ConsoleMessageType | 'issue'; const FILTERABLE_MESSAGE_TYPES: readonly [ - ConsoleMessageType, - ...ConsoleMessageType[], + ConsoleResponseType, + ...ConsoleResponseType[], ] = [ 'log', 'debug', @@ -33,6 +34,7 @@ const FILTERABLE_MESSAGE_TYPES: readonly [ 'count', 'timeEnd', 'verbose', + 'issue', ]; export const listConsoleMessages = defineTool({ diff --git a/tests/PageCollector.test.ts b/tests/PageCollector.test.ts index 9cd7c423..741712ef 100644 --- a/tests/PageCollector.test.ts +++ b/tests/PageCollector.test.ts @@ -4,12 +4,26 @@ * SPDX-License-Identifier: Apache-2.0 */ import assert from 'node:assert'; -import {describe, it} from 'node:test'; - -import type {Browser, Frame, HTTPRequest, Page, Target} from 'puppeteer-core'; - +import {beforeEach, describe, it} from 'node:test'; + +import type { + Browser, + Frame, + HTTPRequest, + Page, + Target, + CDPSession, + Protocol, +} from 'puppeteer-core'; +import sinon from 'sinon'; + +import {AggregatedIssue} from '../node_modules/chrome-devtools-frontend/mcp/mcp.js'; import type {ListenerMap} from '../src/PageCollector.js'; -import {NetworkCollector, PageCollector} from '../src/PageCollector.js'; +import { + ConsoleCollector, + NetworkCollector, + PageCollector, +} from '../src/PageCollector.js'; import {getMockRequest} from './utils.js'; @@ -36,10 +50,19 @@ function mockListener() { function getMockPage(): Page { const mainFrame = {} as Frame; + const cdpSession = { + ...mockListener(), + send: () => { + // no-op + }, + }; return { mainFrame() { return mainFrame; }, + createCDPSession() { + return Promise.resolve(cdpSession as unknown as CDPSession); + }, ...mockListener(), } as Page; } @@ -319,3 +342,98 @@ describe('NetworkCollector', () => { assert.equal(collector.getData(page, true).length, 3); }); }); + +describe('ConsoleCollector', () => { + let issue: Protocol.Audits.InspectorIssue; + + beforeEach(() => { + issue = { + code: 'MixedContentIssue', + details: { + mixedContentIssueDetails: { + insecureURL: 'test.url', + resolutionStatus: 'MixedContentBlocked', + mainResourceURL: '', + }, + }, + }; + }); + + it('emits issues on page', async () => { + const browser = getMockBrowser(); + const page = (await browser.pages())[0]; + const cdpSession = await page.createCDPSession(); + const onIssuesListener = sinon.spy(); + + page.on('issue', onIssuesListener); + + const collector = new ConsoleCollector(browser, collect => { + return { + issue: issue => { + collect(issue as AggregatedIssue); + }, + } as ListenerMap; + }); + await collector.init(); + cdpSession.emit('Audits.issueAdded', {issue}); + sinon.assert.calledOnce(onIssuesListener); + + const issueArgument = onIssuesListener.getCall(0).args[0]; + assert(issueArgument instanceof AggregatedIssue); + }); + + it('collects issues', async () => { + const browser = getMockBrowser(); + const page = (await browser.pages())[0]; + const cdpSession = await page.createCDPSession(); + + const collector = new ConsoleCollector(browser, collect => { + return { + issue: issue => { + collect(issue as AggregatedIssue); + }, + } as ListenerMap; + }); + await collector.init(); + + const issue2 = { + code: 'ElementAccessibilityIssue' as const, + details: { + elementAccessibilityIssueDetails: { + nodeId: 1, + elementAccessibilityIssueReason: 'DisallowedSelectChild', + hasDisallowedAttributes: true, + }, + }, + } satisfies Protocol.Audits.InspectorIssue; + + cdpSession.emit('Audits.issueAdded', {issue}); + cdpSession.emit('Audits.issueAdded', {issue: issue2}); + const data = collector.getData(page); + assert.equal(data.length, 2); + }); + + it('filters duplicated issues', async () => { + const browser = getMockBrowser(); + const page = (await browser.pages())[0]; + const cdpSession = await page.createCDPSession(); + + const collector = new ConsoleCollector(browser, collect => { + return { + issue: issue => { + collect(issue as AggregatedIssue); + }, + } as ListenerMap; + }); + await collector.init(); + + cdpSession.emit('Audits.issueAdded', {issue}); + cdpSession.emit('Audits.issueAdded', {issue}); + const data = collector.getData(page); + assert.equal(data.length, 1); + const collectedIssue = data[0]; + assert(collectedIssue instanceof AggregatedIssue); + assert.equal(collectedIssue.code(), 'MixedContentIssue'); + assert.equal(collectedIssue.getAggregatedIssuesCount(), 1); + }); +}); diff --git a/tests/tools/console.test.ts b/tests/tools/console.test.ts index e94713e0..5a6f7b8c 100644 --- a/tests/tools/console.test.ts +++ b/tests/tools/console.test.ts @@ -4,8 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ import assert from 'node:assert'; -import {describe, it} from 'node:test'; +import {before, describe, it} from 'node:test'; +import {loadIssueDescriptions} from '../../src/issue-descriptions.js'; import { getConsoleMessage, listConsoleMessages, @@ -14,6 +15,10 @@ import {withBrowser} from '../utils.js'; describe('console', () => { describe('list_console_messages', () => { + before(async () => { + await loadIssueDescriptions(); + }); + it('list messages', async () => { await withBrowser(async (response, context) => { await listConsoleMessages.handler({params: {}}, response, context); @@ -36,6 +41,28 @@ describe('console', () => { }); }); + it('lists issues messages', async () => { + await withBrowser(async (response, context) => { + const page = await context.newPage(); + const issuePromise = new Promise(resolve => { + page.on('issue', () => { + resolve(); + }); + }); + + await page.setContent(''); + await issuePromise; + await listConsoleMessages.handler({params: {}}, response, context); + const formattedResponse = await response.handle('test', context); + const textContent = formattedResponse[0] as {text: string}; + assert.ok( + textContent.text.includes( + `msgid=1 [issue] An element doesn't have an autocomplete attribute (count: 1)`, + ), + ); + }); + }); + it('work with primitive unhandled errors', async () => { await withBrowser(async (response, context) => { const page = await context.newPage(); diff --git a/tsconfig.json b/tsconfig.json index 7ccc4448..d4e0eaf4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -61,7 +61,10 @@ "node_modules/chrome-devtools-frontend/front_end/third_party/marked", "node_modules/chrome-devtools-frontend/front_end/third_party/source-map-scopes-codec", "node_modules/chrome-devtools-frontend/front_end/third_party/third-party-web", - "node_modules/chrome-devtools-frontend/mcp/mcp.ts" + "node_modules/chrome-devtools-frontend/mcp/mcp.ts", + "node_modules/chrome-devtools-frontend/front_end/models/issues_manager", + "node_modules/chrome-devtools-frontend/front_end/third_party/marked", + "node_modules/chrome-devtools-frontend/front_end/panels/issues/IssueAggregator.ts" ], "exclude": ["node_modules/chrome-devtools-frontend/**/*.test.ts"] }