diff --git a/src/McpResponse.ts b/src/McpResponse.ts index fa487047d..97e87d10b 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -1031,17 +1031,16 @@ Call ${handleDialog.name} to handle it before continuing.`); response.push('## Console messages'); if (messages.length) { + const grouped = ConsoleFormatter.groupConsecutive(messages); const paginationData = this.#dataWithPagination( - messages, + grouped, this.#consoleDataOptions.pagination, ); structuredContent.pagination = paginationData.pagination; response.push(...paginationData.info); - response.push( - ...paginationData.items.map(message => message.toString()), - ); - structuredContent.consoleMessages = paginationData.items.map(message => - message.toJSON(), + response.push(...paginationData.items.map(item => item.toString())); + structuredContent.consoleMessages = paginationData.items.map(item => + item.toJSON(), ); } else { response.push(''); diff --git a/src/formatters/ConsoleFormatter.ts b/src/formatters/ConsoleFormatter.ts index 4cefab55e..5cbf4d663 100644 --- a/src/formatters/ConsoleFormatter.ts +++ b/src/formatters/ConsoleFormatter.ts @@ -13,6 +13,8 @@ import {UncaughtError} from '../PageCollector.js'; import * as DevTools from '../third_party/index.js'; import type {ConsoleMessage} from '../third_party/index.js'; +import type {IssueFormatter} from './IssueFormatter.js'; + export interface ConsoleFormatterOptions { fetchDetailedData?: boolean; id: number; @@ -32,6 +34,7 @@ interface ConsoleMessageConcise { text: string; argsCount: number; id: number; + count?: number; } interface ConsoleMessageDetailed extends ConsoleMessageConcise { @@ -54,7 +57,7 @@ export class ConsoleFormatter { readonly isIgnored: IgnoreCheck; - private constructor(params: { + protected constructor(params: { id: number; type: string; text: string; @@ -201,6 +204,48 @@ export class ConsoleFormatter { }; } + /** + * Groups consecutive messages with the same type, text, and argument count. + * Similar to Chrome DevTools' console grouping behavior. + */ + static groupConsecutive( + messages: Array, + ): Array { + const grouped: Array<{ + message: ConsoleFormatter | IssueFormatter; + count: number; + }> = []; + for (const msg of messages) { + const prev = grouped[grouped.length - 1]; + if ( + prev && + prev.message instanceof ConsoleFormatter && + msg instanceof ConsoleFormatter && + prev.message.#type === msg.#type && + prev.message.#text === msg.#text && + prev.message.#argCount === msg.#argCount + ) { + prev.count++; + } else { + grouped.push({message: msg, count: 1}); + } + } + return grouped.map(({message, count}) => + count > 1 && message instanceof ConsoleFormatter + ? new GroupedConsoleFormatter( + { + id: message.#id, + type: message.#type, + text: message.#text, + argCount: message.#argCount, + isIgnored: message.isIgnored, + }, + count, + ) + : message, + ); + } + toJSONDetailed(): ConsoleMessageDetailed { return { id: this.#id, @@ -215,8 +260,37 @@ export class ConsoleFormatter { } } +export class GroupedConsoleFormatter extends ConsoleFormatter { + readonly #count: number; + + constructor( + params: { + id: number; + type: string; + text: string; + argCount: number; + isIgnored: IgnoreCheck; + }, + count: number, + ) { + super(params); + this.#count = count; + } + + override toString(): string { + return convertConsoleMessageConciseToString(this.toJSON()); + } + + override toJSON(): ConsoleMessageConcise { + const json = super.toJSON(); + json.count = this.#count; + return json; + } +} + function convertConsoleMessageConciseToString(msg: ConsoleMessageConcise) { - return `msgid=${msg.id} [${msg.type}] ${msg.text} (${msg.argsCount} args)`; + const countSuffix = msg.count && msg.count > 1 ? ` [${msg.count} times]` : ''; + return `msgid=${msg.id} [${msg.type}] ${msg.text} (${msg.argsCount} args)${countSuffix}`; } function convertConsoleMessageConciseDetailedToString( diff --git a/tests/formatters/ConsoleFormatterGrouping.test.ts b/tests/formatters/ConsoleFormatterGrouping.test.ts new file mode 100644 index 000000000..41564a817 --- /dev/null +++ b/tests/formatters/ConsoleFormatterGrouping.test.ts @@ -0,0 +1,140 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {describe, it} from 'node:test'; + +import { + ConsoleFormatter, + GroupedConsoleFormatter, +} from '../../src/formatters/ConsoleFormatter.js'; +import type {ConsoleMessage} from '../../src/third_party/index.js'; + +const createMockMessage = ( + type: string, + text: string, + argsCount = 0, +): ConsoleMessage => { + const args = Array.from({length: argsCount}, () => ({ + jsonValue: async () => 'val', + remoteObject: () => ({type: 'string'}), + })); + return { + type: () => type, + text: () => text, + args: () => args, + } as unknown as ConsoleMessage; +}; + +const makeFormatter = (id: number, type: string, text: string, argsCount = 0) => + ConsoleFormatter.from(createMockMessage(type, text, argsCount), {id}); + +describe('ConsoleFormatter grouping', () => { + describe('groupConsecutive', () => { + it('groups identical consecutive messages', async () => { + const msgs = await Promise.all([ + makeFormatter(1, 'log', 'hello'), + makeFormatter(2, 'log', 'hello'), + makeFormatter(3, 'log', 'hello'), + ]); + const grouped = ConsoleFormatter.groupConsecutive(msgs); + assert.strictEqual(grouped.length, 1); + assert.ok(grouped[0] instanceof GroupedConsoleFormatter); + assert.ok(grouped[0].toString().includes('[3 times]')); + }); + + it('does not group different messages', async () => { + const msgs = await Promise.all([ + makeFormatter(1, 'log', 'aaa'), + makeFormatter(2, 'log', 'bbb'), + makeFormatter(3, 'log', 'ccc'), + ]); + const grouped = ConsoleFormatter.groupConsecutive(msgs); + assert.strictEqual(grouped.length, 3); + for (const g of grouped) { + assert.ok(!(g instanceof GroupedConsoleFormatter)); + assert.ok(!g.toString().includes('times')); + } + }); + + it('groups A,A,B,A,A correctly', async () => { + const msgs = await Promise.all([ + makeFormatter(1, 'log', 'A'), + makeFormatter(2, 'log', 'A'), + makeFormatter(3, 'log', 'B'), + makeFormatter(4, 'log', 'A'), + makeFormatter(5, 'log', 'A'), + ]); + const grouped = ConsoleFormatter.groupConsecutive(msgs); + assert.strictEqual(grouped.length, 3); + assert.ok(grouped[0] instanceof GroupedConsoleFormatter); + assert.ok(grouped[0].toString().includes('[2 times]')); + assert.ok(!(grouped[1] instanceof GroupedConsoleFormatter)); + assert.ok(grouped[2] instanceof GroupedConsoleFormatter); + assert.ok(grouped[2].toString().includes('[2 times]')); + }); + + it('does not group messages with different types', async () => { + const msgs = await Promise.all([ + makeFormatter(1, 'log', 'hello'), + makeFormatter(2, 'error', 'hello'), + ]); + const grouped = ConsoleFormatter.groupConsecutive(msgs); + assert.strictEqual(grouped.length, 2); + }); + + it('does not group messages with different argsCount', async () => { + const msgs = await Promise.all([ + makeFormatter(1, 'log', 'hello', 1), + makeFormatter(2, 'log', 'hello', 2), + ]); + const grouped = ConsoleFormatter.groupConsecutive(msgs); + assert.strictEqual(grouped.length, 2); + }); + + it('returns empty array for empty input', () => { + const grouped = ConsoleFormatter.groupConsecutive([]); + assert.strictEqual(grouped.length, 0); + }); + + it('handles single message', async () => { + const msgs = await Promise.all([makeFormatter(1, 'log', 'solo')]); + const grouped = ConsoleFormatter.groupConsecutive(msgs); + assert.strictEqual(grouped.length, 1); + assert.ok(!(grouped[0] instanceof GroupedConsoleFormatter)); + }); + }); + + describe('GroupedConsoleFormatter output', () => { + it('toString includes count suffix', async () => { + const msgs = await Promise.all([ + makeFormatter(1, 'log', 'hello'), + makeFormatter(2, 'log', 'hello'), + makeFormatter(3, 'log', 'hello'), + makeFormatter(4, 'log', 'hello'), + makeFormatter(5, 'log', 'hello'), + ]); + const grouped = ConsoleFormatter.groupConsecutive(msgs); + assert.strictEqual(grouped.length, 1); + const str = grouped[0].toString(); + assert.ok(str.includes('[5 times]'), `expected [5 times] in: ${str}`); + assert.ok(str.includes('msgid=1'), `expected msgid=1 in: ${str}`); + }); + + it('toJSON includes count field', async () => { + const msgs = await Promise.all([ + makeFormatter(1, 'log', 'hello'), + makeFormatter(2, 'log', 'hello'), + makeFormatter(3, 'log', 'hello'), + ]); + const grouped = ConsoleFormatter.groupConsecutive(msgs); + assert.strictEqual(grouped.length, 1); + const json = (grouped[0] as GroupedConsoleFormatter).toJSON(); + assert.strictEqual(json.count, 3); + assert.strictEqual(json.id, 1); + }); + }); +});