From 89558e419ccc2131555206a3230a48b3d3c3dbc9 Mon Sep 17 00:00:00 2001 From: Thomas Bachem Date: Fri, 17 Jul 2026 07:38:50 +0200 Subject: [PATCH] fix: include console messages logged before the server attached --- src/McpPage.ts | 35 ++- src/McpResponse.ts | 31 ++- src/PageCollector.ts | 212 +++++++++++++++++- src/formatters/ConsoleFormatter.ts | 34 ++- tests/McpResponse.test.ts | 4 +- tests/PageCollector.test.ts | 206 ++++++++++++++++- .../ConsoleFormatter.test.js.snapshot | 32 +++ tests/formatters/ConsoleFormatter.test.ts | 41 +++- tests/tools/console.test.ts | 81 ++++++- 9 files changed, 647 insertions(+), 29 deletions(-) diff --git a/src/McpPage.ts b/src/McpPage.ts index eb68b3973..6ee2e09cb 100644 --- a/src/McpPage.ts +++ b/src/McpPage.ts @@ -62,6 +62,7 @@ import { import { ConsoleCollector, NetworkCollector, + type BufferedConsoleMessage, type ListenerMap, type UncaughtError, } from './PageCollector.js'; @@ -359,15 +360,41 @@ export class McpPage implements ContextPage { } } - getConsoleData( + // Both console reads wait for the collector's backfill, so a tool call + // right after attach cannot race the recovery of buffered messages. An + // open dialog pauses the renderer and thereby the backfill itself, so in + // that case return the live-collected data instead of stalling on it. + async #consoleBackfilled(): Promise { + if (!this.#dialog) { + await this.consoleCollector.backfilled; + } + } + + async getConsoleData( includePreservedMessages?: boolean, - ): Array { + ): Promise< + Array< + | ConsoleMessage + | BufferedConsoleMessage + | Error + | DevTools.AggregatedIssue + | UncaughtError + > + > { + await this.#consoleBackfilled(); return this.consoleCollector.getData(includePreservedMessages); } - getConsoleMessageById( + async getConsoleMessageById( id: number, - ): ConsoleMessage | Error | DevTools.AggregatedIssue | UncaughtError { + ): Promise< + | ConsoleMessage + | BufferedConsoleMessage + | Error + | DevTools.AggregatedIssue + | UncaughtError + > { + await this.#consoleBackfilled(); return this.consoleCollector.getById(id); } diff --git a/src/McpResponse.ts b/src/McpResponse.ts index b403ea467..c75f5127d 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -24,7 +24,7 @@ import type { } from './HeapSnapshotManager.js'; import type {McpContext} from './McpContext.js'; import type {McpPage} from './McpPage.js'; -import {UncaughtError} from './PageCollector.js'; +import {BufferedConsoleMessage, UncaughtError} from './PageCollector.js'; import {TextSnapshot} from './TextSnapshot.js'; import {DevTools, getToonEncode, getGcfEncode} from './third_party/index.js'; import type { @@ -506,14 +506,17 @@ export class McpResponse implements Response { throw new Error(`Response must have an McpPage`); } - const message = this.#page.getConsoleMessageById( + const message = await this.#page.getConsoleMessageById( this.#attachedConsoleMessageId, ); const consoleMessageStableId = this.#attachedConsoleMessageId; - if ('args' in message || message instanceof UncaughtError) { - const consoleMessage = message as ConsoleMessage | UncaughtError; + if ( + message instanceof BufferedConsoleMessage || + message instanceof UncaughtError || + 'args' in message + ) { const devTools = this.#page.devtoolsUniverse; - detailedConsoleMessage = await ConsoleFormatter.from(consoleMessage, { + detailedConsoleMessage = await ConsoleFormatter.from(message, { id: consoleMessageStableId, fetchDetailedData: true, devTools: devTools ?? undefined, @@ -575,7 +578,7 @@ export class McpResponse implements Response { if (!page) { throw new Error(`Response must have an McpPage`); } - messages = page.getConsoleData( + messages = await page.getConsoleData( this.#consoleDataOptions.includePreservedMessages, ); } @@ -599,9 +602,12 @@ export class McpResponse implements Response { async (item): Promise => { const consoleMessageStableId = this.getConsoleMessageStableId(item); - if ('args' in item || item instanceof UncaughtError) { - const consoleMessage = item as ConsoleMessage | UncaughtError; - return await ConsoleFormatter.from(consoleMessage, { + if ( + item instanceof BufferedConsoleMessage || + item instanceof UncaughtError || + 'args' in item + ) { + return await ConsoleFormatter.from(item, { id: consoleMessageStableId, fetchDetailedData: false, devTools: page ? page.devtoolsUniverse : undefined, @@ -683,7 +689,12 @@ export class McpResponse implements Response { } getConsoleMessageStableId( - message: ConsoleMessage | Error | DevTools.AggregatedIssue | UncaughtError, + message: + | ConsoleMessage + | BufferedConsoleMessage + | Error + | DevTools.AggregatedIssue + | UncaughtError, ): number { return (message as WithSymbolId)[stableIdSymbol] ?? -1; } diff --git a/src/PageCollector.ts b/src/PageCollector.ts index a5b557ffa..c4f4c362f 100644 --- a/src/PageCollector.ts +++ b/src/PageCollector.ts @@ -8,6 +8,7 @@ import {FakeIssuesManager} from './devtools/DevtoolsUtils.js'; import type { CDPSession, ConsoleMessage, + ConsoleMessageType, Protocol, Issue, } from './third_party/index.js'; @@ -36,6 +37,42 @@ export class UncaughtError { } } +/** + * A console message recovered from Chromium's per-page buffer after the fact. + * Replayed args cannot be adopted as live JSHandles, so it only carries their + * text representation plus the raw stack trace for symbolization. + */ +export class BufferedConsoleMessage { + readonly argsCount: number; + readonly rawStackTrace?: Protocol.Runtime.StackTrace; + readonly targetId: string; + #type: ConsoleMessageType; + #text: string; + + constructor(params: { + type: ConsoleMessageType; + text: string; + argsCount: number; + rawStackTrace?: Protocol.Runtime.StackTrace; + targetId: string; + }) { + this.#type = params.type; + this.#text = params.text; + this.argsCount = params.argsCount; + this.rawStackTrace = params.rawStackTrace; + this.targetId = params.targetId; + } + + // Accessors mirror ConsoleMessage so shared code paths work on both. + type(): ConsoleMessageType { + return this.#type; + } + + text(): string { + return this.#text; + } +} + interface PageEvents extends PuppeteerPageEvents { devtoolsAggregatedIssue: DevTools.AggregatedIssue; uncaughtError: UncaughtError; @@ -48,6 +85,7 @@ export type ListenerMap = { export class PageCollector { protected pptrPage: Page; #listeners?: ListenerMap; + #idGenerator = createIdGenerator(); protected maxNavigationSaved = 3; /** @@ -63,12 +101,8 @@ export class PageCollector { ) { this.pptrPage = page; - const idGenerator = createIdGenerator(); - const listenerMap = listeners(value => { - const withId = value as WithSymbolId; - withId[stableIdSymbol] = idGenerator(); - this.storage[0].push(withId); + this.storage[0].push(this.#withStableId(value)); }); listenerMap['framenavigated'] = (frame: Frame) => { @@ -94,6 +128,22 @@ export class PageCollector { } } + #withStableId(value: T): WithSymbolId { + const withId = value as WithSymbolId; + withId[stableIdSymbol] = this.#idGenerator(); + return withId; + } + + /** + * Prepends items that were collected out-of-band and predate live + * collection. The bucket is the navigation bucket the items belong to, + * which may no longer be the current one if the page navigated in the + * meantime. + */ + protected prepend(items: T[], bucket: Array>): void { + bucket.unshift(...items.map(item => this.#withStableId(item))); + } + protected splitAfterNavigation() { // Add the latest navigation first this.storage.unshift([]); @@ -142,21 +192,110 @@ export class PageCollector { } export class ConsoleCollector extends PageCollector< - ConsoleMessage | Error | DevTools.AggregatedIssue | UncaughtError + | ConsoleMessage + | BufferedConsoleMessage + | Error + | DevTools.AggregatedIssue + | UncaughtError > { #subscriber?: PageEventSubscriber; + #backfill: Promise; constructor( page: Page, listeners: ( collector: ( - item: ConsoleMessage | Error | DevTools.AggregatedIssue | UncaughtError, + item: + | ConsoleMessage + | BufferedConsoleMessage + | Error + | DevTools.AggregatedIssue + | UncaughtError, ) => void, ) => ListenerMap, ) { super(page, listeners); this.#subscriber = new PageEventSubscriber(this.pptrPage); this.#subscriber.subscribe(); + this.#backfill = this.#backfillBufferedMessages(); + } + + /** + * Resolves once messages buffered before this collector attached have been + * recovered. + */ + get backfilled(): Promise { + return this.#backfill; + } + + /** + * Chromium buffers console messages, uncaught exceptions, and log entries + * per page and replays them to every new CDP session that enables the + * respective domain. Puppeteer consumes that replay internally while + * connecting, before any `console` listener can subscribe, so anything + * logged before this collector attached would otherwise be missing. + * Recover it through a throwaway CDP session. Events stamped after the + * attach time are dropped because the live listeners already collect them. + */ + async #backfillBufferedMessages(): Promise { + // Called from the constructor and runs synchronously up to the first + // await, so both capture the attach-time state. + const attachedAt = Date.now(); + const bucket = this.storage[0]; + try { + const session = await this.pptrPage.createCDPSession(); + try { + const {targetInfo} = await session.send('Target.getTargetInfo'); + const targetId = targetInfo.targetId; + const buffered: Array<{ + timestamp: number; + item: BufferedConsoleMessage | UncaughtError; + }> = []; + session.on('Runtime.consoleAPICalled', event => { + if (event.timestamp <= attachedAt) { + buffered.push({ + timestamp: event.timestamp, + item: createBufferedConsoleMessage(event, targetId), + }); + } + }); + session.on('Runtime.exceptionThrown', event => { + if (event.timestamp <= attachedAt) { + buffered.push({ + timestamp: event.timestamp, + item: new UncaughtError(event.exceptionDetails, targetId), + }); + } + }); + session.on('Log.entryAdded', event => { + const entry = event.entry; + // Puppeteer skips worker log entries in its live conversion. + if (entry.timestamp <= attachedAt && entry.source !== 'worker') { + buffered.push({ + timestamp: entry.timestamp, + item: createBufferedLogMessage(entry, targetId), + }); + } + }); + // Buffered events are replayed before the respective call resolves. + await session.send('Runtime.enable'); + await session.send('Log.enable'); + if (buffered.length > 0) { + // Merge the replays of both domains chronologically. + this.prepend( + buffered + .sort((a, b) => a.timestamp - b.timestamp) + .map(entry => entry.item), + bucket, + ); + } + } finally { + // Detaching also releases the remote objects held by replayed args. + await session.detach(); + } + } catch (error) { + logger?.('Error backfilling buffered console messages', error); + } } override dispose(): void { @@ -165,6 +304,65 @@ export class ConsoleCollector extends PageCollector< } } +/** + * Creates a BufferedConsoleMessage from a replayed `Runtime.consoleAPICalled` + * event, converting the type and args text the same way Puppeteer converts + * live events. + */ +function createBufferedConsoleMessage( + event: Protocol.Runtime.ConsoleAPICalledEvent, + targetId: string, +): BufferedConsoleMessage { + return new BufferedConsoleMessage({ + type: event.type === 'warning' ? 'warn' : event.type, + text: event.args + .map(remoteObject => textFromRemoteObject(remoteObject)) + .join(' '), + argsCount: event.args.length, + rawStackTrace: event.stackTrace, + targetId, + }); +} + +/** + * Creates a BufferedConsoleMessage from a replayed `Log.entryAdded` event, + * mirroring Puppeteer's own conversion. + */ +function createBufferedLogMessage( + entry: Protocol.Log.LogEntry, + targetId: string, +): BufferedConsoleMessage { + return new BufferedConsoleMessage({ + type: entry.level === 'warning' ? 'warn' : entry.level, + text: entry.text, + argsCount: 0, + rawStackTrace: entry.stackTrace, + targetId, + }); +} + +/** + * Mirrors Puppeteer's `valueFromJSHandle` for args that are only available + * as protocol RemoteObjects. + */ +function textFromRemoteObject( + remoteObject: Protocol.Runtime.RemoteObject, +): string { + if (remoteObject.objectId) { + const description = remoteObject.description ?? ''; + if (remoteObject.subtype === 'error' && description) { + return description.split('\n')[0]; + } + return `[${remoteObject.subtype || remoteObject.type} ${remoteObject.className}]`; + } + if (remoteObject.unserializableValue) { + return remoteObject.type === 'bigint' + ? remoteObject.unserializableValue.replace('n', '') + : remoteObject.unserializableValue; + } + return String(remoteObject.value); +} + class PageEventSubscriber { #issueManager = new FakeIssuesManager(); #issueAggregator = new DevTools.IssueAggregator(this.#issueManager); diff --git a/src/formatters/ConsoleFormatter.ts b/src/formatters/ConsoleFormatter.ts index 5c77c2168..a56afaff7 100644 --- a/src/formatters/ConsoleFormatter.ts +++ b/src/formatters/ConsoleFormatter.ts @@ -5,11 +5,12 @@ */ import { + createStackTrace, createStackTraceForConsoleMessage, type TargetUniverse, SymbolizedError, } from '../devtools/DevtoolsUtils.js'; -import {UncaughtError} from '../PageCollector.js'; +import {BufferedConsoleMessage, UncaughtError} from '../PageCollector.js'; import * as DevTools from '../third_party/index.js'; import type {ConsoleMessage} from '../third_party/index.js'; @@ -78,7 +79,7 @@ export class ConsoleFormatter { } static async from( - msg: ConsoleMessage | UncaughtError, + msg: ConsoleMessage | BufferedConsoleMessage | UncaughtError, options: ConsoleFormatterOptions, ): Promise { const ignoreListManager = options?.devTools?.universe.context.get( @@ -124,6 +125,35 @@ export class ConsoleFormatter { }); } + if (msg instanceof BufferedConsoleMessage) { + let stack: DevTools.DevTools.StackTrace.StackTrace.StackTrace | undefined; + if (options.resolvedStackTraceForTesting) { + stack = options.resolvedStackTraceForTesting; + } else if ( + options.fetchDetailedData && + options.devTools && + msg.rawStackTrace + ) { + try { + stack = await createStackTrace( + options.devTools, + msg.rawStackTrace, + msg.targetId, + ); + } catch { + // ignore + } + } + return new ConsoleFormatter({ + id: options.id, + type: msg.type(), + text: msg.text(), + argCount: msg.argsCount, + stack, + isIgnored, + }); + } + let resolvedArgs: unknown[] = []; if (options.resolvedArgsForTesting) { resolvedArgs = options.resolvedArgsForTesting; diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts index af28e18f9..379a940ea 100644 --- a/tests/McpResponse.test.ts +++ b/tests/McpResponse.test.ts @@ -626,7 +626,7 @@ describe('McpResponse', () => { }; mockAggregatedIssue.getDescription.returns(mockDescription); response.setIncludeConsoleData(true); - context.getSelectedMcpPage().getConsoleData = () => { + context.getSelectedMcpPage().getConsoleData = async () => { return [mockAggregatedIssue]; }; @@ -651,7 +651,7 @@ describe('McpResponse', () => { }; mockAggregatedIssue.getDescription.returns(mockDescription); response.attachConsoleMessage(1); - context.getSelectedMcpPage().getConsoleMessageById = () => { + context.getSelectedMcpPage().getConsoleMessageById = async () => { return mockAggregatedIssue; }; diff --git a/tests/PageCollector.test.ts b/tests/PageCollector.test.ts index 774f188d6..b15574afd 100644 --- a/tests/PageCollector.test.ts +++ b/tests/PageCollector.test.ts @@ -7,18 +7,26 @@ import assert from 'node:assert'; import {afterEach, beforeEach, describe, it} from 'node:test'; -import type {Frame, HTTPRequest, Protocol} from 'puppeteer-core'; +import type { + CDPSession, + ConsoleMessage, + Frame, + HTTPRequest, + Protocol, +} from 'puppeteer-core'; import sinon from 'sinon'; import type {ListenerMap} from '../src/PageCollector.js'; import { + BufferedConsoleMessage, ConsoleCollector, NetworkCollector, PageCollector, + UncaughtError, } from '../src/PageCollector.js'; import {DevTools} from '../src/third_party/index.js'; -import {getMockRequest, getMockBrowser} from './utils.js'; +import {getMockRequest, getMockBrowser, mockListener} from './utils.js'; describe('PageCollector', () => { it('works', async () => { @@ -246,6 +254,35 @@ describe('NetworkCollector', () => { }); }); +function getMockCdpSessionWithReplay( + replayed: Array< + | ['Runtime.consoleAPICalled', Protocol.Runtime.ConsoleAPICalledEvent] + | ['Runtime.exceptionThrown', Protocol.Runtime.ExceptionThrownEvent] + | ['Log.entryAdded', Protocol.Log.EntryAddedEvent] + >, +): CDPSession { + const session = { + ...mockListener(), + async send(method: string) { + if (method === 'Target.getTargetInfo') { + return {targetInfo: {targetId: ''}}; + } + // Chromium replays buffered events before the enable call resolves. + const domain = method.split('.')[0]; + for (const [eventName, event] of replayed) { + if (method.endsWith('.enable') && eventName.startsWith(domain)) { + session.emit(eventName, event); + } + } + return undefined; + }, + async detach() { + // no-op + }, + }; + return session as unknown as CDPSession; +} + describe('ConsoleCollector', () => { let issue: Protocol.Audits.InspectorIssue; @@ -375,4 +412,169 @@ describe('ConsoleCollector', () => { }), ); }); + + it('backfills messages buffered before construction', async () => { + const browser = getMockBrowser(); + const page = (await browser.pages())[0]; + page.createCDPSession = async () => + getMockCdpSessionWithReplay([ + [ + 'Runtime.consoleAPICalled', + { + type: 'warning', + args: [ + {type: 'string', value: 'buffered'}, + {type: 'number', value: 42}, + ], + executionContextId: 1, + timestamp: Date.now() - 2000, + }, + ], + [ + 'Runtime.exceptionThrown', + { + timestamp: Date.now() - 1000, + exceptionDetails: { + exceptionId: 1, + text: 'Uncaught', + lineNumber: 0, + columnNumber: 0, + }, + }, + ], + [ + 'Log.entryAdded', + { + entry: { + source: 'network', + level: 'error', + text: 'Failed to load resource: net::ERR_CONNECTION_REFUSED', + // The oldest event, though replayed last (the Log domain is + // enabled after Runtime) - the merge must order it first. + timestamp: Date.now() - 3000, + }, + }, + ], + [ + 'Log.entryAdded', + { + entry: { + source: 'worker', + level: 'verbose', + text: 'skipped like in live collection', + timestamp: Date.now() - 3000, + }, + }, + ], + ]); + + const collector = new ConsoleCollector(page, collect => { + return { + console: message => { + collect(message); + }, + uncaughtError: error => { + collect(error); + }, + } as ListenerMap; + }); + await collector.backfilled; + + const data = collector.getData(); + assert.equal(data.length, 3); + const logEntry = data[0]; + assert.ok(logEntry instanceof BufferedConsoleMessage); + assert.equal(logEntry.type(), 'error'); + assert.ok(logEntry.text().startsWith('Failed to load resource')); + const message = data[1]; + assert.ok(message instanceof BufferedConsoleMessage); + assert.equal(message.type(), 'warn'); + assert.equal(message.text(), 'buffered 42'); + assert.equal(message.argsCount, 2); + assert.ok(data[2] instanceof UncaughtError); + }); + + it('prepends backfilled messages and drops post-attach replays', async () => { + const browser = getMockBrowser(); + const page = (await browser.pages())[0]; + page.createCDPSession = async () => + getMockCdpSessionWithReplay([ + [ + 'Runtime.consoleAPICalled', + { + type: 'log', + args: [{type: 'string', value: 'buffered'}], + executionContextId: 1, + timestamp: Date.now() - 1000, + }, + ], + [ + 'Runtime.consoleAPICalled', + { + type: 'log', + // Live collection already has anything logged after attach time, + // so this replayed event must be dropped. + args: [{type: 'string', value: 'already collected live'}], + executionContextId: 1, + timestamp: Date.now() + 60_000, + }, + ], + ]); + + const collector = new ConsoleCollector(page, collect => { + return { + console: message => { + collect(message); + }, + } as ListenerMap; + }); + const liveMessage = {} as ConsoleMessage; + page.emit('console', liveMessage); + await collector.backfilled; + + const data = collector.getData(); + assert.equal(data.length, 2); + const backfilled = data[0]; + assert.ok(backfilled instanceof BufferedConsoleMessage); + assert.equal(backfilled.text(), 'buffered'); + assert.equal(data[1], liveMessage); + }); + + it('keeps backfilled messages out of newer navigations', async () => { + const browser = getMockBrowser(); + const page = (await browser.pages())[0]; + const mainFrame = page.mainFrame(); + mainFrame.page = () => page; + const {promise: sessionPromise, resolve: resolveSession} = + Promise.withResolvers(); + page.createCDPSession = () => sessionPromise; + + const collector = new ConsoleCollector(page, collect => { + return { + console: message => { + collect(message); + }, + } as ListenerMap; + }); + // The page navigates before the backfill session is ready, so the + // recovered message belongs to the previous navigation's bucket. + page.emit('framenavigated', page.mainFrame()); + resolveSession( + getMockCdpSessionWithReplay([ + [ + 'Runtime.consoleAPICalled', + { + type: 'log', + args: [{type: 'string', value: 'buffered'}], + executionContextId: 1, + timestamp: Date.now() - 1000, + }, + ], + ]), + ); + await collector.backfilled; + + assert.equal(collector.getData().length, 0); + assert.equal(collector.getData(true).length, 1); + }); }); diff --git a/tests/formatters/ConsoleFormatter.test.js.snapshot b/tests/formatters/ConsoleFormatter.test.js.snapshot index 9f4490278..33ace4d5f 100644 --- a/tests/formatters/ConsoleFormatter.test.js.snapshot +++ b/tests/formatters/ConsoleFormatter.test.js.snapshot @@ -1,3 +1,16 @@ +exports[`ConsoleFormatter > toString/toJSON > formats a buffered console message toJSON 1`] = ` +{ + "type": "log", + "text": "Recovered from the buffer", + "argsCount": 2, + "id": 1 +} +`; + +exports[`ConsoleFormatter > toString/toJSON > formats a buffered console message toString 1`] = ` +msgid=1 [log] Recovered from the buffer (2 args) +`; + exports[`ConsoleFormatter > toString/toJSON > formats a console.log message toJSON 1`] = ` { "type": "log", @@ -91,6 +104,25 @@ at bar (foo.ts:20:2) Note: line and column numbers use 1-based indexing `; +exports[`ConsoleFormatter > toStringDetailed/toJSONDetailed > formats a buffered console message toJSONDetailed 1`] = ` +{ + "id": 1, + "type": "error", + "text": "Recovered from the buffer", + "argsCount": 1, + "args": [], + "stackTrace": "at foo (foo.ts:10:2)\\nNote: line and column numbers use 1-based indexing" +} +`; + +exports[`ConsoleFormatter > toStringDetailed/toJSONDetailed > formats a buffered console message toStringDetailed 1`] = ` +ID: 1 +Message: error> Recovered from the buffer +### Stack trace +at foo (foo.ts:10:2) +Note: line and column numbers use 1-based indexing +`; + exports[`ConsoleFormatter > toStringDetailed/toJSONDetailed > formats a console message with a stack trace toJSONDetailed 1`] = ` { "id": 1, diff --git a/tests/formatters/ConsoleFormatter.test.ts b/tests/formatters/ConsoleFormatter.test.ts index 2213f429a..01bcd1d2d 100644 --- a/tests/formatters/ConsoleFormatter.test.ts +++ b/tests/formatters/ConsoleFormatter.test.ts @@ -8,7 +8,10 @@ import {describe, it} from 'node:test'; import {SymbolizedError} from '../../src/devtools/DevtoolsUtils.js'; import {ConsoleFormatter} from '../../src/formatters/ConsoleFormatter.js'; -import {UncaughtError} from '../../src/PageCollector.js'; +import { + BufferedConsoleMessage, + UncaughtError, +} from '../../src/PageCollector.js'; import type {ConsoleMessage, Protocol} from '../../src/third_party/index.js'; import type {DevTools} from '../../src/third_party/index.js'; @@ -71,6 +74,16 @@ describe('ConsoleFormatter', () => { return await ConsoleFormatter.from(message, {id: 1}); }); + formatterTestConcise('formats a buffered console message', async () => { + const message = new BufferedConsoleMessage({ + type: 'log', + text: 'Recovered from the buffer', + argsCount: 2, + targetId: 'target-1', + }); + return await ConsoleFormatter.from(message, {id: 1}); + }); + formatterTestConcise( 'formats a console.log message with one argument', async () => { @@ -194,6 +207,32 @@ describe('ConsoleFormatter', () => { }); }); + formatterTestDetailed('formats a buffered console message', async () => { + const message = new BufferedConsoleMessage({ + type: 'error', + text: 'Recovered from the buffer', + argsCount: 1, + targetId: 'target-1', + }); + const stackTrace = { + syncFragment: { + frames: [ + { + line: 10, + column: 2, + url: 'foo.ts', + name: 'foo', + }, + ], + }, + asyncFragments: [], + } as unknown as DevTools.StackTrace.StackTrace.StackTrace; + return await ConsoleFormatter.from(message, { + id: 1, + resolvedStackTraceForTesting: stackTrace, + }); + }); + formatterTestDetailed( 'formats a console message with a stack trace', async () => { diff --git a/tests/tools/console.test.ts b/tests/tools/console.test.ts index 3a3ee9642..c94176eb8 100644 --- a/tests/tools/console.test.ts +++ b/tests/tools/console.test.ts @@ -8,10 +8,13 @@ import assert from 'node:assert'; import path from 'node:path'; import {before, describe, it} from 'node:test'; +import logger from 'debug'; +import {Locator} from 'puppeteer'; import type {Dialog} from 'puppeteer-core'; import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js'; import {loadIssueDescriptions} from '../../src/devtools/issueDescriptions.js'; +import {McpContext} from '../../src/McpContext.js'; import {McpResponse} from '../../src/McpResponse.js'; import {TextSnapshot} from '../../src/TextSnapshot.js'; import type {CdpWebWorker} from '../../src/third_party/index.js'; @@ -24,6 +27,7 @@ import {installExtension} from '../../src/tools/extensions.js'; import {serverHooks} from '../server.js'; import { getTextContent, + withBrowser, withMcpContext, stabilizeStructuredContent, extractExtensionId, @@ -203,6 +207,81 @@ describe('console', () => { }); }); + it('lists messages logged before the collector attached', async () => { + await withBrowser(async (browser, page) => { + // Log while nothing is attached yet. The uncaught error resolves the + // promise via the error event, so it is buffered once evaluate + // returns. + await page.evaluate(async () => { + console.log('pre-attach log'); + // A fetch to a restricted port fails in the browser itself, + // producing a deterministic network error log entry. + await fetch('http://127.0.0.1:1/').catch(() => undefined); + await new Promise(resolve => { + window.addEventListener('error', () => resolve()); + setTimeout(() => { + throw new Error('pre-attach error'); + }, 0); + }); + }); + + TextSnapshot.resetCounter(); + McpContext.resetPageIdsForTesting(); + const context = await McpContext.from( + browser, + logger('test'), + { + experimentalDevToolsDebugging: false, + performanceCrux: false, + }, + Locator, + ); + try { + // No waiting for the backfill here: reading right after attach must + // not race it, since `getConsoleData()` awaits it internally. + const mcpPage = context.getSelectedMcpPage(); + await mcpPage.pptrPage.evaluate(() => { + console.log('post-attach log'); + }); + + const response = new McpResponse({} as ParsedArguments); + response.setPage(mcpPage); + await listConsoleMessages().handler( + {params: {}, page: mcpPage}, + response, + context, + ); + const formattedResponse = await response.handle('test', context); + const textContent = getTextContent(formattedResponse.content[0]); + + assert.ok( + textContent.includes('[log] pre-attach log'), + 'Should contain the buffered log', + ); + assert.ok( + textContent.includes('pre-attach error'), + 'Should contain the buffered uncaught error', + ); + assert.ok( + textContent.includes('Failed to load resource'), + 'Should contain the buffered network error log entry', + ); + assert.ok( + textContent.indexOf('pre-attach log') < + textContent.indexOf('post-attach log'), + 'Buffered messages should come first', + ); + assert.equal( + textContent.split('post-attach log').length, + 2, + 'Live messages should not be duplicated by the backfill', + ); + } finally { + context.dispose(); + } + }); + }); + describe('issues', () => { it('lists issues', async () => { await withMcpContext(async (response, context) => { @@ -403,7 +482,7 @@ describe('console', () => { `); page.textSnapshot = await TextSnapshot.create(page); await issuePromise; - const messages = page.getConsoleData(); + const messages = await page.getConsoleData(); let issueMsg; for (const message of messages) { if (message instanceof DevTools.AggregatedIssue) {