Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion scripts/post-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export const i18n = {
return str;
},
lockedLazyString: () => {},
getLazilyComputedLocalizedString: () => {},
getLazilyComputedLocalizedString: () => ()=>{},
};

// TODO(jacktfranklin): once the DocumentLatency insight does not depend on
Expand Down Expand Up @@ -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();
18 changes: 18 additions & 0 deletions src/DevtoolsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<IssuesManagerEventTypes> {
issues(): Issue[] {
return [];
}
}
19 changes: 13 additions & 6 deletions src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -92,7 +94,7 @@ export class McpContext implements Context {
// The most recent snapshot.
#textSnapshot: TextSnapshot | null = null;
#networkCollector: NetworkCollector;
#consoleCollector: PageCollector<ConsoleMessage | Error>;
#consoleCollector: ConsoleCollector;

#isRunningTrace = false;
#networkConditionsMap = new WeakMap<Page, string>();
Expand Down Expand Up @@ -122,7 +124,7 @@ export class McpContext implements Context {
this.#options.experimentalIncludeAllPages,
);

this.#consoleCollector = new PageCollector(
this.#consoleCollector = new ConsoleCollector(
this.browser,
collect => {
return {
Expand All @@ -138,6 +140,9 @@ export class McpContext implements Context {
collect(error);
}
},
issue: event => {
collect(event);
},
} as ListenerMap;
},
this.#options.experimentalIncludeAllPages,
Expand Down Expand Up @@ -205,16 +210,18 @@ export class McpContext implements Context {

getConsoleData(
includePreservedMessages?: boolean,
): Array<ConsoleMessage | Error> {
): Array<ConsoleMessage | Error | AggregatedIssue> {
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);
}

Expand Down
92 changes: 65 additions & 27 deletions src/McpResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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<ConsoleMessageData> => {
const consoleMessageStableId =
context.getConsoleMessageStableId(item);
if ('args' in item) {
const consoleMessage = item as ConsoleMessage;
consoleListData = (
await Promise.all(
messages.map(async (item): Promise<ConsoleMessageData | null> => {
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, {
Expand Down
99 changes: 92 additions & 7 deletions src/PageCollector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<EventMap extends PageEvents = PageEvents> = {
[K in keyof EventMap]?: (event: EventMap[K]) => void;
};
Expand Down Expand Up @@ -61,22 +76,22 @@ export class PageCollector<T> {
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 => {
const page = await target.page();
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);
});
}

Expand All @@ -88,7 +103,6 @@ export class PageCollector<T> {
if (this.storage.has(page)) {
return;
}

const idGenerator = createIdGenerator();
const storedLists: Array<Array<WithSymbolId<T>>> = [[]];
this.storage.set(page, storedLists);
Expand Down Expand Up @@ -126,7 +140,7 @@ export class PageCollector<T> {
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)) {
Expand All @@ -147,7 +161,6 @@ export class PageCollector<T> {
}

const data: T[] = [];

for (let index = this.#maxNavigationSaved; index >= 0; index--) {
if (navigations[index]) {
data.push(...navigations[index]);
Expand Down Expand Up @@ -194,6 +207,78 @@ export class PageCollector<T> {
}
}

export class ConsoleCollector extends PageCollector<
ConsoleMessage | Error | AggregatedIssue
> {
#seenIssueKeys = new WeakMap<Page, Set<string>>();
#issuesAggregators = new WeakMap<Page, IssueAggregator>();
#mockIssuesManagers = new WeakMap<Page, FakeIssuesManager>();

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<AggregatedIssue>;
// 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<HTTPRequest> {
constructor(
browser: Browser,
Expand Down
Loading
Loading