@@ -8,6 +8,7 @@ import {FakeIssuesManager} from './devtools/DevtoolsUtils.js';
88import type {
99 CDPSession ,
1010 ConsoleMessage ,
11+ ConsoleMessageType ,
1112 Protocol ,
1213 Issue ,
1314} from './third_party/index.js' ;
@@ -36,6 +37,42 @@ export class UncaughtError {
3637 }
3738}
3839
40+ /**
41+ * A console message recovered from Chromium's per-page buffer after the fact.
42+ * Replayed args cannot be adopted as live JSHandles, so it only carries their
43+ * text representation plus the raw stack trace for symbolization.
44+ */
45+ export class BufferedConsoleMessage {
46+ readonly argsCount : number ;
47+ readonly rawStackTrace ?: Protocol . Runtime . StackTrace ;
48+ readonly targetId : string ;
49+ #type: ConsoleMessageType ;
50+ #text: string ;
51+
52+ constructor ( params : {
53+ type : ConsoleMessageType ;
54+ text : string ;
55+ argsCount : number ;
56+ rawStackTrace ?: Protocol . Runtime . StackTrace ;
57+ targetId : string ;
58+ } ) {
59+ this . #type = params . type ;
60+ this . #text = params . text ;
61+ this . argsCount = params . argsCount ;
62+ this . rawStackTrace = params . rawStackTrace ;
63+ this . targetId = params . targetId ;
64+ }
65+
66+ // Accessors mirror ConsoleMessage so shared code paths work on both.
67+ type ( ) : ConsoleMessageType {
68+ return this . #type;
69+ }
70+
71+ text ( ) : string {
72+ return this . #text;
73+ }
74+ }
75+
3976interface PageEvents extends PuppeteerPageEvents {
4077 devtoolsAggregatedIssue : DevTools . AggregatedIssue ;
4178 uncaughtError : UncaughtError ;
@@ -48,6 +85,7 @@ export type ListenerMap<EventMap extends PageEvents = PageEvents> = {
4885export class PageCollector < T > {
4986 protected pptrPage : Page ;
5087 #listeners?: ListenerMap < PageEvents > ;
88+ #idGenerator = createIdGenerator ( ) ;
5189 protected maxNavigationSaved = 3 ;
5290
5391 /**
@@ -63,12 +101,8 @@ export class PageCollector<T> {
63101 ) {
64102 this . pptrPage = page ;
65103
66- const idGenerator = createIdGenerator ( ) ;
67-
68104 const listenerMap = listeners ( value => {
69- const withId = value as WithSymbolId < T > ;
70- withId [ stableIdSymbol ] = idGenerator ( ) ;
71- this . storage [ 0 ] . push ( withId ) ;
105+ this . storage [ 0 ] . push ( this . #withStableId( value ) ) ;
72106 } ) ;
73107
74108 listenerMap [ 'framenavigated' ] = ( frame : Frame ) => {
@@ -94,6 +128,22 @@ export class PageCollector<T> {
94128 }
95129 }
96130
131+ #withStableId( value : T ) : WithSymbolId < T > {
132+ const withId = value as WithSymbolId < T > ;
133+ withId [ stableIdSymbol ] = this . #idGenerator( ) ;
134+ return withId ;
135+ }
136+
137+ /**
138+ * Prepends items that were collected out-of-band and predate live
139+ * collection. The bucket is the navigation bucket the items belong to,
140+ * which may no longer be the current one if the page navigated in the
141+ * meantime.
142+ */
143+ protected prepend ( items : T [ ] , bucket : Array < WithSymbolId < T > > ) : void {
144+ bucket . unshift ( ...items . map ( item => this . #withStableId( item ) ) ) ;
145+ }
146+
97147 protected splitAfterNavigation ( ) {
98148 // Add the latest navigation first
99149 this . storage . unshift ( [ ] ) ;
@@ -142,21 +192,110 @@ export class PageCollector<T> {
142192}
143193
144194export class ConsoleCollector extends PageCollector <
145- ConsoleMessage | Error | DevTools . AggregatedIssue | UncaughtError
195+ | ConsoleMessage
196+ | BufferedConsoleMessage
197+ | Error
198+ | DevTools . AggregatedIssue
199+ | UncaughtError
146200> {
147201 #subscriber?: PageEventSubscriber ;
202+ #backfill: Promise < void > ;
148203
149204 constructor (
150205 page : Page ,
151206 listeners : (
152207 collector : (
153- item : ConsoleMessage | Error | DevTools . AggregatedIssue | UncaughtError ,
208+ item :
209+ | ConsoleMessage
210+ | BufferedConsoleMessage
211+ | Error
212+ | DevTools . AggregatedIssue
213+ | UncaughtError ,
154214 ) => void ,
155215 ) => ListenerMap < PageEvents > ,
156216 ) {
157217 super ( page , listeners ) ;
158218 this . #subscriber = new PageEventSubscriber ( this . pptrPage ) ;
159219 this . #subscriber. subscribe ( ) ;
220+ this . #backfill = this . #backfillBufferedMessages( ) ;
221+ }
222+
223+ /**
224+ * Resolves once messages buffered before this collector attached have been
225+ * recovered.
226+ */
227+ get backfillForTesting ( ) : Promise < void > {
228+ return this . #backfill;
229+ }
230+
231+ /**
232+ * Chromium buffers console messages, uncaught exceptions, and log entries
233+ * per page and replays them to every new CDP session that enables the
234+ * respective domain. Puppeteer consumes that replay internally while
235+ * connecting, before any `console` listener can subscribe, so anything
236+ * logged before this collector attached would otherwise be missing.
237+ * Recover it through a throwaway CDP session. Events stamped after the
238+ * attach time are dropped because the live listeners already collect them.
239+ */
240+ async #backfillBufferedMessages( ) : Promise < void > {
241+ // Called from the constructor and runs synchronously up to the first
242+ // await, so both capture the attach-time state.
243+ const attachedAt = Date . now ( ) ;
244+ const bucket = this . storage [ 0 ] ;
245+ try {
246+ const session = await this . pptrPage . createCDPSession ( ) ;
247+ try {
248+ const { targetInfo} = await session . send ( 'Target.getTargetInfo' ) ;
249+ const targetId = targetInfo . targetId ;
250+ const buffered : Array < {
251+ timestamp : number ;
252+ item : BufferedConsoleMessage | UncaughtError ;
253+ } > = [ ] ;
254+ session . on ( 'Runtime.consoleAPICalled' , event => {
255+ if ( event . timestamp <= attachedAt ) {
256+ buffered . push ( {
257+ timestamp : event . timestamp ,
258+ item : createBufferedConsoleMessage ( event , targetId ) ,
259+ } ) ;
260+ }
261+ } ) ;
262+ session . on ( 'Runtime.exceptionThrown' , event => {
263+ if ( event . timestamp <= attachedAt ) {
264+ buffered . push ( {
265+ timestamp : event . timestamp ,
266+ item : new UncaughtError ( event . exceptionDetails , targetId ) ,
267+ } ) ;
268+ }
269+ } ) ;
270+ session . on ( 'Log.entryAdded' , event => {
271+ const entry = event . entry ;
272+ // Puppeteer skips worker log entries in its live conversion.
273+ if ( entry . timestamp <= attachedAt && entry . source !== 'worker' ) {
274+ buffered . push ( {
275+ timestamp : entry . timestamp ,
276+ item : createBufferedLogMessage ( entry , targetId ) ,
277+ } ) ;
278+ }
279+ } ) ;
280+ // Buffered events are replayed before the respective call resolves.
281+ await session . send ( 'Runtime.enable' ) ;
282+ await session . send ( 'Log.enable' ) ;
283+ if ( buffered . length > 0 ) {
284+ // Merge the replays of both domains chronologically.
285+ this . prepend (
286+ buffered
287+ . sort ( ( a , b ) => a . timestamp - b . timestamp )
288+ . map ( entry => entry . item ) ,
289+ bucket ,
290+ ) ;
291+ }
292+ } finally {
293+ // Detaching also releases the remote objects held by replayed args.
294+ await session . detach ( ) ;
295+ }
296+ } catch ( error ) {
297+ logger ?.( 'Error backfilling buffered console messages' , error ) ;
298+ }
160299 }
161300
162301 override dispose ( ) : void {
@@ -165,6 +304,65 @@ export class ConsoleCollector extends PageCollector<
165304 }
166305}
167306
307+ /**
308+ * Creates a BufferedConsoleMessage from a replayed `Runtime.consoleAPICalled`
309+ * event, converting the type and args text the same way Puppeteer converts
310+ * live events.
311+ */
312+ function createBufferedConsoleMessage (
313+ event : Protocol . Runtime . ConsoleAPICalledEvent ,
314+ targetId : string ,
315+ ) : BufferedConsoleMessage {
316+ return new BufferedConsoleMessage ( {
317+ type : event . type === 'warning' ? 'warn' : event . type ,
318+ text : event . args
319+ . map ( remoteObject => textFromRemoteObject ( remoteObject ) )
320+ . join ( ' ' ) ,
321+ argsCount : event . args . length ,
322+ rawStackTrace : event . stackTrace ,
323+ targetId,
324+ } ) ;
325+ }
326+
327+ /**
328+ * Creates a BufferedConsoleMessage from a replayed `Log.entryAdded` event,
329+ * mirroring Puppeteer's own conversion.
330+ */
331+ function createBufferedLogMessage (
332+ entry : Protocol . Log . LogEntry ,
333+ targetId : string ,
334+ ) : BufferedConsoleMessage {
335+ return new BufferedConsoleMessage ( {
336+ type : entry . level === 'warning' ? 'warn' : entry . level ,
337+ text : entry . text ,
338+ argsCount : 0 ,
339+ rawStackTrace : entry . stackTrace ,
340+ targetId,
341+ } ) ;
342+ }
343+
344+ /**
345+ * Mirrors Puppeteer's `valueFromJSHandle` for args that are only available
346+ * as protocol RemoteObjects.
347+ */
348+ function textFromRemoteObject (
349+ remoteObject : Protocol . Runtime . RemoteObject ,
350+ ) : string {
351+ if ( remoteObject . objectId ) {
352+ const description = remoteObject . description ?? '' ;
353+ if ( remoteObject . subtype === 'error' && description ) {
354+ return description . split ( '\n' ) [ 0 ] ;
355+ }
356+ return `[${ remoteObject . subtype || remoteObject . type } ${ remoteObject . className } ]` ;
357+ }
358+ if ( remoteObject . unserializableValue ) {
359+ return remoteObject . type === 'bigint'
360+ ? remoteObject . unserializableValue . replace ( 'n' , '' )
361+ : remoteObject . unserializableValue ;
362+ }
363+ return String ( remoteObject . value ) ;
364+ }
365+
168366class PageEventSubscriber {
169367 #issueManager = new FakeIssuesManager ( ) ;
170368 #issueAggregator = new DevTools . IssueAggregator ( this . #issueManager) ;
0 commit comments