@@ -269,8 +269,155 @@ async function captureWithRetry(
269269}
270270
271271/**
272- * Capture all configured routes at all viewports.
273- * Supports authenticated captures, smart readiness, retry, and bot detection.
272+ * Resolve Playwright device descriptors from DevicePreset names.
273+ * Falls back to the raw viewport config if no preset matches.
274+ */
275+ function resolveViewports ( config : DojoWatchConfig ) : Viewport [ ] {
276+ const DEVICE_MAP : Record < string , Partial < Viewport > > = {
277+ "iPhone 14" : { name : "iPhone 14" , width : 390 , height : 844 , deviceScaleFactor : 3 , isMobile : true , userAgent : "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)" } ,
278+ "iPhone 14 Pro Max" : { name : "iPhone 14 Pro Max" , width : 430 , height : 932 , deviceScaleFactor : 3 , isMobile : true } ,
279+ "iPhone SE" : { name : "iPhone SE" , width : 375 , height : 667 , deviceScaleFactor : 2 , isMobile : true } ,
280+ "iPad" : { name : "iPad" , width : 768 , height : 1024 , deviceScaleFactor : 2 , isMobile : true } ,
281+ "iPad Pro" : { name : "iPad Pro" , width : 1024 , height : 1366 , deviceScaleFactor : 2 , isMobile : true } ,
282+ "Pixel 7" : { name : "Pixel 7" , width : 412 , height : 915 , deviceScaleFactor : 2.625 , isMobile : true } ,
283+ "Galaxy S23" : { name : "Galaxy S23" , width : 360 , height : 780 , deviceScaleFactor : 3 , isMobile : true } ,
284+ "Desktop Chrome" : { name : "Desktop Chrome" , width : 1280 , height : 720 } ,
285+ "Desktop Firefox" : { name : "Desktop Firefox" , width : 1280 , height : 720 } ,
286+ "Desktop Safari" : { name : "Desktop Safari" , width : 1280 , height : 720 } ,
287+ } ;
288+
289+ const viewports = [ ...config . viewports ] ;
290+
291+ if ( config . devices ) {
292+ for ( const preset of config . devices ) {
293+ const device = DEVICE_MAP [ preset ] ;
294+ if ( device ) {
295+ viewports . push ( {
296+ name : device . name ?? preset ,
297+ width : device . width ?? 1280 ,
298+ height : device . height ?? 720 ,
299+ deviceScaleFactor : device . deviceScaleFactor ,
300+ isMobile : device . isMobile ,
301+ userAgent : device . userAgent ,
302+ } ) ;
303+ }
304+ }
305+ }
306+
307+ return viewports ;
308+ }
309+
310+ /**
311+ * Build the list of capture jobs (route x viewport x scheme x locale).
312+ */
313+ interface CaptureJob {
314+ route : string ;
315+ viewport : Viewport ;
316+ colorScheme ?: "light" | "dark" ;
317+ locale ?: string ;
318+ profileName ?: string ;
319+ storageState ?: string ;
320+ }
321+
322+ function buildCaptureJobs ( config : DojoWatchConfig , routes : string [ ] ) : CaptureJob [ ] {
323+ const viewports = resolveViewports ( config ) ;
324+ const schemes = config . colorSchemes ?? [ undefined ] ;
325+ const locales = config . locales ?? [ undefined ] ;
326+ const jobs : CaptureJob [ ] = [ ] ;
327+
328+ for ( const route of routes ) {
329+ const { storageState, profileName } = resolveAuthForRoute ( route , config . auth ) ;
330+ for ( const viewport of viewports ) {
331+ for ( const scheme of schemes ) {
332+ for ( const locale of locales ) {
333+ jobs . push ( {
334+ route,
335+ viewport,
336+ colorScheme : scheme as "light" | "dark" | undefined ,
337+ locale : locale as string | undefined ,
338+ profileName,
339+ storageState,
340+ } ) ;
341+ }
342+ }
343+ }
344+ }
345+
346+ return jobs ;
347+ }
348+
349+ /**
350+ * Execute a single capture job with timeout protection.
351+ */
352+ async function executeCaptureJob (
353+ browser : Awaited < ReturnType < typeof chromium . launch > > ,
354+ config : DojoWatchConfig ,
355+ job : CaptureJob ,
356+ outputDir : string
357+ ) : Promise < CaptureResult > {
358+ const timeout = config . smart ?. routeTimeout ?? 30_000 ;
359+
360+ const context = await browser . newContext ( {
361+ ...( job . storageState ? { storageState : job . storageState } : { } ) ,
362+ ...( job . viewport . deviceScaleFactor ? { deviceScaleFactor : job . viewport . deviceScaleFactor } : { } ) ,
363+ ...( job . viewport . isMobile !== undefined ? { isMobile : job . viewport . isMobile } : { } ) ,
364+ ...( job . viewport . userAgent ? { userAgent : job . viewport . userAgent } : { } ) ,
365+ ...( job . colorScheme ? { colorScheme : job . colorScheme } : { } ) ,
366+ ...( job . locale ? { locale : job . locale } : { } ) ,
367+ } ) ;
368+
369+ try {
370+ const page = await context . newPage ( ) ;
371+
372+ const result = await Promise . race ( [
373+ captureWithRetry ( page , config , job . route , job . viewport , outputDir , job . profileName ) ,
374+ new Promise < never > ( ( _ , reject ) =>
375+ setTimeout ( ( ) => reject ( new Error ( `Capture timeout for ${ job . route } ` ) ) , timeout )
376+ ) ,
377+ ] ) ;
378+
379+ // Enrich the result with scheme/locale info
380+ result . colorScheme = job . colorScheme ;
381+ result . locale = job . locale ;
382+
383+ // Update the filename to include scheme/locale if present
384+ if ( job . colorScheme || job . locale ) {
385+ const suffix = [ job . colorScheme , job . locale ] . filter ( Boolean ) . join ( "-" ) ;
386+ const newName = `${ result . name } -${ suffix } ` ;
387+ const newPath = result . path . replace ( `${ result . name } -` , `${ newName } -` ) ;
388+ const { renameSync } = await import ( "node:fs" ) ;
389+ try { renameSync ( result . path , newPath ) ; } catch { /* keep original */ }
390+ result . name = newName ;
391+ result . path = newPath ;
392+ }
393+
394+ return result ;
395+ } catch ( err ) {
396+ // Return a result with a warning instead of crashing
397+ const baseName = routeToName ( job . route ) ;
398+ const name = job . profileName ? `${ baseName } -${ job . profileName } ` : baseName ;
399+ return {
400+ name,
401+ viewport : job . viewport . name ,
402+ profile : job . profileName ,
403+ colorScheme : job . colorScheme ,
404+ locale : job . locale ,
405+ path : "" ,
406+ hash : "" ,
407+ warnings : [ {
408+ type : "readiness_timeout" ,
409+ message : `Failed to capture ${ job . route } : ${ err instanceof Error ? err . message : String ( err ) } ` ,
410+ suggestion : "Check if the dev server is running and the route is accessible" ,
411+ } ] ,
412+ } ;
413+ } finally {
414+ await context . close ( ) ;
415+ }
416+ }
417+
418+ /**
419+ * Capture all configured routes at all viewports, color schemes, and locales.
420+ * Supports parallel capture, device emulation, and per-route timeout.
274421 */
275422export async function captureRoutes (
276423 config : DojoWatchConfig ,
@@ -280,50 +427,45 @@ export async function captureRoutes(
280427 mkdirSync ( outputDir , { recursive : true } ) ;
281428
282429 const browser = await chromium . launch ( { headless : true } ) ;
430+ const jobs = buildCaptureJobs ( config , routes ) ;
431+ const concurrency = config . smart ?. concurrency ?? 4 ;
283432 const results : CaptureResult [ ] = [ ] ;
284433
285- // Group routes by auth profile to minimize context creation
286- const routesByAuth = new Map < string , { storageState ?: string ; profileName ?: string ; routes : string [ ] } > ( ) ;
287- for ( const route of routes ) {
288- const { storageState, profileName } = resolveAuthForRoute ( route , config . auth ) ;
289- const key = storageState ?? "__anonymous__" ;
290- if ( ! routesByAuth . has ( key ) ) {
291- routesByAuth . set ( key , { storageState, profileName, routes : [ ] } ) ;
292- }
293- routesByAuth . get ( key ) ! . routes . push ( route ) ;
294- }
434+ console . log ( pc . dim ( ` ${ jobs . length } capture job(s), concurrency: ${ concurrency } ` ) ) ;
295435
296436 try {
297- for ( const [ , group ] of routesByAuth ) {
298- const context = await browser . newContext (
299- group . storageState ? { storageState : group . storageState } : undefined
300- ) ;
301- const page = await context . newPage ( ) ;
302-
303- if ( group . profileName ) {
304- console . log ( pc . dim ( ` Auth profile: ${ group . profileName } ` ) ) ;
305- }
306-
307- for ( const route of group . routes ) {
308- for ( const viewport of config . viewports ) {
437+ // Process jobs in batches for parallel capture
438+ for ( let i = 0 ; i < jobs . length ; i += concurrency ) {
439+ const batch = jobs . slice ( i , i + concurrency ) ;
440+ const batchResults = await Promise . all (
441+ batch . map ( ( job ) => {
442+ const schemeSuffix = job . colorScheme ? ` [${ job . colorScheme } ]` : "" ;
443+ const localeSuffix = job . locale ? ` (${ job . locale } )` : "" ;
309444 console . log (
310- pc . dim ( ` Capturing ${ route } @ ${ viewport . name } ( ${ viewport . width } x ${ viewport . height } ) ` )
445+ pc . dim ( ` Capturing ${ job . route } @ ${ job . viewport . name } ${ schemeSuffix } ${ localeSuffix } ` )
311446 ) ;
312- const result = await captureWithRetry (
313- page , config , route , viewport , outputDir , group . profileName
314- ) ;
315- results . push ( result ) ;
316- }
317- }
318-
319- await context . close ( ) ;
447+ return executeCaptureJob ( browser , config , job , outputDir ) ;
448+ } )
449+ ) ;
450+ results . push ( ...batchResults ) ;
320451 }
321452 } finally {
322453 await browser . close ( ) ;
323454 }
324455
325- // Report warnings
326- const allWarnings = results . flatMap ( ( r ) => r . warnings ) ;
456+ // Filter out failed captures (empty path)
457+ const successful = results . filter ( ( r ) => r . path !== "" ) ;
458+ const failed = results . filter ( ( r ) => r . path === "" ) ;
459+
460+ if ( failed . length > 0 ) {
461+ console . log ( pc . yellow ( `\n ${ failed . length } capture(s) failed:` ) ) ;
462+ for ( const f of failed ) {
463+ console . log ( pc . yellow ( ` ${ f . name } : ${ f . warnings [ 0 ] ?. message } ` ) ) ;
464+ }
465+ }
466+
467+ // Report warnings from successful captures
468+ const allWarnings = successful . flatMap ( ( r ) => r . warnings ) ;
327469 if ( allWarnings . length > 0 ) {
328470 console . log ( pc . yellow ( `\n ${ allWarnings . length } warning(s):` ) ) ;
329471 for ( const w of allWarnings ) {
0 commit comments