refactor: move comments formatting to CommentFormatter class (#2719)

This commit is contained in:
Alex Rudenko
2026-09-10 12:05:01 +00:00
committed by GitHub
parent 46d7d2d408
commit e3cded0f43
7 changed files with 388 additions and 74 deletions
+43 -1
View File
@@ -7,6 +7,10 @@
import type {WebMCPTool} from 'puppeteer-core';
import type {ParsedArguments} from './config/mcp-options.js';
import {
CommentFormatter,
type StructuredCommentThread,
} from './formatters/CommentFormatter.js';
import {ConsoleFormatter} from './formatters/ConsoleFormatter.js';
import {
HeapSnapshotFormatter,
@@ -51,7 +55,7 @@ import {
getInsightOutput,
getTraceSummary,
} from './processors/PerformanceTrace.js';
import type {PaginationOptions} from './types.js';
import type {CD4ACommentThread, PaginationOptions} from './types.js';
import type {WithSymbolId} from './utils/id.js';
import {stableIdSymbol} from './utils/id.js';
import {paginate} from './utils/pagination.js';
@@ -125,6 +129,7 @@ export class McpResponse implements Response {
#error?: Error;
#attachedWaitForResult?: WaitForEventsResult;
#reconnectNotice = false;
#devToolsComments?: CD4ACommentThread[];
get #deviceScope(): DevTools.CrUXManager.DeviceScope {
return this.#page?.viewport?.isMobile ? 'PHONE' : 'DESKTOP';
@@ -327,6 +332,14 @@ export class McpResponse implements Response {
this.#attachedWaitForResult = result;
}
setDevToolsComments(threads: CD4ACommentThread[]): void {
this.#devToolsComments = threads;
}
get devToolsComments(): readonly CD4ACommentThread[] | undefined {
return this.#devToolsComments;
}
setHeapSnapshotAggregates(
aggregateData: HeapSnapshotAggregateData,
options?: PaginationOptions,
@@ -668,6 +681,22 @@ export class McpResponse implements Response {
);
}
async #handleComments(): Promise<CommentFormatter | undefined> {
const comments = this.#devToolsComments;
if (!comments) {
return undefined;
}
const page = this.#page;
return await CommentFormatter.from(comments, {
resolveBackendNodeId: page
? (id: number) => page.resolveBackendNodeId(id)
: undefined,
resolveCdpRequestId: page
? (id: string) => page.resolveCdpRequestId(id)
: undefined,
});
}
async handle(
context: McpContext,
dataFormat: DataFormat = 'default',
@@ -683,6 +712,7 @@ export class McpResponse implements Response {
webmcpTools,
consoleMessages,
networkRequests,
comments,
] = await Promise.all([
this.#handleSnapshot(context),
this.#handleAttachedNetworkRequest(context),
@@ -691,6 +721,7 @@ export class McpResponse implements Response {
this.#handleWebMCP(),
this.#handleConsoleList(context),
this.#handleNetworkRequestList(context),
this.#handleComments(),
]);
if (this.#includeExtensionServiceWorkers) {
@@ -716,6 +747,7 @@ export class McpResponse implements Response {
lighthouseResult: this.#attachedLighthouseResult,
thirdPartyDeveloperTools,
webmcpTools,
comments,
errorMessage: this.#error?.message,
},
dataFormat,
@@ -746,6 +778,7 @@ export class McpResponse implements Response {
lighthouseResult?: LighthouseData;
thirdPartyDeveloperTools?: ToolGroups;
webmcpTools?: WebMCPTool[];
comments?: CommentFormatter;
errorMessage?: string;
},
dataFormat: DataFormat = 'default',
@@ -805,6 +838,7 @@ export class McpResponse implements Response {
heapSnapshotObjectDetails?: DevTools.HeapSnapshotModel.HeapSnapshotModel.ObjectInfo;
extensionServiceWorkers?: object[];
extensionPages?: object[];
comments?: StructuredCommentThread[];
errorMessage?: string;
navigatedToUrl?: string;
geolocation?: {latitude: number; longitude: number};
@@ -1392,6 +1426,14 @@ Call ${handleDialog.name} to handle it before continuing.`);
}
}
if (data.comments) {
const commentsJson = data.comments.toJSON();
structuredContent.comments = commentsJson;
response.push(
compactEncode ? compactEncode(commentsJson) : data.comments.toString(),
);
}
if (data.errorMessage) {
response.push(`Error: ${data.errorMessage}`);
structuredContent.errorMessage = data.errorMessage;
+109
View File
@@ -0,0 +1,109 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {CD4ACommentThread, CD4AEditorAnchorSignature} from '../types.js';
export interface CommentFormatterOptions {
resolveBackendNodeId?: (backendNodeId: number) => Promise<string | undefined>;
resolveCdpRequestId?: (cdpRequestId: string) => number | undefined;
}
export interface StructuredCommentThread {
id: string;
text: string;
elementUid?: string;
reqid?: number;
editor?: CD4AEditorAnchorSignature;
}
function formatCommentThread(thread: StructuredCommentThread): string {
const lines: string[] = [
`### Thread: ${thread.id}`,
`- Comment: ${thread.text}`,
];
if (thread.elementUid) {
lines.push(`- Target element (snapshot UID): ${thread.elementUid}`);
}
if (thread.reqid !== undefined) {
lines.push(`- Network request ID (reqid): ${thread.reqid}`);
}
if (thread.editor) {
const location = thread.editor.filePath
? `${thread.editor.filePath}:${thread.editor.lineNumber}`
: `line ${thread.editor.lineNumber}`;
lines.push(`- Editor location: ${location}`);
}
return lines.join('\n');
}
function formatComments(threads: readonly StructuredCommentThread[]): string {
if (threads.length === 0) {
return 'No open DevTools comments found.';
}
const lines: string[] = [
`Found ${threads.length} DevTools comment thread(s):`,
];
for (const thread of threads) {
lines.push(`\n${formatCommentThread(thread)}`);
}
return lines.join('\n');
}
export class CommentFormatter {
readonly #threads: readonly StructuredCommentThread[];
constructor(threads: readonly StructuredCommentThread[]) {
this.#threads = threads;
}
static async from(
threads: readonly CD4ACommentThread[],
options?: CommentFormatterOptions,
): Promise<CommentFormatter> {
const structuredThreads: StructuredCommentThread[] = [];
for (const thread of threads) {
let elementUid: string | undefined;
const resolveBackendNodeId = options?.resolveBackendNodeId;
if (thread.backendNodeId !== undefined && resolveBackendNodeId) {
elementUid = await resolveBackendNodeId(thread.backendNodeId);
}
let reqid: number | undefined;
const resolveCdpRequestId = options?.resolveCdpRequestId;
if (thread.networkRequestId !== undefined && resolveCdpRequestId) {
reqid = resolveCdpRequestId(thread.networkRequestId);
}
const item: StructuredCommentThread = {
id: thread.id,
text: thread.text,
};
if (elementUid) {
item.elementUid = elementUid;
}
if (reqid !== undefined) {
item.reqid = reqid;
}
if (thread.editor) {
item.editor = thread.editor;
}
structuredThreads.push(item);
}
return new CommentFormatter(structuredThreads);
}
static formatThread(thread: StructuredCommentThread): string {
return formatCommentThread(thread);
}
toJSON(): StructuredCommentThread[] {
return [...this.#threads];
}
toString(): string {
return formatComments(this.toJSON());
}
}
+2
View File
@@ -35,6 +35,7 @@ import type {
TextSnapshotNode,
GeolocationOptions,
ExtensionServiceWorker,
CD4ACommentThread,
} from '../types.js';
import type {PaginationOptions} from '../types.js';
import type {
@@ -194,6 +195,7 @@ export interface Response {
setListThirdPartyDeveloperTools(): void;
setListWebMcpTools(): void;
attachWaitForResult(result: WaitForEventsResult): void;
setDevToolsComments(threads: CD4ACommentThread[]): void;
}
export type SupportedExtensions =
+1 -34
View File
@@ -68,40 +68,7 @@ export const getDevtoolsComments = definePageTool({
return window.universe?.cd4aBridge?.getCommentThreads() ?? [];
});
if (threads.length === 0) {
response.appendResponseLine('No open DevTools comments found.');
return;
}
response.appendResponseLine(
`Found ${threads.length} DevTools comment thread(s):`,
);
for (const thread of threads) {
response.appendResponseLine(`\n### Thread: ${thread.id}`);
response.appendResponseLine(`- Comment: ${thread.text}`);
if (thread.backendNodeId !== undefined) {
const elementUid = await page.resolveBackendNodeId(
thread.backendNodeId,
);
if (elementUid) {
response.appendResponseLine(
`- Target element (snapshot UID): ${elementUid}`,
);
}
}
if (thread.networkRequestId) {
const reqid = page.resolveCdpRequestId(thread.networkRequestId);
if (reqid !== undefined) {
response.appendResponseLine(`- Network request ID (reqid): ${reqid}`);
}
}
if (thread.editor) {
const location = thread.editor.filePath
? `${thread.editor.filePath}:${thread.editor.lineNumber}`
: `line ${thread.editor.lineNumber}`;
response.appendResponseLine(`- Editor location: ${location}`);
}
}
response.setDevToolsComments(threads);
},
});
@@ -0,0 +1,99 @@
exports[`CommentFormatter > formats comments with targets and editor locations toJSON 1`] = `
[
{
"id": "comment-1",
"text": "Fix the color contrast here",
"elementUid": "element-uid-42",
"reqid": 7,
"editor": {
"filePath": "src/style.css",
"lineNumber": 10
}
}
]
`;
exports[`CommentFormatter > formats comments with targets and editor locations toString 1`] = `
Found 1 DevTools comment thread(s):
### Thread: comment-1
- Comment: Fix the color contrast here
- Target element (snapshot UID): element-uid-42
- Network request ID (reqid): 7
- Editor location: src/style.css:10
`;
exports[`CommentFormatter > formats editor location when filePath is missing toJSON 1`] = `
[
{
"id": "comment-2",
"text": "Review this script line",
"editor": {
"lineNumber": 25
}
}
]
`;
exports[`CommentFormatter > formats editor location when filePath is missing toString 1`] = `
Found 1 DevTools comment thread(s):
### Thread: comment-2
- Comment: Review this script line
- Editor location: line 25
`;
exports[`CommentFormatter > formats empty comments list toJSON 1`] = `
[]
`;
exports[`CommentFormatter > formats empty comments list toString 1`] = `
No open DevTools comments found.
`;
exports[`CommentFormatter > formats single thread using static formatThread 1`] = `
### Thread: comment-3
- Comment: Check padding
- Target element (snapshot UID): node-99
`;
exports[`CommentFormatter > omits unresolved targets when from() resolves undefined toJSON 1`] = `
[
{
"id": "comment-2",
"text": "Fix heading font size"
}
]
`;
exports[`CommentFormatter > omits unresolved targets when from() resolves undefined toString 1`] = `
Found 1 DevTools comment thread(s):
### Thread: comment-2
- Comment: Fix heading font size
`;
exports[`CommentFormatter > resolves targets using from() method toJSON 1`] = `
[
{
"id": "comment-1",
"text": "Fix the color contrast here",
"elementUid": "element-uid-42",
"reqid": 7,
"editor": {
"filePath": "src/style.css",
"lineNumber": 10
}
}
]
`;
exports[`CommentFormatter > resolves targets using from() method toString 1`] = `
Found 1 DevTools comment thread(s):
### Thread: comment-1
- Comment: Fix the color contrast here
- Target element (snapshot UID): element-uid-42
- Network request ID (reqid): 7
- Editor location: src/style.css:10
`;
+124
View File
@@ -0,0 +1,124 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {afterEach, describe, it} from 'node:test';
import sinon from 'sinon';
import {
CommentFormatter,
type StructuredCommentThread,
} from '../../src/formatters/CommentFormatter.js';
import type {CD4ACommentThread} from '../../src/types.js';
describe('CommentFormatter', () => {
afterEach(() => {
sinon.restore();
});
function formatterTest(
label: string,
setup: (t: it.TestContext) => CommentFormatter | Promise<CommentFormatter>,
) {
it(label + ' toString', async t => {
const formatter = await setup(t);
t.assert.snapshot(formatter.toString());
});
it(label + ' toJSON', async t => {
const formatter = await setup(t);
t.assert.snapshot(JSON.stringify(formatter.toJSON(), null, 2));
});
}
formatterTest('formats empty comments list', () => {
return new CommentFormatter([]);
});
formatterTest('formats comments with targets and editor locations', () => {
const thread: StructuredCommentThread = {
id: 'comment-1',
text: 'Fix the color contrast here',
elementUid: 'element-uid-42',
reqid: 7,
editor: {
filePath: 'src/style.css',
lineNumber: 10,
},
};
return new CommentFormatter([thread]);
});
formatterTest('formats editor location when filePath is missing', () => {
const thread: StructuredCommentThread = {
id: 'comment-2',
text: 'Review this script line',
editor: {
lineNumber: 25,
},
};
return new CommentFormatter([thread]);
});
it('formats single thread using static formatThread', t => {
const thread: StructuredCommentThread = {
id: 'comment-3',
text: 'Check padding',
elementUid: 'node-99',
};
t.assert.snapshot(CommentFormatter.formatThread(thread));
});
formatterTest('resolves targets using from() method', async () => {
const rawThread: CD4ACommentThread = {
id: 'comment-1',
text: 'Fix the color contrast here',
backendNodeId: 42,
networkRequestId: 'req-99',
editor: {
filePath: 'src/style.css',
lineNumber: 10,
},
};
const resolveBackendNodeId = sinon.stub().resolves('element-uid-42');
const resolveCdpRequestId = sinon.stub().returns(7);
const formatter = await CommentFormatter.from([rawThread], {
resolveBackendNodeId,
resolveCdpRequestId,
});
sinon.assert.calledOnceWithExactly(resolveBackendNodeId, 42);
sinon.assert.calledOnceWithExactly(resolveCdpRequestId, 'req-99');
return formatter;
});
formatterTest(
'omits unresolved targets when from() resolves undefined',
async () => {
const rawThread: CD4ACommentThread = {
id: 'comment-2',
text: 'Fix heading font size',
backendNodeId: 42,
networkRequestId: 'req-99',
};
const resolveBackendNodeId = sinon.stub().resolves(undefined);
const resolveCdpRequestId = sinon.stub().returns(undefined);
const formatter = await CommentFormatter.from([rawThread], {
resolveBackendNodeId,
resolveCdpRequestId,
});
sinon.assert.calledOnceWithExactly(resolveBackendNodeId, 42);
sinon.assert.calledOnceWithExactly(resolveCdpRequestId, 'req-99');
return formatter;
},
);
});
+10 -39
View File
@@ -45,34 +45,14 @@ describe('comments tools', () => {
response.appendResponseLine,
'DevTools window is not open for this page. Call open_devtools first to open DevTools.',
);
sinon.assert.notCalled(response.setDevToolsComments);
t.assert.snapshot(lines.join('\n'));
});
it('reports message when no comments are found', async t => {
it('fetches comments and sets them on response', async () => {
const {page, context, response} = createHandlerMocks();
const lines = trackResponseLines(response);
const devtoolsPage = createMockPuppeteerPage();
page.getDevToolsPage.resolves(devtoolsPage);
devtoolsPage.evaluate.resolves([]);
await getDevtoolsComments.handler({params: {}, page}, response, context);
sinon.assert.calledOnce(page.getDevToolsPage);
sinon.assert.calledOnce(devtoolsPage.evaluate);
sinon.assert.calledOnceWithExactly(
response.appendResponseLine,
'No open DevTools comments found.',
);
t.assert.snapshot(lines.join('\n'));
});
it('formats comment threads with text, targets, and editor location', async t => {
const {page, context, response} = createHandlerMocks();
const lines = trackResponseLines(response);
const devtoolsPage = createMockPuppeteerPage();
page.getDevToolsPage.resolves(devtoolsPage);
page.resolveBackendNodeId.resolves('element-uid-42');
page.resolveCdpRequestId.returns(7);
const mockThread: CommentThreadPayload = {
id: 'comment-1',
@@ -91,31 +71,22 @@ describe('comments tools', () => {
sinon.assert.calledOnce(page.getDevToolsPage);
sinon.assert.calledOnce(devtoolsPage.evaluate);
sinon.assert.calledOnceWithExactly(page.resolveBackendNodeId, 42);
sinon.assert.calledOnceWithExactly(page.resolveCdpRequestId, 'req-99');
t.assert.snapshot(lines.join('\n'));
sinon.assert.calledOnceWithExactly(response.setDevToolsComments, [
mockThread,
]);
});
it('omits target element and network request ID if resolution returns undefined', async t => {
it('sets empty comments list when no comments are found', async () => {
const {page, context, response} = createHandlerMocks();
const lines = trackResponseLines(response);
const devtoolsPage = createMockPuppeteerPage();
page.getDevToolsPage.resolves(devtoolsPage);
page.resolveBackendNodeId.resolves(undefined);
page.resolveCdpRequestId.returns(undefined);
const mockThread: CommentThreadPayload = {
id: 'comment-2',
text: 'Fix heading font size',
backendNodeId: 42,
networkRequestId: 'req-99',
};
devtoolsPage.evaluate.resolves([mockThread]);
devtoolsPage.evaluate.resolves([]);
await getDevtoolsComments.handler({params: {}, page}, response, context);
t.assert.snapshot(lines.join('\n'));
sinon.assert.calledOnce(page.getDevToolsPage);
sinon.assert.calledOnce(devtoolsPage.evaluate);
sinon.assert.calledOnceWithExactly(response.setDevToolsComments, []);
});
});