@@ -262,6 +262,14 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number):
262262 } ) ;
263263 }
264264
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+
265273 try {
266274 // 1. List existing targets, find the default about:blank page.
267275 const targetsResp = await cdpSend ( 'Target.getTargets' ) ;
@@ -281,9 +289,37 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number):
281289 throw new Error ( 'Target.attachToTarget did not return a sessionId' ) ;
282290 }
283291
284- // 3. Enable Page domain so we get the `Page.loadEventFired` event
285- // we wait on below.
292+ // 3. Enable Page + Network so we get the `Page.loadEventFired` event
293+ // we wait on below AND the main-document response status. Network
294+ // has to be enabled BEFORE Page.navigate, or the response event
295+ // fires before our listener is wired and we miss the status.
286296 await cdpSend ( 'Page.enable' , { } , pageSessionId ) ;
297+ await cdpSend ( 'Network.enable' , { } , pageSessionId ) ;
298+
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+ } ) ;
287323
288324 // 4. Navigate. The response includes a `frameId`; we wait on the
289325 // `Page.loadEventFired` event below (more reliable than
@@ -294,6 +330,7 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number):
294330 if ( navError ) {
295331 throw new Error ( `Page.navigate failed: ${ navError } ` ) ;
296332 }
333+ mainDocumentFrameId = ( navResp . result ?. frameId as string | undefined ) ?? null ;
297334
298335 // 5. Wait for the page load event. SPA-style apps may continue
299336 // fetching after this fires, so add a 2s settle wait. For
@@ -302,7 +339,20 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number):
302339 await waitForEvent ( 'Page.loadEventFired' ) ;
303340 await new Promise ( ( r ) => setTimeout ( r , 2000 ) ) ;
304341
305- // 6. Take the screenshot.
342+ // 6. Reject non-2xx main-document statuses before screenshotting.
343+ // A 404 / 503 / auth wall renders a "successful" page from CDP's
344+ // perspective; the user sees a confidently-wrong screenshot of an
345+ // error page posted as the deploy preview. Throw → processor's
346+ // 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
350+ // optimistically; that's the pre-#287 behaviour.
351+ if ( mainDocumentStatus !== null && ( mainDocumentStatus < 200 || mainDocumentStatus >= 300 ) ) {
352+ throw new Error ( `Preview URL returned HTTP ${ mainDocumentStatus } ; skipping screenshot` ) ;
353+ }
354+
355+ // 7. Take the screenshot.
306356 const shotResp = await cdpSend ( 'Page.captureScreenshot' , {
307357 format : 'png' ,
308358 captureBeyondViewport : true ,
0 commit comments