Skip to content

Commit fde1fbc

Browse files
author
Natallia Harshunova
committed
Emit aggregated issue
1 parent 5185aa1 commit fde1fbc

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';
@@ -94,7 +94,7 @@ export class McpContext implements Context {
9494
// The most recent snapshot.
9595
#textSnapshot: TextSnapshot | null = null;
9696
#networkCollector: NetworkCollector;
97-
#consoleCollector: PageCollector<ConsoleMessage | Error | Issue.Issue>;
97+
#consoleCollector: PageCollector<ConsoleMessage | Error | AggregatedIssue>;
9898

9999
#isRunningTrace = false;
100100
#networkConditionsMap = new WeakMap<Page, string>();
@@ -203,16 +203,16 @@ export class McpContext implements Context {
203203

204204
getConsoleData(
205205
includePreservedMessages?: boolean,
206-
): Array<ConsoleMessage | Error | Issue.Issue> {
206+
): Array<ConsoleMessage | Error | AggregatedIssue> {
207207
const page = this.getSelectedPage();
208208
return this.#consoleCollector.getData(page, includePreservedMessages);
209209
}
210210

211-
getConsoleMessageStableId(message: ConsoleMessage | Error | Issue.Issue): number {
211+
getConsoleMessageStableId(message: ConsoleMessage | Error | AggregatedIssue): number {
212212
return this.#consoleCollector.getIdForResource(message);
213213
}
214214

215-
getConsoleMessageById(id: number): ConsoleMessage | Error | Issue.Issue {
215+
getConsoleMessageById(id: number): ConsoleMessage | Error | AggregatedIssue {
216216
return this.#consoleCollector.getById(this.getSelectedPage(), id);
217217
}
218218

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> = {
@@ -47,11 +53,10 @@ export class PageCollector<T> {
4753
#seenIssueKeys = new WeakMap<Page, Set<string>>();
4854
#maxNavigationSaved = 3;
4955

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

5762
constructor(
@@ -92,18 +97,31 @@ export class PageCollector<T> {
9297
}
9398

9499
async #initializePage(page: Page) {
95-
await this.subscribeForIssues(page);
96100
const idGenerator = createIdGenerator();
97101
const storedLists: Array<Array<WithSymbolId<T>>> = [[]];
98102
this.storage.set(page, storedLists);
99103

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

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

108126
listeners['framenavigated'] = (frame: Frame) => {
109127
// Only split the storage on main frame navigation
@@ -121,22 +139,44 @@ export class PageCollector<T> {
121139
}
122140

123141
protected async subscribeForIssues(page: Page) {
124-
if (this instanceof NetworkCollector) return;
142+
if (this instanceof NetworkCollector) {
143+
return;
144+
}
125145
if (!this.#seenIssueKeys.has(page)) {
126146
this.#seenIssueKeys.set(page, new Set());
127147
}
148+
149+
const mockManager = new FakeIssuesManager();
150+
// @ts-expect-error Aggregator receives partial IssuesManager
151+
const aggregator = new IssueAggregator(mockManager);
152+
this.#mockIssuesManagers.set(page, mockManager);
153+
this.#issuesAggregators.set(page, aggregator);
154+
155+
aggregator.addEventListener(
156+
AggregatorEvents.AGGREGATED_ISSUE_UPDATED,
157+
event => {
158+
page.emit('issue', event.data);
159+
},
160+
);
161+
128162
const session = await page.createCDPSession();
129-
session.on('Audits.issueAdded', data => {// TODO unsubscribe
130-
// @ts-expect-error Types of protocol from Puppeteer and CDP are incopatible for Issues
131-
const issue = IssuesManager.createIssuesFromProtocolIssue(null,data.issue)[0]; // returns issue wrapped in array, need to get first element
163+
session.on('Audits.issueAdded', data => {
164+
// @ts-expect-error Types of protocol from Puppeteer and CDP are incopatible for Issues but it's the same type
165+
const issue = IssuesManager.createIssuesFromProtocolIssue(null,data.issue,)[0];
132166
if (!issue) {
133167
return;
134168
}
135169
const seenKeys = this.#seenIssueKeys.get(page)!;
136170
const primaryKey = issue.primaryKey();
137171
if (seenKeys.has(primaryKey)) return;
138172
seenKeys.add(primaryKey);
139-
page.emit('issue', issue);
173+
174+
// Trigger the aggregator via our mock manager. Do NOT call collector() here.
175+
const mockManager = this.#mockIssuesManagers.get(page);
176+
if (mockManager) {
177+
// @ts-expect-error we don't care about issies model being null
178+
mockManager.dispatchEventToListeners(IssuesManager.Events.ISSUE_ADDED, {issue, issuesModel: null});
179+
}
140180
});
141181
await session.send('Audits.enable');
142182
}
@@ -146,7 +186,6 @@ export class PageCollector<T> {
146186
if (!navigations) {
147187
return;
148188
}
149-
// Add the latest navigation first
150189
navigations.unshift([]);
151190
navigations.splice(this.#maxNavigationSaved);
152191
}
@@ -160,6 +199,8 @@ export class PageCollector<T> {
160199
}
161200
this.storage.delete(page);
162201
this.#seenIssueKeys.delete(page);
202+
this.#issuesAggregators.delete(page);
203+
this.#mockIssuesManagers.delete(page);
163204
}
164205

165206
getData(page: Page, includePreservedData?: boolean): T[] {
@@ -173,7 +214,6 @@ export class PageCollector<T> {
173214
}
174215

175216
const data: T[] = [];
176-
177217
for (let index = this.#maxNavigationSaved; index >= 0; index--) {
178218
if (navigations[index]) {
179219
data.push(...navigations[index]);
@@ -249,14 +289,11 @@ export class NetworkCollector extends PageCollector<HTTPRequest> {
249289
: false;
250290
});
251291

252-
// Keep all requests since the last navigation request including that
253-
// navigation request itself.
254-
// Keep the reference
255292
if (lastRequestIdx !== -1) {
256293
const fromCurrentNavigation = requests.splice(lastRequestIdx);
257294
navigations.unshift(fromCurrentNavigation);
258295
} else {
259296
navigations.unshift([]);
260297
}
261298
}
262-
}
299+
}

0 commit comments

Comments
 (0)