Skip to content
Open
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
35 changes: 31 additions & 4 deletions src/McpPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
import {
ConsoleCollector,
NetworkCollector,
type BufferedConsoleMessage,
type ListenerMap,
type UncaughtError,
} from './PageCollector.js';
Expand Down Expand Up @@ -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<void> {
if (!this.#dialog) {
await this.consoleCollector.backfilled;
}
}

async getConsoleData(
includePreservedMessages?: boolean,
): Array<ConsoleMessage | Error | DevTools.AggregatedIssue | UncaughtError> {
): 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);
}

Expand Down
31 changes: 21 additions & 10 deletions src/McpResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
);
}
Expand All @@ -599,9 +602,12 @@ export class McpResponse implements Response {
async (item): Promise<ConsoleFormatter | IssueFormatter | null> => {
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,
Expand Down Expand Up @@ -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<typeof message>)[stableIdSymbol] ?? -1;
}
Expand Down
212 changes: 205 additions & 7 deletions src/PageCollector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {FakeIssuesManager} from './devtools/DevtoolsUtils.js';
import type {
CDPSession,
ConsoleMessage,
ConsoleMessageType,
Protocol,
Issue,
} from './third_party/index.js';
Expand Down Expand Up @@ -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;
Expand All @@ -48,6 +85,7 @@ export type ListenerMap<EventMap extends PageEvents = PageEvents> = {
export class PageCollector<T> {
protected pptrPage: Page;
#listeners?: ListenerMap<PageEvents>;
#idGenerator = createIdGenerator();
protected maxNavigationSaved = 3;

/**
Expand All @@ -63,12 +101,8 @@ export class PageCollector<T> {
) {
this.pptrPage = page;

const idGenerator = createIdGenerator();

const listenerMap = listeners(value => {
const withId = value as WithSymbolId<T>;
withId[stableIdSymbol] = idGenerator();
this.storage[0].push(withId);
this.storage[0].push(this.#withStableId(value));
});

listenerMap['framenavigated'] = (frame: Frame) => {
Expand All @@ -94,6 +128,22 @@ export class PageCollector<T> {
}
}

#withStableId(value: T): WithSymbolId<T> {
const withId = value as WithSymbolId<T>;
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<WithSymbolId<T>>): void {
bucket.unshift(...items.map(item => this.#withStableId(item)));
}

protected splitAfterNavigation() {
// Add the latest navigation first
this.storage.unshift([]);
Expand Down Expand Up @@ -142,21 +192,110 @@ export class PageCollector<T> {
}

export class ConsoleCollector extends PageCollector<
ConsoleMessage | Error | DevTools.AggregatedIssue | UncaughtError
| ConsoleMessage
| BufferedConsoleMessage
| Error
| DevTools.AggregatedIssue
| UncaughtError
> {
#subscriber?: PageEventSubscriber;
#backfill: Promise<void>;

constructor(
page: Page,
listeners: (
collector: (
item: ConsoleMessage | Error | DevTools.AggregatedIssue | UncaughtError,
item:
| ConsoleMessage
| BufferedConsoleMessage
| Error
| DevTools.AggregatedIssue
| UncaughtError,
) => void,
) => ListenerMap<PageEvents>,
) {
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<void> {
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<void> {
// 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 {
Expand All @@ -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);
Expand Down
Loading