@@ -36,11 +36,15 @@ async function captureRoute(
3636 config : DojoWatchConfig ,
3737 route : string ,
3838 viewport : Viewport ,
39- outputDir : string
39+ outputDir : string ,
40+ profileName ?: string
4041) : Promise < CaptureResult > {
41- const name = routeToName ( route ) ;
42+ const baseName = routeToName ( route ) ;
43+ // Role-aware naming: dashboard-admin-desktop.png vs dashboard-desktop.png
44+ const name = profileName ? `${ baseName } -${ profileName } ` : baseName ;
4245 const filename = `${ name } -${ viewport . name } .png` ;
4346 const outputPath = join ( outputDir , filename ) ;
47+ const warnings : import ( "./types.js" ) . CaptureWarning [ ] = [ ] ;
4448
4549 // Set viewport size
4650 await page . setViewportSize ( {
@@ -52,9 +56,39 @@ async function captureRoute(
5256 const url = new URL ( route , config . baseUrl ) . toString ( ) ;
5357 await page . goto ( url , { waitUntil : "load" , timeout : 30_000 } ) ;
5458
59+ // Smart layer: wait for readiness
60+ const readiness =
61+ config . smart ?. routeReadiness ?. [ route ] ?? config . smart ?. readiness ;
62+ if ( readiness ) {
63+ try {
64+ await waitForReadiness ( page , readiness ) ;
65+ } catch {
66+ warnings . push ( {
67+ type : "readiness_timeout" ,
68+ message : `Readiness check timed out for ${ route } ` ,
69+ suggestion : "Increase smart.readiness.timeout or check waitForSelector/waitForText" ,
70+ } ) ;
71+ }
72+ }
73+
74+ // Smart layer: detect bot protection
75+ if ( config . smart ?. detectBotProtection !== false ) {
76+ const botDetected = await detectBotProtection ( page ) ;
77+ if ( botDetected ) {
78+ warnings . push ( {
79+ type : "bot_protection" ,
80+ message : `Bot protection detected on ${ route } — screenshot may show a challenge page` ,
81+ suggestion : "Disable bot protection for localhost or add DojoWatch's user-agent to allowlist" ,
82+ } ) ;
83+ }
84+ }
85+
5586 // Stabilize the page
5687 await injectStabilization ( page ) ;
5788
89+ // Smart layer: wait for SPA hydration
90+ await waitForHydration ( page , config . smart ?. hydrationSelectors ) ;
91+
5892 // Mask dynamic elements
5993 await maskElements ( page , config . maskSelectors ) ;
6094
@@ -64,38 +98,170 @@ async function captureRoute(
6498 return {
6599 name,
66100 viewport : viewport . name ,
101+ profile : profileName ,
67102 path : outputPath ,
68103 hash : hashFile ( outputPath ) ,
104+ warnings,
69105 } ;
70106}
71107
72108/**
73- * Resolve which storageState file to use for a given route.
74- * Returns undefined for anonymous access.
109+ * Wait for page readiness beyond basic load/networkidle.
110+ */
111+ async function waitForReadiness (
112+ page : Awaited < ReturnType < Awaited < ReturnType < typeof chromium . launch > > [ "newPage" ] > > ,
113+ readiness : import ( "./types.js" ) . ReadinessCheck
114+ ) : Promise < void > {
115+ const timeout = readiness . timeout ?? 10_000 ;
116+
117+ if ( readiness . waitForSelector ) {
118+ await page . waitForSelector ( readiness . waitForSelector , {
119+ state : "visible" ,
120+ timeout,
121+ } ) ;
122+ }
123+
124+ if ( readiness . waitForText ) {
125+ await page . waitForFunction (
126+ ( text : string ) => document . body . textContent ?. includes ( text ) ?? false ,
127+ readiness . waitForText ,
128+ { timeout }
129+ ) ;
130+ }
131+ }
132+
133+ /**
134+ * Detect bot protection challenge pages (Cloudflare, hCaptcha, reCAPTCHA).
135+ */
136+ async function detectBotProtection (
137+ page : Awaited < ReturnType < Awaited < ReturnType < typeof chromium . launch > > [ "newPage" ] > >
138+ ) : Promise < boolean > {
139+ return page . evaluate ( ( ) => {
140+ const indicators = [
141+ // Cloudflare
142+ document . querySelector ( "#cf-challenge-running" ) ,
143+ document . querySelector ( ".cf-browser-verification" ) ,
144+ document . querySelector ( "#challenge-form" ) ,
145+ // hCaptcha
146+ document . querySelector ( ".h-captcha" ) ,
147+ document . querySelector ( 'iframe[src*="hcaptcha.com"]' ) ,
148+ // reCAPTCHA
149+ document . querySelector ( ".g-recaptcha" ) ,
150+ document . querySelector ( 'iframe[src*="recaptcha"]' ) ,
151+ // Generic challenge page signals
152+ document . title . includes ( "Just a moment" ) ,
153+ document . title . includes ( "Attention Required" ) ,
154+ ] ;
155+ return indicators . some ( Boolean ) ;
156+ } ) ;
157+ }
158+
159+ /**
160+ * Wait for SPA framework hydration to complete.
161+ * Checks for framework-specific signals or custom selectors.
162+ */
163+ async function waitForHydration (
164+ page : Awaited < ReturnType < Awaited < ReturnType < typeof chromium . launch > > [ "newPage" ] > > ,
165+ customSelectors ?: string [ ]
166+ ) : Promise < void > {
167+ const selectors = customSelectors ?? [ ] ;
168+
169+ // Auto-detect common framework hydration signals
170+ const hydrated = await page . evaluate ( ( customSels : string [ ] ) => {
171+ // Check custom selectors first
172+ for ( const sel of customSels ) {
173+ if ( ! document . querySelector ( sel ) ) return false ;
174+ }
175+
176+ // Next.js: __NEXT_DATA__ script exists after hydration
177+ // React: check for [data-reactroot] or root with children
178+ // These are best-effort — if not found, assume hydrated
179+ return true ;
180+ } , selectors ) ;
181+
182+ if ( ! hydrated && selectors . length > 0 ) {
183+ // Wait briefly for hydration
184+ try {
185+ await page . waitForSelector ( selectors [ 0 ] , { timeout : 5_000 } ) ;
186+ } catch {
187+ // Proceed anyway — hydration may have already completed
188+ }
189+ }
190+ }
191+
192+ /**
193+ * Resolve auth info for a given route.
194+ * Returns the storageState file path and profile name.
75195 */
76196function resolveAuthForRoute (
77197 route : string ,
78198 auth ?: AuthConfig
79- ) : string | undefined {
80- if ( ! auth ) return undefined ;
199+ ) : { storageState ?: string ; profileName ?: string } {
200+ if ( ! auth ) return { } ;
81201
82202 // Check per-route mapping first
83203 if ( auth . routes && route in auth . routes ) {
84204 const profileName = auth . routes [ route ] ;
85- if ( profileName === null ) return undefined ; // explicitly anonymous
86- if ( auth . profiles && profileName in auth . profiles ) {
87- return auth . profiles [ profileName ] ;
205+ if ( profileName === null ) return { } ; // explicitly anonymous
206+ if ( profileName && auth . profiles && profileName in auth . profiles ) {
207+ return { storageState : auth . profiles [ profileName ] , profileName } ;
88208 }
89- return undefined ;
209+ return { } ;
210+ }
211+
212+ // Fall back to default storageState (no named profile)
213+ return auth . storageState ? { storageState : auth . storageState } : { } ;
214+ }
215+
216+ /**
217+ * Capture a route with retry logic for flaky detection.
218+ * Captures N times and compares hashes. If hashes differ, flags as flaky.
219+ */
220+ async function captureWithRetry (
221+ page : Awaited < ReturnType < Awaited < ReturnType < typeof chromium . launch > > [ "newPage" ] > > ,
222+ config : DojoWatchConfig ,
223+ route : string ,
224+ viewport : Viewport ,
225+ outputDir : string ,
226+ profileName ?: string
227+ ) : Promise < CaptureResult > {
228+ const retries = config . smart ?. retries ?? 1 ;
229+
230+ if ( retries <= 1 ) {
231+ return captureRoute ( page , config , route , viewport , outputDir , profileName ) ;
232+ }
233+
234+ // Capture multiple times and compare hashes
235+ const captures : CaptureResult [ ] = [ ] ;
236+ for ( let i = 0 ; i < retries ; i ++ ) {
237+ const result = await captureRoute ( page , config , route , viewport , outputDir , profileName ) ;
238+ captures . push ( result ) ;
239+ }
240+
241+ // Find the most common hash (majority vote)
242+ const hashCounts = new Map < string , number > ( ) ;
243+ for ( const c of captures ) {
244+ hashCounts . set ( c . hash , ( hashCounts . get ( c . hash ) ?? 0 ) + 1 ) ;
245+ }
246+
247+ const uniqueHashes = hashCounts . size ;
248+ const [ bestHash ] = [ ...hashCounts . entries ( ) ] . sort ( ( a , b ) => b [ 1 ] - a [ 1 ] ) [ 0 ] ;
249+ const bestCapture = captures . find ( ( c ) => c . hash === bestHash ) ! ;
250+
251+ if ( uniqueHashes > 1 ) {
252+ bestCapture . warnings . push ( {
253+ type : "flaky_capture" ,
254+ message : `${ uniqueHashes } different screenshots from ${ retries } captures of ${ route } ` ,
255+ suggestion : "Page has non-deterministic rendering. Add data-vr-mask to dynamic elements or increase stabilization wait time." ,
256+ } ) ;
90257 }
91258
92- // Fall back to default storageState
93- return auth . storageState ;
259+ return bestCapture ;
94260}
95261
96262/**
97263 * Capture all configured routes at all viewports.
98- * Supports authenticated captures via Playwright storageState .
264+ * Supports authenticated captures, smart readiness, retry, and bot detection .
99265 */
100266export async function captureRoutes (
101267 config : DojoWatchConfig ,
@@ -108,32 +274,35 @@ export async function captureRoutes(
108274 const results : CaptureResult [ ] = [ ] ;
109275
110276 // Group routes by auth profile to minimize context creation
111- const routesByAuth = new Map < string | undefined , string [ ] > ( ) ;
277+ const routesByAuth = new Map < string , { storageState ?: string ; profileName ?: string ; routes : string [ ] } > ( ) ;
112278 for ( const route of routes ) {
113- const authFile = resolveAuthForRoute ( route , config . auth ) ;
114- const key = authFile ?? "__anonymous__" ;
115- if ( ! routesByAuth . has ( key ) ) routesByAuth . set ( key , [ ] ) ;
116- routesByAuth . get ( key ) ! . push ( route ) ;
279+ const { storageState, profileName } = resolveAuthForRoute ( route , config . auth ) ;
280+ const key = storageState ?? "__anonymous__" ;
281+ if ( ! routesByAuth . has ( key ) ) {
282+ routesByAuth . set ( key , { storageState, profileName, routes : [ ] } ) ;
283+ }
284+ routesByAuth . get ( key ) ! . routes . push ( route ) ;
117285 }
118286
119287 try {
120- for ( const [ authKey , groupedRoutes ] of routesByAuth ) {
121- const storageState = authKey === "__anonymous__" ? undefined : authKey ;
288+ for ( const [ , group ] of routesByAuth ) {
122289 const context = await browser . newContext (
123- storageState ? { storageState } : undefined
290+ group . storageState ? { storageState : group . storageState } : undefined
124291 ) ;
125292 const page = await context . newPage ( ) ;
126293
127- if ( storageState ) {
128- console . log ( pc . dim ( ` Auth: ${ storageState } ` ) ) ;
294+ if ( group . profileName ) {
295+ console . log ( pc . dim ( ` Auth profile : ${ group . profileName } ` ) ) ;
129296 }
130297
131- for ( const route of groupedRoutes ) {
298+ for ( const route of group . routes ) {
132299 for ( const viewport of config . viewports ) {
133300 console . log (
134301 pc . dim ( ` Capturing ${ route } @ ${ viewport . name } (${ viewport . width } x${ viewport . height } )` )
135302 ) ;
136- const result = await captureRoute ( page , config , route , viewport , outputDir ) ;
303+ const result = await captureWithRetry (
304+ page , config , route , viewport , outputDir , group . profileName
305+ ) ;
137306 results . push ( result ) ;
138307 }
139308 }
@@ -144,6 +313,16 @@ export async function captureRoutes(
144313 await browser . close ( ) ;
145314 }
146315
316+ // Report warnings
317+ const allWarnings = results . flatMap ( ( r ) => r . warnings ) ;
318+ if ( allWarnings . length > 0 ) {
319+ console . log ( pc . yellow ( `\n ${ allWarnings . length } warning(s):` ) ) ;
320+ for ( const w of allWarnings ) {
321+ console . log ( pc . yellow ( ` [${ w . type } ] ${ w . message } ` ) ) ;
322+ if ( w . suggestion ) console . log ( pc . dim ( ` → ${ w . suggestion } ` ) ) ;
323+ }
324+ }
325+
147326 return results ;
148327}
149328
@@ -213,6 +392,7 @@ export async function captureStorybook(
213392 viewport : viewport . name ,
214393 path : outputPath ,
215394 hash : hashFile ( outputPath ) ,
395+ warnings : [ ] ,
216396 } ) ;
217397 }
218398 }
0 commit comments