Skip to content

Commit acf3875

Browse files
author
Natallia Harshunova
committed
Emit aggregated issue
1 parent 5fc86e9 commit acf3875

File tree

6 files changed

+16702
-37
lines changed

6 files changed

+16702
-37
lines changed

src/DevtoolsUtils.ts

Lines changed: 17 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 IssuesManager,
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,13 @@ 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.ObjectWrapper<IssuesManager.EventTypes> {
65+
issues(): Issue.Issue[] {
66+
return [];
67+
}
68+
}

src/McpContext.ts

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

10-
import {type Issue} from '../node_modules/chrome-devtools-frontend/mcp/mcp.js';
10+
import {type AggregatedIssue} from '../node_modules/chrome-devtools-frontend/mcp/mcp.js';
1111

1212
import {extractUrlLikeFromDevToolsTitle, urlsEqual} from './DevtoolsUtils.js';
1313
import type {ListenerMap} from './PageCollector.js';
@@ -96,7 +96,7 @@ export class McpContext implements Context {
9696
// The most recent snapshot.
9797
#textSnapshot: TextSnapshot | null = null;
9898
#networkCollector: NetworkCollector;
99-
#consoleCollector: PageCollector<ConsoleMessage | Error | Issue.Issue>;
99+
#consoleCollector: PageCollector<ConsoleMessage | Error | AggregatedIssue>;
100100

101101
#isRunningTrace = false;
102102
#networkConditionsMap = new WeakMap<Page, string>();
@@ -212,16 +212,16 @@ export class McpContext implements Context {
212212

213213
getConsoleData(
214214
includePreservedMessages?: boolean,
215-
): Array<ConsoleMessage | Error | Issue.Issue> {
215+
): Array<ConsoleMessage | Error | AggregatedIssue> {
216216
const page = this.getSelectedPage();
217217
return this.#consoleCollector.getData(page, includePreservedMessages);
218218
}
219219

220-
getConsoleMessageStableId(message: ConsoleMessage | Error | Issue.Issue): number {
220+
getConsoleMessageStableId(message: ConsoleMessage | Error | AggregatedIssue): number {
221221
return this.#consoleCollector.getIdForResource(message);
222222
}
223223

224-
getConsoleMessageById(id: number): ConsoleMessage | Error | Issue.Issue {
224+
getConsoleMessageById(id: number): ConsoleMessage | Error | AggregatedIssue {
225225
return this.#consoleCollector.getById(this.getSelectedPage(), id);
226226
}
227227

src/McpResponse.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
* Copyright 2025 Google LLC
44
* SPDX-License-Identifier: Apache-2.0
55
*/
6-
import { Issue } from '../node_modules/chrome-devtools-frontend/mcp/mcp.js';
6+
import {
7+
AggregatedIssue, Marked, MarkdownIssueDescription
8+
} from '../node_modules/chrome-devtools-frontend/mcp/mcp.js';
79

810
import type {ConsoleMessageData} from './formatters/consoleFormatter.js';
911
import {
@@ -272,7 +274,10 @@ export class McpResponse implements Response {
272274
if ('type' in message) {
273275
return normalizedTypes.has(message.type());
274276
}
275-
return normalizedTypes.has('error'); // TODO add filtering
277+
if (message instanceof AggregatedIssue) {
278+
return normalizedTypes.has('issue');
279+
}
280+
return normalizedTypes.has('error');
276281
});
277282
}
278283

@@ -298,16 +303,27 @@ export class McpResponse implements Response {
298303
),
299304
};
300305
}
301-
if (item instanceof Issue.Issue) {
302-
const descriptionFile = item.getDescription()?.file;
303-
const description = descriptionFile
304-
? getIssueDescription(descriptionFile)
306+
if (item instanceof AggregatedIssue) {
307+
const count = item.getAggregatedIssuesCount();
308+
const filename = item.getDescription()?.file;
309+
const rawMarkdown = filename
310+
? getIssueDescription(filename)
305311
: null;
312+
if (!rawMarkdown) {
313+
return {
314+
consoleMessageStableId,
315+
type: 'issue',
316+
message: `${item.code()} (count: ${count})`,
317+
args: [],
318+
};
319+
}
320+
const markdownAst = Marked.Marked.lexer(rawMarkdown);
321+
const title = MarkdownIssueDescription.findTitleFromMarkdownAst(markdownAst);
306322
return {
307323
consoleMessageStableId,
308324
type: 'issue',
309-
message: item.primaryKey(),
310-
args: description ? [description] : [],
325+
message: `${title} (count: ${count})`,
326+
args: [],
311327
};
312328
}
313329
return {

src/PageCollector.ts

Lines changed: 60 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,14 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import {IssuesManager, Issue} from '../node_modules/chrome-devtools-frontend/mcp/mcp.js';
7+
import {
8+
type AggregatedIssue,
9+
AggregatorEvents,
10+
IssuesManager,
11+
IssueAggregator,
12+
} from '../node_modules/chrome-devtools-frontend/mcp/mcp.js';
813

14+
import {FakeIssuesManager} from './DevtoolsUtils.js';
915
import {
1016
type Browser,
1117
type Frame,
@@ -16,7 +22,7 @@ import {
1622
} from './third_party/index.js';
1723

1824
interface PageEvents extends PuppeteerPageEvents {
19-
issue: Issue.Issue;
25+
issue: AggregatedIssue;
2026
}
2127

2228
export type ListenerMap<EventMap extends PageEvents = PageEvents> = {
@@ -48,11 +54,10 @@ export class PageCollector<T> {
4854
#maxNavigationSaved = 3;
4955
#includeAllPages?: boolean;
5056

51-
/**
52-
* This maps a Page to a list of navigations with a sub-list
53-
* of all collected resources.
54-
* The newer navigations come first.
55-
*/
57+
// Store an aggregator and a mock manager for each page.
58+
#issuesAggregators = new WeakMap<Page, IssueAggregator>();
59+
#mockIssuesManagers = new WeakMap<Page, FakeIssuesManager>();
60+
5661
protected storage = new WeakMap<Page, Array<Array<WithSymbolId<T>>>>();
5762

5863
constructor(
@@ -95,18 +100,31 @@ export class PageCollector<T> {
95100
}
96101

97102
async #initializePage(page: Page) {
98-
await this.subscribeForIssues(page);
99103
const idGenerator = createIdGenerator();
100104
const storedLists: Array<Array<WithSymbolId<T>>> = [[]];
101105
this.storage.set(page, storedLists);
102106

103-
const listeners = this.#listenersInitializer(value => {
107+
// This is the single function responsible for adding items to storage.
108+
const collector = (value: T) => {
104109
const withId = value as WithSymbolId<T>;
105-
withId[stableIdSymbol] = idGenerator();
110+
// Assign an ID only if it's a new item.
111+
if (!withId[stableIdSymbol]) {
112+
withId[stableIdSymbol] = idGenerator();
113+
}
106114

107115
const navigations = this.storage.get(page) ?? [[]];
108-
navigations[0].push(withId);
109-
});
116+
const currentNavigation = navigations[0];
117+
118+
// The aggregator sends the same object instance for updates, so we just
119+
// need to ensure it's in the list.
120+
if (!currentNavigation.includes(withId)) {
121+
currentNavigation.push(withId);
122+
}
123+
};
124+
125+
await this.subscribeForIssues(page);
126+
127+
const listeners = this.#listenersInitializer(collector);
110128

111129
listeners['framenavigated'] = (frame: Frame) => {
112130
// Only split the storage on main frame navigation
@@ -124,22 +142,44 @@ export class PageCollector<T> {
124142
}
125143

126144
protected async subscribeForIssues(page: Page) {
127-
if (this instanceof NetworkCollector) return;
145+
if (this instanceof NetworkCollector) {
146+
return;
147+
}
128148
if (!this.#seenIssueKeys.has(page)) {
129149
this.#seenIssueKeys.set(page, new Set());
130150
}
151+
152+
const mockManager = new FakeIssuesManager();
153+
// @ts-expect-error Aggregator receives partial IssuesManager
154+
const aggregator = new IssueAggregator(mockManager);
155+
this.#mockIssuesManagers.set(page, mockManager);
156+
this.#issuesAggregators.set(page, aggregator);
157+
158+
aggregator.addEventListener(
159+
AggregatorEvents.AGGREGATED_ISSUE_UPDATED,
160+
event => {
161+
page.emit('issue', event.data);
162+
},
163+
);
164+
131165
const session = await page.createCDPSession();
132-
session.on('Audits.issueAdded', data => {// TODO unsubscribe
133-
// @ts-expect-error Types of protocol from Puppeteer and CDP are incopatible for Issues
134-
const issue = IssuesManager.createIssuesFromProtocolIssue(null,data.issue)[0]; // returns issue wrapped in array, need to get first element
166+
session.on('Audits.issueAdded', data => {
167+
// @ts-expect-error Types of protocol from Puppeteer and CDP are incopatible for Issues but it's the same type
168+
const issue = IssuesManager.createIssuesFromProtocolIssue(null,data.issue,)[0];
135169
if (!issue) {
136170
return;
137171
}
138172
const seenKeys = this.#seenIssueKeys.get(page)!;
139173
const primaryKey = issue.primaryKey();
140174
if (seenKeys.has(primaryKey)) return;
141175
seenKeys.add(primaryKey);
142-
page.emit('issue', issue);
176+
177+
// Trigger the aggregator via our mock manager. Do NOT call collector() here.
178+
const mockManager = this.#mockIssuesManagers.get(page);
179+
if (mockManager) {
180+
// @ts-expect-error we don't care about issies model being null
181+
mockManager.dispatchEventToListeners(IssuesManager.Events.ISSUE_ADDED, {issue, issuesModel: null});
182+
}
143183
});
144184
await session.send('Audits.enable');
145185
}
@@ -149,7 +189,6 @@ export class PageCollector<T> {
149189
if (!navigations) {
150190
return;
151191
}
152-
// Add the latest navigation first
153192
navigations.unshift([]);
154193
navigations.splice(this.#maxNavigationSaved);
155194
}
@@ -163,6 +202,8 @@ export class PageCollector<T> {
163202
}
164203
this.storage.delete(page);
165204
this.#seenIssueKeys.delete(page);
205+
this.#issuesAggregators.delete(page);
206+
this.#mockIssuesManagers.delete(page);
166207
}
167208

168209
getData(page: Page, includePreservedData?: boolean): T[] {
@@ -176,7 +217,6 @@ export class PageCollector<T> {
176217
}
177218

178219
const data: T[] = [];
179-
180220
for (let index = this.#maxNavigationSaved; index >= 0; index--) {
181221
if (navigations[index]) {
182222
data.push(...navigations[index]);
@@ -253,14 +293,11 @@ export class NetworkCollector extends PageCollector<HTTPRequest> {
253293
: false;
254294
});
255295

256-
// Keep all requests since the last navigation request including that
257-
// navigation request itself.
258-
// Keep the reference
259296
if (lastRequestIdx !== -1) {
260297
const fromCurrentNavigation = requests.splice(lastRequestIdx);
261298
navigations.unshift(fromCurrentNavigation);
262299
} else {
263300
navigations.unshift([]);
264301
}
265302
}
266-
}
303+
}

0 commit comments

Comments
 (0)