diff --git a/scripts/post-build.ts b/scripts/post-build.ts index 5fa66d5b..b33e32c3 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,20 @@ export const hostConfig = {}; fs.copyFileSync(devtoolsLicenseFileSource, devtoolsLicenseFileDestination); copyThirdPartyLicenseFiles(); + copyDevToolsDescriptionFiles(); } -main(); +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(); \ No newline at end of file diff --git a/src/DevtoolsUtils.ts b/src/DevtoolsUtils.ts index 8b12b3a9..36d4f62a 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,13 @@ 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..9678051b 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, @@ -29,6 +31,8 @@ import type {Context, DevToolsData} from './tools/ToolDefinition.js'; import type {TraceResult} from './trace-processing/parse.js'; import {WaitForHelper} from './WaitForHelper.js'; + + export interface TextSnapshotNode extends SerializedAXNode { id: string; backendNodeId?: number; @@ -92,7 +96,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 +126,8 @@ export class McpContext implements Context { this.#options.experimentalIncludeAllPages, ); - this.#consoleCollector = new PageCollector( + + this.#consoleCollector = new ConsoleCollector( this.browser, collect => { return { @@ -138,6 +143,9 @@ export class McpContext implements Context { collect(error); } }, + issue: event => { + collect(event); + }, } as ListenerMap; }, this.#options.experimentalIncludeAllPages, @@ -205,16 +213,16 @@ 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..e77fb18a 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -3,6 +3,10 @@ * 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 +20,7 @@ import { getStatusFromRequest, } from './formatters/networkFormatter.js'; import {formatSnapshotNode} from './formatters/snapshotFormatter.js'; +import {getIssueDescription} from './issue-descriptions.js'; import type {McpContext} from './McpContext.js'; import type { ConsoleMessage, @@ -269,6 +274,9 @@ 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'); }); } @@ -295,6 +303,29 @@ export class McpResponse implements Response { ), }; } + if (item instanceof AggregatedIssue) { + const count = item.getAggregatedIssuesCount(); + const filename = item.getDescription()?.file; + const rawMarkdown = filename + ? getIssueDescription(filename) + : null; + if (!rawMarkdown) { + return { + consoleMessageStableId, + type: 'issue', + message: `${item.code()} (count: ${count})`, + args: [], + }; + } + const markdownAst = Marked.Marked.lexer(rawMarkdown); + const title = findTitleFromMarkdownAst(markdownAst); + return { + consoleMessageStableId, + type: 'issue', + message: `${title} (count: ${count})`, + args: [], + }; + } return { consoleMessageStableId, type: 'error', diff --git a/src/PageCollector.ts b/src/PageCollector.ts index 56d29c51..69e090c5 100644 --- a/src/PageCollector.ts +++ b/src/PageCollector.ts @@ -4,15 +4,29 @@ * 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 type {ConsoleMessage} 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 +75,7 @@ export class PageCollector { async init() { const pages = await this.#browser.pages(this.#includeAllPages); for (const page of pages) { - this.#initializePage(page); + await this.addPage(page); } this.#browser.on('targetcreated', async target => { @@ -69,37 +83,47 @@ export class PageCollector { if (!page) { return; } - this.#initializePage(page); + await this.addPage(page); }); this.#browser.on('targetdestroyed', async target => { const page = await target.page(); if (!page) { return; } - this.#cleanupPageDestroyed(page); + this.cleanupPageDestroyed(page); }); } - public addPage(page: Page) { - this.#initializePage(page); - } - - #initializePage(page: Page) { + public async addPage(page: Page) { if (this.storage.has(page)) { return; } + await this.#initializePage(page); + } + async #initializePage(page: Page) { const idGenerator = createIdGenerator(); const storedLists: Array>> = [[]]; this.storage.set(page, storedLists); - const listeners = this.#listenersInitializer(value => { + const collector = (value: T) => { const withId = value as WithSymbolId; - withId[stableIdSymbol] = idGenerator(); + // Assign an ID only if it's a new item. + if (!withId[stableIdSymbol]) { + withId[stableIdSymbol] = idGenerator(); + } const navigations = this.storage.get(page) ?? [[]]; - navigations[0].push(withId); - }); + const currentNavigation = navigations[0]; + + // The issues aggregator sends the same object instance for updates, so we just + // need to ensure it's not in the list. + if (!currentNavigation.includes(withId)) { + currentNavigation.push(withId); + } + }; + + const listeners = this.#listenersInitializer(collector); listeners['framenavigated'] = (frame: Frame) => { // Only split the storage on main frame navigation @@ -121,12 +145,11 @@ export class PageCollector { if (!navigations) { return; } - // Add the latest navigation first navigations.unshift([]); 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 +170,6 @@ export class PageCollector { } const data: T[] = []; - for (let index = this.#maxNavigationSaved; index >= 0; index--) { if (navigations[index]) { data.push(...navigations[index]); @@ -194,6 +216,65 @@ export class PageCollector { } } +export class ConsoleCollector extends PageCollector { + #seenIssueKeys = new WeakMap>(); + #issuesAggregators = new WeakMap(); + #mockIssuesManagers = new WeakMap(); + + override async addPage(page: Page) { + if (this.storage.has(page)) { + return; + } + await super.addPage(page); + await this.subscribeForIssues(page); + } + async subscribeForIssues(page: Page) { + if (!this.#seenIssueKeys.has(page)) { + this.#seenIssueKeys.set(page, new Set()); + } + + const mockManager = new FakeIssuesManager(); + // @ts-expect-error Aggregator receives partial IssuesManager + const aggregator = new IssueAggregator(mockManager); + this.#mockIssuesManagers.set(page, mockManager); + this.#issuesAggregators.set(page, aggregator); + + aggregator.addEventListener( + IssueAggregatorEvents.AGGREGATED_ISSUE_UPDATED, + event => { + page.emit('issue', event.data); + }, + ); + + const session = await page.createCDPSession(); + session.on('Audits.issueAdded', data => { + // @ts-expect-error Types of protocol from Puppeteer and CDP are incopatible for Issues but it's the same type + const issue = createIssuesFromProtocolIssue(null,data.issue)[0]; + if (!issue) { + 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) { + // @ts-expect-error We don't care that issues model is null + mockManager.dispatchEventToListeners(IssuesManagerEvents.ISSUE_ADDED, {issue, issuesModel: null}); + } + }); + await session.send('Audits.enable'); + } + + 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, @@ -224,9 +305,6 @@ export class NetworkCollector extends PageCollector { : false; }); - // Keep all requests since the last navigation request including that - // navigation request itself. - // Keep the reference if (lastRequestIdx !== -1) { const fromCurrentNavigation = requests.splice(lastRequestIdx); navigations.unshift(fromCurrentNavigation); @@ -234,4 +312,4 @@ export class NetworkCollector extends PageCollector { navigations.unshift([]); } } -} +} \ No newline at end of file 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..e6939148 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/tsconfig.json b/tsconfig.json index 11e5dfa9..1bffe13e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -56,7 +56,11 @@ "node_modules/chrome-devtools-frontend/front_end/third_party/legacy-javascript", "node_modules/chrome-devtools-frontend/front_end/third_party/source-map-scopes-codec", "node_modules/chrome-devtools-frontend/front_end/core/root", - "node_modules/chrome-devtools-frontend/front_end/third_party/third-party-web" + "node_modules/chrome-devtools-frontend/front_end/third_party/third-party-web", + + "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"] }