Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions src/McpResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<no console messages found>');
Expand Down
78 changes: 76 additions & 2 deletions src/formatters/ConsoleFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -32,6 +34,7 @@ interface ConsoleMessageConcise {
text: string;
argsCount: number;
id: number;
count?: number;
}

interface ConsoleMessageDetailed extends ConsoleMessageConcise {
Expand All @@ -54,7 +57,7 @@ export class ConsoleFormatter {

readonly isIgnored: IgnoreCheck;

private constructor(params: {
protected constructor(params: {
id: number;
type: string;
text: string;
Expand Down Expand Up @@ -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<ConsoleFormatter | IssueFormatter>,
): Array<ConsoleFormatter | IssueFormatter> {
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,
Expand All @@ -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(
Expand Down
140 changes: 140 additions & 0 deletions tests/formatters/ConsoleFormatterGrouping.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});