@@ -91,13 +91,47 @@ async function autoScroll(page) {
9191 } ) ;
9292}
9393
94+ /**
95+ * Group viewports by their device scale factor, preserving order.
96+ *
97+ * Playwright's deviceScaleFactor can only be set when a context is created and
98+ * cannot be changed on a live context, so each distinct DPR needs its own
99+ * context. Viewports usually share a DPR, so grouping is cheaper than a context
100+ * per viewport while still letting setViewportSize switch sizes within a group.
101+ * A viewport without an explicit deviceScaleFactor defaults to 1.
102+ *
103+ * @param {Array } viewports - Viewport definitions.
104+ * @returns {Array<{ deviceScaleFactor: number, viewports: Array }> } Groups in
105+ * first-seen DPR order.
106+ */
107+ export function groupViewportsByScaleFactor ( viewports ) {
108+ const groups = new Map ( ) ;
109+
110+ for ( const viewport of viewports ) {
111+ const deviceScaleFactor = viewport . deviceScaleFactor ?? 1 ;
112+ if ( ! groups . has ( deviceScaleFactor ) ) {
113+ groups . set ( deviceScaleFactor , [ ] ) ;
114+ }
115+ groups . get ( deviceScaleFactor ) . push ( viewport ) ;
116+ }
117+
118+ return [ ...groups . entries ( ) ] . map ( ( [ deviceScaleFactor , grouped ] ) => ( {
119+ deviceScaleFactor,
120+ viewports : grouped ,
121+ } ) ) ;
122+ }
123+
94124/**
95125 * Capture screenshots for a single target across all viewports.
96126 *
97127 * Returns the list of slugs that did not capture cleanly so the caller can
98128 * report them. A degraded capture is still written (best effort) but is
99129 * recorded as a failure rather than silently treated as success.
100130 *
131+ * Viewports are captured one context per device scale factor (see
132+ * groupViewportsByScaleFactor); within a context the first viewport navigates
133+ * fresh and the rest reuse the page (reloading unless --skip-reload).
134+ *
101135 * @param {import('playwright').Browser } browser - The shared browser.
102136 * @param {object } target - The target ({ key, url }).
103137 * @param {Array } viewports - Viewport definitions.
@@ -112,107 +146,118 @@ async function captureTarget(browser, target, viewports, dirs, options) {
112146 timeouts = DEFAULT_TIMEOUTS ,
113147 ignoreHTTPSErrors = false ,
114148 } = options ;
115- const context = await browser . newContext ( { ignoreHTTPSErrors } ) ;
116- const page = await context . newPage ( ) ;
117149 const failures = [ ] ;
118150 let currentSlug = target . key ;
119151
120- const failedResources = new Set ( ) ;
121- page . on ( 'requestfailed' , ( request ) => {
122- const type = request . resourceType ( ) ;
123- if ( type === 'stylesheet' || type === 'script' ) {
124- failedResources . add ( request . url ( ) ) ;
125- console . warn ( `⚠️ Failed to load ${ type } : ${ request . url ( ) } ` ) ;
126- }
127- } ) ;
128-
129- try {
130- for ( let i = 0 ; i < viewports . length ; i ++ ) {
131- const viewport = viewports [ i ] ;
132- const slug = `${ target . key } -${ viewport . name } ` ;
133- currentSlug = slug ;
134-
135- console . log ( `Capturing ${ slug } ...` ) ;
136-
137- await page . setViewportSize ( {
138- width : viewport . width ,
139- height : viewport . height ,
140- } ) ;
141-
142- failedResources . clear ( ) ;
152+ for ( const group of groupViewportsByScaleFactor ( viewports ) ) {
153+ const context = await browser . newContext ( {
154+ ignoreHTTPSErrors,
155+ deviceScaleFactor : group . deviceScaleFactor ,
156+ } ) ;
157+ const page = await context . newPage ( ) ;
158+
159+ const failedResources = new Set ( ) ;
160+ page . on ( 'requestfailed' , ( request ) => {
161+ const type = request . resourceType ( ) ;
162+ if ( type === 'stylesheet' || type === 'script' ) {
163+ failedResources . add ( request . url ( ) ) ;
164+ console . warn ( `⚠️ Failed to load ${ type } : ${ request . url ( ) } ` ) ;
165+ }
166+ } ) ;
143167
144- let attempts = 0 ;
145- let success = false ;
168+ try {
169+ for ( let i = 0 ; i < group . viewports . length ; i ++ ) {
170+ const viewport = group . viewports [ i ] ;
171+ const slug = `${ target . key } -${ viewport . name } ` ;
172+ currentSlug = slug ;
146173
147- while ( attempts <= retryCount && ! success ) {
148- try {
149- if ( i === 0 || ! skipReload ) {
150- if ( attempts > 0 ) {
151- console . log ( ` Retry attempt ${ attempts } ...` ) ;
152- await page . waitForTimeout ( 1000 ) ;
153- }
174+ console . log ( `Capturing ${ slug } ...` ) ;
154175
155- const gotoOptions = {
156- waitUntil : 'networkidle' ,
157- timeout : timeouts . goto ,
158- } ;
176+ await page . setViewportSize ( {
177+ width : viewport . width ,
178+ height : viewport . height ,
179+ } ) ;
159180
160- if ( i === 0 || attempts > 0 ) {
161- await page . goto ( target . url , gotoOptions ) ;
162- } else {
163- await page . reload ( gotoOptions ) ;
181+ failedResources . clear ( ) ;
182+
183+ let attempts = 0 ;
184+ let success = false ;
185+
186+ while ( attempts <= retryCount && ! success ) {
187+ try {
188+ if ( i === 0 || ! skipReload ) {
189+ if ( attempts > 0 ) {
190+ console . log ( ` Retry attempt ${ attempts } ...` ) ;
191+ await page . waitForTimeout ( 1000 ) ;
192+ }
193+
194+ const gotoOptions = {
195+ waitUntil : 'networkidle' ,
196+ timeout : timeouts . goto ,
197+ } ;
198+
199+ if ( i === 0 || attempts > 0 ) {
200+ await page . goto ( target . url , gotoOptions ) ;
201+ } else {
202+ await page . reload ( gotoOptions ) ;
203+ }
204+
205+ if (
206+ failedResources . size > 0 &&
207+ attempts < retryCount
208+ ) {
209+ throw new Error (
210+ 'Critical resources failed to load'
211+ ) ;
212+ }
164213 }
165-
166- if ( failedResources . size > 0 && attempts < retryCount ) {
167- throw new Error (
168- 'Critical resources failed to load'
214+ success = true ;
215+ } catch ( error ) {
216+ attempts ++ ;
217+ if ( attempts > retryCount ) {
218+ // Retries exhausted. Screenshot best-effort below, but
219+ // record the slug as degraded rather than faking success.
220+ const reason = error . message || String ( error ) ;
221+ console . error (
222+ ` ⚠️ ${ slug } did not load cleanly after ${ retryCount + 1 } attempts: ${ reason } `
169223 ) ;
224+ failures . push ( { slug, url : target . url , reason } ) ;
225+ success = true ;
170226 }
171227 }
172- success = true ;
173- } catch ( error ) {
174- attempts ++ ;
175- if ( attempts > retryCount ) {
176- // Retries exhausted. Screenshot best-effort below, but
177- // record the slug as degraded rather than faking success.
178- const reason = error . message || String ( error ) ;
179- console . error (
180- ` ⚠️ ${ slug } did not load cleanly after ${ retryCount + 1 } attempts: ${ reason } `
181- ) ;
182- failures . push ( { slug, url : target . url , reason } ) ;
183- success = true ;
184- }
185228 }
186- }
187229
188- await autoScroll ( page ) ;
189- // A single event-driven settle after scrolling, bounded by the
190- // configurable timeout: near-instant on a page that is already
191- // quiet, and long enough for lazy assets on a slow one. (The goto
192- // above already waited for the initial network idle.)
193- await page
194- . waitForLoadState ( 'networkidle' , { timeout : timeouts . settle } )
195- . catch ( ( ) => {
196- // Slow/never-idle page; screenshot what we have.
197- } ) ;
198-
199- const imagePath = path . join ( dirs . captures , `${ slug } .png` ) ;
200- await page . screenshot ( { path : imagePath , fullPage : true } ) ;
201-
202- const htmlPath = path . join ( dirs . capturesHtml , `${ slug } .html` ) ;
203- fs . writeFileSync ( htmlPath , await page . content ( ) ) ;
204-
205- console . log ( `✓ Captured ${ slug } ` ) ;
230+ await autoScroll ( page ) ;
231+ // A single event-driven settle after scrolling, bounded by the
232+ // configurable timeout: near-instant on a page that is already
233+ // quiet, and long enough for lazy assets on a slow one. (The goto
234+ // above already waited for the initial network idle.)
235+ await page
236+ . waitForLoadState ( 'networkidle' , {
237+ timeout : timeouts . settle ,
238+ } )
239+ . catch ( ( ) => {
240+ // Slow/never-idle page; screenshot what we have.
241+ } ) ;
242+
243+ const imagePath = path . join ( dirs . captures , `${ slug } .png` ) ;
244+ await page . screenshot ( { path : imagePath , fullPage : true } ) ;
245+
246+ const htmlPath = path . join ( dirs . capturesHtml , `${ slug } .html` ) ;
247+ fs . writeFileSync ( htmlPath , await page . content ( ) ) ;
248+
249+ console . log ( `✓ Captured ${ slug } ` ) ;
250+ }
251+ } catch ( error ) {
252+ // A failure outside the retry loop (e.g. screenshot or HTML write).
253+ const reason = error . message || String ( error ) ;
254+ console . error (
255+ `Error capturing ${ currentSlug } (${ target . url } ): ${ reason } `
256+ ) ;
257+ failures . push ( { slug : currentSlug , url : target . url , reason } ) ;
258+ } finally {
259+ await context . close ( ) ;
206260 }
207- } catch ( error ) {
208- // A failure outside the retry loop (e.g. screenshot or HTML write).
209- const reason = error . message || String ( error ) ;
210- console . error (
211- `Error capturing ${ currentSlug } (${ target . url } ): ${ reason } `
212- ) ;
213- failures . push ( { slug : currentSlug , url : target . url , reason } ) ;
214- } finally {
215- await context . close ( ) ;
216261 }
217262
218263 return failures ;
0 commit comments