Skip to content

Commit c1e4118

Browse files
nattalliusNatallia HarshunovaOrKoN
authored
feat: report console issues in list_console_messages (#505)
Co-authored-by: Natallia Harshunova <nharshunova@chromium.org> Co-authored-by: Alex Rudenko <OrKoN@users.noreply.github.com>
1 parent c42d81a commit c1e4118

File tree

12 files changed

+413
-50
lines changed

12 files changed

+413
-50
lines changed

scripts/post-build.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export const i18n = {
6363
return str;
6464
},
6565
lockedLazyString: () => {},
66-
getLazilyComputedLocalizedString: () => {},
66+
getLazilyComputedLocalizedString: () => ()=>{},
6767
};
6868
6969
// TODO(jacktfranklin): once the DocumentLatency insight does not depend on
@@ -169,6 +169,15 @@ export const hostConfig = {};
169169
fs.copyFileSync(devtoolsLicenseFileSource, devtoolsLicenseFileDestination);
170170

171171
copyThirdPartyLicenseFiles();
172+
copyDevToolsDescriptionFiles();
173+
}
174+
175+
function copyDevToolsDescriptionFiles() {
176+
const devtoolsIssuesDescriptionPath =
177+
'node_modules/chrome-devtools-frontend/front_end/models/issues_manager/descriptions';
178+
const sourceDir = path.join(process.cwd(), devtoolsIssuesDescriptionPath);
179+
const destDir = path.join(BUILD_DIR, devtoolsIssuesDescriptionPath);
180+
fs.cpSync(sourceDir, destDir, {recursive: true});
172181
}
173182

174183
main();

src/DevtoolsUtils.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33
* Copyright 2025 Google LLC
44
* SPDX-License-Identifier: Apache-2.0
55
*/
6+
7+
import {
8+
type Issue,
9+
type IssuesManagerEventTypes,
10+
Common,
11+
} from '../node_modules/chrome-devtools-frontend/mcp/mcp.js';
12+
613
export function extractUrlLikeFromDevToolsTitle(
714
title: string,
815
): string | undefined {
@@ -49,3 +56,14 @@ function normalizeUrl(url: string): string {
4956

5057
return result;
5158
}
59+
60+
/**
61+
* A mock implementation of an issues manager that only implements the methods
62+
* that are actually used by the IssuesAggregator
63+
*/
64+
export class FakeIssuesManager extends Common.ObjectWrapper
65+
.ObjectWrapper<IssuesManagerEventTypes> {
66+
issues(): Issue[] {
67+
return [];
68+
}
69+
}

src/McpContext.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import fs from 'node:fs/promises';
77
import os from 'node:os';
88
import path from 'node:path';
99

10+
import {type AggregatedIssue} from '../node_modules/chrome-devtools-frontend/mcp/mcp.js';
11+
1012
import {extractUrlLikeFromDevToolsTitle, urlsEqual} from './DevtoolsUtils.js';
1113
import type {ListenerMap} from './PageCollector.js';
12-
import {NetworkCollector, PageCollector} from './PageCollector.js';
14+
import {NetworkCollector, ConsoleCollector} from './PageCollector.js';
1315
import {Locator} from './third_party/index.js';
1416
import type {
1517
Browser,
@@ -92,7 +94,7 @@ export class McpContext implements Context {
9294
// The most recent snapshot.
9395
#textSnapshot: TextSnapshot | null = null;
9496
#networkCollector: NetworkCollector;
95-
#consoleCollector: PageCollector<ConsoleMessage | Error>;
97+
#consoleCollector: ConsoleCollector;
9698

9799
#isRunningTrace = false;
98100
#networkConditionsMap = new WeakMap<Page, string>();
@@ -122,7 +124,7 @@ export class McpContext implements Context {
122124
this.#options.experimentalIncludeAllPages,
123125
);
124126

125-
this.#consoleCollector = new PageCollector(
127+
this.#consoleCollector = new ConsoleCollector(
126128
this.browser,
127129
collect => {
128130
return {
@@ -138,6 +140,9 @@ export class McpContext implements Context {
138140
collect(error);
139141
}
140142
},
143+
issue: event => {
144+
collect(event);
145+
},
141146
} as ListenerMap;
142147
},
143148
this.#options.experimentalIncludeAllPages,
@@ -205,16 +210,18 @@ export class McpContext implements Context {
205210

206211
getConsoleData(
207212
includePreservedMessages?: boolean,
208-
): Array<ConsoleMessage | Error> {
213+
): Array<ConsoleMessage | Error | AggregatedIssue> {
209214
const page = this.getSelectedPage();
210215
return this.#consoleCollector.getData(page, includePreservedMessages);
211216
}
212217

213-
getConsoleMessageStableId(message: ConsoleMessage | Error): number {
218+
getConsoleMessageStableId(
219+
message: ConsoleMessage | Error | AggregatedIssue,
220+
): number {
214221
return this.#consoleCollector.getIdForResource(message);
215222
}
216223

217-
getConsoleMessageById(id: number): ConsoleMessage | Error {
224+
getConsoleMessageById(id: number): ConsoleMessage | Error | AggregatedIssue {
218225
return this.#consoleCollector.getById(this.getSelectedPage(), id);
219226
}
220227

src/McpResponse.ts

Lines changed: 65 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33
* Copyright 2025 Google LLC
44
* SPDX-License-Identifier: Apache-2.0
55
*/
6+
import {
7+
AggregatedIssue,
8+
Marked,
9+
findTitleFromMarkdownAst,
10+
} from '../node_modules/chrome-devtools-frontend/mcp/mcp.js';
11+
612
import type {ConsoleMessageData} from './formatters/consoleFormatter.js';
713
import {
814
formatConsoleEventShort,
@@ -16,6 +22,8 @@ import {
1622
getStatusFromRequest,
1723
} from './formatters/networkFormatter.js';
1824
import {formatSnapshotNode} from './formatters/snapshotFormatter.js';
25+
import {getIssueDescription} from './issue-descriptions.js';
26+
import {logger} from './logger.js';
1927
import type {McpContext} from './McpContext.js';
2028
import type {
2129
ConsoleMessage,
@@ -269,40 +277,70 @@ export class McpResponse implements Response {
269277
if ('type' in message) {
270278
return normalizedTypes.has(message.type());
271279
}
280+
if (message instanceof AggregatedIssue) {
281+
return normalizedTypes.has('issue');
282+
}
272283
return normalizedTypes.has('error');
273284
});
274285
}
275286

276-
consoleListData = await Promise.all(
277-
messages.map(async (item): Promise<ConsoleMessageData> => {
278-
const consoleMessageStableId =
279-
context.getConsoleMessageStableId(item);
280-
if ('args' in item) {
281-
const consoleMessage = item as ConsoleMessage;
287+
consoleListData = (
288+
await Promise.all(
289+
messages.map(async (item): Promise<ConsoleMessageData | null> => {
290+
const consoleMessageStableId =
291+
context.getConsoleMessageStableId(item);
292+
if ('args' in item) {
293+
const consoleMessage = item as ConsoleMessage;
294+
return {
295+
consoleMessageStableId,
296+
type: consoleMessage.type(),
297+
message: consoleMessage.text(),
298+
args: await Promise.all(
299+
consoleMessage.args().map(async arg => {
300+
const stringArg = await arg.jsonValue().catch(() => {
301+
// Ignore errors.
302+
});
303+
return typeof stringArg === 'object'
304+
? JSON.stringify(stringArg)
305+
: String(stringArg);
306+
}),
307+
),
308+
};
309+
}
310+
if (item instanceof AggregatedIssue) {
311+
const count = item.getAggregatedIssuesCount();
312+
const filename = item.getDescription()?.file;
313+
const rawMarkdown = filename
314+
? getIssueDescription(filename)
315+
: null;
316+
if (!rawMarkdown) {
317+
logger(`no markdown ${filename} found for issue:` + item.code);
318+
return null;
319+
}
320+
const markdownAst = Marked.Marked.lexer(rawMarkdown);
321+
const title = findTitleFromMarkdownAst(markdownAst);
322+
if (!title) {
323+
logger('cannot read issue title from ' + filename);
324+
return null;
325+
}
326+
return {
327+
consoleMessageStableId,
328+
type: 'issue',
329+
item,
330+
message: title,
331+
count,
332+
args: [],
333+
};
334+
}
282335
return {
283336
consoleMessageStableId,
284-
type: consoleMessage.type(),
285-
message: consoleMessage.text(),
286-
args: await Promise.all(
287-
consoleMessage.args().map(async arg => {
288-
const stringArg = await arg.jsonValue().catch(() => {
289-
// Ignore errors.
290-
});
291-
return typeof stringArg === 'object'
292-
? JSON.stringify(stringArg)
293-
: String(stringArg);
294-
}),
295-
),
337+
type: 'error',
338+
message: (item as Error).message,
339+
args: [],
296340
};
297-
}
298-
return {
299-
consoleMessageStableId,
300-
type: 'error',
301-
message: (item as Error).message,
302-
args: [],
303-
};
304-
}),
305-
);
341+
}),
342+
)
343+
).filter(item => item !== null);
306344
}
307345

308346
return this.format(toolName, context, {

src/PageCollector.ts

Lines changed: 92 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,30 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7+
import {
8+
type AggregatedIssue,
9+
IssueAggregatorEvents,
10+
IssuesManagerEvents,
11+
createIssuesFromProtocolIssue,
12+
IssueAggregator,
13+
} from '../node_modules/chrome-devtools-frontend/mcp/mcp.js';
14+
15+
import {FakeIssuesManager} from './DevtoolsUtils.js';
16+
import {logger} from './logger.js';
17+
import type {ConsoleMessage, Protocol} from './third_party/index.js';
718
import {
819
type Browser,
920
type Frame,
1021
type Handler,
1122
type HTTPRequest,
1223
type Page,
13-
type PageEvents,
24+
type PageEvents as PuppeteerPageEvents,
1425
} from './third_party/index.js';
1526

27+
interface PageEvents extends PuppeteerPageEvents {
28+
issue: AggregatedIssue;
29+
}
30+
1631
export type ListenerMap<EventMap extends PageEvents = PageEvents> = {
1732
[K in keyof EventMap]?: (event: EventMap[K]) => void;
1833
};
@@ -61,22 +76,22 @@ export class PageCollector<T> {
6176
async init() {
6277
const pages = await this.#browser.pages(this.#includeAllPages);
6378
for (const page of pages) {
64-
this.#initializePage(page);
79+
this.addPage(page);
6580
}
6681

6782
this.#browser.on('targetcreated', async target => {
6883
const page = await target.page();
6984
if (!page) {
7085
return;
7186
}
72-
this.#initializePage(page);
87+
this.addPage(page);
7388
});
7489
this.#browser.on('targetdestroyed', async target => {
7590
const page = await target.page();
7691
if (!page) {
7792
return;
7893
}
79-
this.#cleanupPageDestroyed(page);
94+
this.cleanupPageDestroyed(page);
8095
});
8196
}
8297

@@ -88,7 +103,6 @@ export class PageCollector<T> {
88103
if (this.storage.has(page)) {
89104
return;
90105
}
91-
92106
const idGenerator = createIdGenerator();
93107
const storedLists: Array<Array<WithSymbolId<T>>> = [[]];
94108
this.storage.set(page, storedLists);
@@ -126,7 +140,7 @@ export class PageCollector<T> {
126140
navigations.splice(this.#maxNavigationSaved);
127141
}
128142

129-
#cleanupPageDestroyed(page: Page) {
143+
protected cleanupPageDestroyed(page: Page) {
130144
const listeners = this.#listeners.get(page);
131145
if (listeners) {
132146
for (const [name, listener] of Object.entries(listeners)) {
@@ -147,7 +161,6 @@ export class PageCollector<T> {
147161
}
148162

149163
const data: T[] = [];
150-
151164
for (let index = this.#maxNavigationSaved; index >= 0; index--) {
152165
if (navigations[index]) {
153166
data.push(...navigations[index]);
@@ -194,6 +207,78 @@ export class PageCollector<T> {
194207
}
195208
}
196209

210+
export class ConsoleCollector extends PageCollector<
211+
ConsoleMessage | Error | AggregatedIssue
212+
> {
213+
#seenIssueKeys = new WeakMap<Page, Set<string>>();
214+
#issuesAggregators = new WeakMap<Page, IssueAggregator>();
215+
#mockIssuesManagers = new WeakMap<Page, FakeIssuesManager>();
216+
217+
override addPage(page: Page): void {
218+
super.addPage(page);
219+
void this.subscribeForIssues(page);
220+
}
221+
async subscribeForIssues(page: Page) {
222+
if (this.#seenIssueKeys.has(page)) return;
223+
224+
this.#seenIssueKeys.set(page, new Set());
225+
const mockManager = new FakeIssuesManager();
226+
const aggregator = new IssueAggregator(mockManager);
227+
this.#mockIssuesManagers.set(page, mockManager);
228+
this.#issuesAggregators.set(page, aggregator);
229+
230+
aggregator.addEventListener(
231+
IssueAggregatorEvents.AGGREGATED_ISSUE_UPDATED,
232+
event => {
233+
const withId = event.data as WithSymbolId<AggregatedIssue>;
234+
// Emit aggregated issue only if it's a new one
235+
if (withId[stableIdSymbol]) {
236+
return;
237+
}
238+
page.emit('issue', event.data);
239+
},
240+
);
241+
try {
242+
const session = await page.createCDPSession();
243+
session.on('Audits.issueAdded', data => {
244+
const inspectorIssue =
245+
data.issue satisfies Protocol.Audits.InspectorIssue;
246+
// @ts-expect-error Types of protocol from Puppeteer and CDP are incomparable for InspectorIssueCode, one is union, other is enum
247+
const issue = createIssuesFromProtocolIssue(null, inspectorIssue)[0];
248+
if (!issue) {
249+
logger('No issue mapping for for the issue: ', inspectorIssue.code);
250+
return;
251+
}
252+
253+
const seenKeys = this.#seenIssueKeys.get(page)!;
254+
const primaryKey = issue.primaryKey();
255+
if (seenKeys.has(primaryKey)) return;
256+
seenKeys.add(primaryKey);
257+
258+
const mockManager = this.#mockIssuesManagers.get(page);
259+
if (!mockManager) return;
260+
261+
mockManager.dispatchEventToListeners(IssuesManagerEvents.ISSUE_ADDED, {
262+
issue,
263+
// @ts-expect-error We don't care that issues model is null
264+
issuesModel: null,
265+
});
266+
});
267+
268+
await session.send('Audits.enable');
269+
} catch (e) {
270+
logger('Error subscribing to issues', e);
271+
}
272+
}
273+
274+
override cleanupPageDestroyed(page: Page) {
275+
super.cleanupPageDestroyed(page);
276+
this.#seenIssueKeys.delete(page);
277+
this.#issuesAggregators.delete(page);
278+
this.#mockIssuesManagers.delete(page);
279+
}
280+
}
281+
197282
export class NetworkCollector extends PageCollector<HTTPRequest> {
198283
constructor(
199284
browser: Browser,

0 commit comments

Comments
 (0)