@@ -107,6 +107,8 @@ export const routes: {
107107 finalPath : string
108108 screenshot : string | null
109109 platformSurfaces : number
110+ /** From the document's load to no read or skeleton left, or null if it never got there. */
111+ contentReadyMs : number | null
110112} [ ] = [ ]
111113export const firstPaints : FirstPaint [ ] = [ ]
112114const unexpected : Problem [ ] = [ ]
@@ -131,6 +133,11 @@ const ENVIRONMENT_CONSOLE_NOISE = [
131133 / ^ T h e C r o s s - O r i g i n - O p e n e r - P o l i c y h e a d e r h a s b e e n i g n o r e d , b e c a u s e t h e U R L ' s o r i g i n w a s u n t r u s t w o r t h y \. / ,
132134 / ^ A c c e s s t o f e t c h a t ' h t t p s : \/ \/ s i m - s i m \. i n t h \. a p p \/ i n i t ' f r o m o r i g i n / ,
133135]
136+ /** How long a route may keep showing a first read before the walk fails it as stuck. */
137+ const CONTENT_DEADLINE_MS = 20_000
138+ /** The gap between the two looks that must agree: the next read starts after a chunk or a mount. */
139+ const CONTENT_RECHECK_MS = 300
140+ const LOADING_STATUS = / ^ ( L o a d i n g | C h e c k i n g | S e a r c h i n g | U p d a t i n g ) \b /
134141const HIGH_VOLUME_REQUEST = / \/ a p i \/ m o t h e r s h i p \/ e v e n t s | \/ s o c k e t \. i o \/ | \/ a p i \/ a u t h \/ s o c k e t - t o k e n /
135142
136143const browser : Browser = await chromium . launch ( )
@@ -142,6 +149,24 @@ interface CheckScope {
142149}
143150const checkScope = new AsyncLocalStorage < CheckScope > ( )
144151
152+ /**
153+ * The loading states on screen, named. Runs in the page. Skeletons announce themselves as a
154+ * `role=status` saying what they load or an `aria-busy` region, which is also what a screen reader
155+ * gets, so no check depends on a skeleton's class.
156+ */
157+ function loadingStatesOnScreen ( pattern : string ) {
158+ const loading = new RegExp ( pattern )
159+ const named = ( element : Element ) =>
160+ element . getAttribute ( 'aria-label' ) || element . textContent ?. trim ( ) || element . tagName
161+ const busy = Array . from ( document . querySelectorAll ( '[aria-busy="true"]' ) )
162+ const statuses = Array . from ( document . querySelectorAll ( '[role="status"]' ) ) . filter ( ( element ) =>
163+ loading . test ( named ( element ) )
164+ )
165+ return [ ...new Set ( [ ...busy , ...statuses ] ) ]
166+ . filter ( ( element ) => element . checkVisibility ( { visibilityProperty : true } ) )
167+ . map ( named )
168+ }
169+
145170function reportCounts ( ) {
146171 const counts = { passed : 0 , failed : 0 , skipped : 0 }
147172 for ( const entry of checks ) counts [ entry . status ] += 1
@@ -318,11 +343,38 @@ class Session {
318343 if ( this . inflight . size === 0 && Date . now ( ) - this . lastActivity > 400 ) return
319344 await sleep ( 100 )
320345 }
321- const out = [ ...this . inflight ] . map ( ( request ) => `${ request . method ( ) } ${ request . url ( ) } ` )
322346 throw new Error (
323- `The page did not go quiet in ${ maxMs } ms. Still out: ${ out . join ( ', ' ) || 'none' } `
347+ `The page did not go quiet in ${ maxMs } ms. Still out: ${ this . readsOut ( ) || 'none' } `
348+ )
349+ }
350+
351+ /**
352+ * Quiet, and past every first read: a quiet network alone passes before a lazy section has loaded
353+ * enough to send its read, with its skeleton on screen. A state a check holds on purpose is that
354+ * check's to assert; this is for a page about to be read as finished. Returns how long it took.
355+ */
356+ async contentReady ( maxMs = CONTENT_DEADLINE_MS ) {
357+ const started = Date . now ( )
358+ const loading = ( ) => this . page . evaluate ( loadingStatesOnScreen , LOADING_STATUS . source )
359+ let showing : string [ ] = [ ]
360+ while ( Date . now ( ) - started < maxMs ) {
361+ await this . settle ( maxMs - ( Date . now ( ) - started ) ) . catch ( ( ) => { } )
362+ showing = await loading ( )
363+ if ( showing . length === 0 ) {
364+ await sleep ( CONTENT_RECHECK_MS )
365+ showing = await loading ( )
366+ if ( showing . length === 0 && this . inflight . size === 0 ) return Date . now ( ) - started
367+ }
368+ await sleep ( 100 )
369+ }
370+ throw new Error (
371+ `Still loading after ${ maxMs } ms: ${ showing . join ( ', ' ) || 'no skeleton' } . Reads still out: ${ this . readsOut ( ) || 'none' } `
324372 )
325373 }
374+
375+ private readsOut ( ) {
376+ return [ ...this . inflight ] . map ( ( request ) => `${ request . method ( ) } ${ request . url ( ) } ` ) . join ( ', ' )
377+ }
326378}
327379
328380async function cookieFor ( persona : Persona ) {
0 commit comments