@@ -156,6 +156,19 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number):
156156 }
157157 const eventWaiters : EventWaiter [ ] = [ ] ;
158158
159+ // Track the latest Document response status PER FRAME so we can fail fast
160+ // on 4xx/5xx (404 / 503 / auth wall pages) instead of capturing what looks
161+ // like the app but isn't. The main frame's id is only known once
162+ // Page.navigate resolves, but `Network.responseReceived` for the main
163+ // document frequently arrives BEFORE that response — so record every
164+ // Document status keyed by frameId and resolve which one is the main
165+ // document afterwards. (Recording a single "latest" status instead would
166+ // race: an early sub-frame response could be misattributed as the main
167+ // document, or the real main-document status missed entirely.)
168+ // Redirect chains re-fire for the same frameId; last write wins, which is
169+ // the final response. (Auth walls that return 200 are out of scope — #287.)
170+ const documentStatusByFrame = new Map < string , number > ( ) ;
171+
159172 ws . on ( 'message' , ( raw : RawData ) => {
160173 const data = raw . toString ( ) ;
161174 let msg : CdpMessage ;
@@ -164,6 +177,19 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number):
164177 } catch {
165178 return ;
166179 }
180+ if ( msg . method === 'Network.responseReceived' ) {
181+ const params = msg . params as
182+ | { type ?: string ; frameId ?: string ; response ?: { status ?: number } }
183+ | undefined ;
184+ // CDP's `Network.responseReceived` fires for every resource (HTML,
185+ // JS, CSS, images, XHR, …). Only type==='Document' responses are
186+ // candidate main-document responses.
187+ if ( params ?. type === 'Document' && typeof params . frameId === 'string' ) {
188+ const status = params . response ?. status ;
189+ if ( typeof status === 'number' ) documentStatusByFrame . set ( params . frameId , status ) ;
190+ }
191+ return ;
192+ }
167193 if ( typeof msg . id === 'number' ) {
168194 const slot = pending . get ( msg . id ) ;
169195 if ( slot ) {
@@ -262,14 +288,6 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number):
262288 } ) ;
263289 }
264290
265- // Track the main-document HTTP response so we can fail fast on 4xx/5xx
266- // (404 / 503 / auth wall pages) instead of capturing what looks like the
267- // app but isn't. Captured in a Network.responseReceived listener below;
268- // checked after Page.loadEventFired but before Page.captureScreenshot.
269- // (Auth walls that return 200 are out of scope — see issue #287.)
270- let mainDocumentStatus : number | null = null ;
271- let mainDocumentFrameId : string | null = null ;
272-
273291 try {
274292 // 1. List existing targets, find the default about:blank page.
275293 const targetsResp = await cdpSend ( 'Target.getTargets' ) ;
@@ -293,34 +311,11 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number):
293311 // we wait on below AND the main-document response status. Network
294312 // has to be enabled BEFORE Page.navigate, or the response event
295313 // fires before our listener is wired and we miss the status.
314+ // (Document statuses are recorded per-frame by the single message
315+ // listener above; we resolve the main frame's status after load.)
296316 await cdpSend ( 'Page.enable' , { } , pageSessionId ) ;
297317 await cdpSend ( 'Network.enable' , { } , pageSessionId ) ;
298318
299- // Tap the raw message stream for Network.responseReceived events —
300- // we want a multi-fire listener (Document responses can appear for
301- // redirect chains), not the one-shot waiter pattern that
302- // eventWaiters / waitForEvent use. Records the latest matching
303- // status; the post-load check below acts on whatever was captured.
304- ws . on ( 'message' , ( raw : RawData ) => {
305- let msg : CdpMessage ;
306- try {
307- msg = JSON . parse ( raw . toString ( ) ) as CdpMessage ;
308- } catch {
309- return ;
310- }
311- if ( msg . method !== 'Network.responseReceived' ) return ;
312- const params = msg . params as
313- | { type ?: string ; frameId ?: string ; response ?: { status ?: number } }
314- | undefined ;
315- // CDP's `Network.responseReceived` fires for every resource (HTML,
316- // JS, CSS, images, XHR, …). Only the type==='Document' event for
317- // the navigated frame is the main-document response we care about.
318- if ( ! params || params . type !== 'Document' ) return ;
319- if ( mainDocumentFrameId && params . frameId !== mainDocumentFrameId ) return ;
320- const status = params . response ?. status ;
321- if ( typeof status === 'number' ) mainDocumentStatus = status ;
322- } ) ;
323-
324319 // 4. Navigate. The response includes a `frameId`; we wait on the
325320 // `Page.loadEventFired` event below (more reliable than
326321 // `frameStoppedLoading` which can fire before navigation
@@ -330,7 +325,7 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number):
330325 if ( navError ) {
331326 throw new Error ( `Page.navigate failed: ${ navError } ` ) ;
332327 }
333- mainDocumentFrameId = ( navResp . result ?. frameId as string | undefined ) ?? null ;
328+ const mainDocumentFrameId = ( navResp . result ?. frameId as string | undefined ) ?? null ;
334329
335330 // 5. Wait for the page load event. SPA-style apps may continue
336331 // fetching after this fires, so add a 2s settle wait. For
@@ -344,10 +339,21 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number):
344339 // perspective; the user sees a confidently-wrong screenshot of an
345340 // error page posted as the deploy preview. Throw → processor's
346341 // catch logs and skips the PR/Linear comment cleanly.
347- // If we never captured a status (Network.responseReceived was
348- // queued but predicate didn't match — e.g. a redirect chain that
349- // doesn't expose the final frame), fall through and capture
342+ // The main frame's id comes from the Page.navigate response; its
343+ // Document responses were recorded per-frame by the message
344+ // listener even if they arrived before navigate resolved. If
345+ // Page.navigate returned no frameId, only an unambiguous single
346+ // recorded status is trusted — with multiple frames we cannot
347+ // tell which is the main document.
348+ // If we never captured a status (e.g. a service variant that
349+ // doesn't emit Network events), fall through and capture
350350 // optimistically; that's the pre-#287 behaviour.
351+ let mainDocumentStatus : number | null = null ;
352+ if ( mainDocumentFrameId !== null ) {
353+ mainDocumentStatus = documentStatusByFrame . get ( mainDocumentFrameId ) ?? null ;
354+ } else if ( documentStatusByFrame . size === 1 ) {
355+ mainDocumentStatus = documentStatusByFrame . values ( ) . next ( ) . value ?? null ;
356+ }
351357 if ( mainDocumentStatus !== null && ( mainDocumentStatus < 200 || mainDocumentStatus >= 300 ) ) {
352358 throw new Error ( `Preview URL returned HTTP ${ mainDocumentStatus } ; skipping screenshot` ) ;
353359 }
0 commit comments