@@ -24,10 +24,92 @@ export function assertNoPageErrors(errors: string[]) {
2424}
2525
2626/** No horizontal body overflow — the signature of the mobile-reflow regressions
27- * (#288 banner, #576 drawer, edit-cover at 375px). */
27+ * (#288 banner, #576 drawer, edit-cover at 375px).
28+ *
29+ * On failure this names the elements that stick out past the viewport. Without
30+ * that, the failure reads "expected <= 1, received 35" and says nothing about
31+ * WHICH element is 35px wide of the edge — which is exactly why CI's detail-page
32+ * overflow sat behind a "pre-existing" label across several releases while nobody
33+ * could act on it (`notes/e2e-mobile-overflow-ci-triage.md`). The dump only runs
34+ * when the assertion is about to fail, so it costs a passing run nothing. */
2835export async function assertNoHorizontalOverflow ( page : Page ) {
2936 const overflow = await pageOverflow ( page ) ;
30- expect ( overflow , 'page scrolls horizontally (mobile reflow regression)' ) . toBeLessThanOrEqual ( 1 ) ;
37+ const detail = overflow > 1 ? `\n${ await describeOverflowingElements ( page ) } ` : '' ;
38+ expect (
39+ overflow ,
40+ `page scrolls horizontally (mobile reflow regression)${ detail } ` ,
41+ ) . toBeLessThanOrEqual ( 1 ) ;
42+ }
43+
44+ /** The elements extending past the viewport's right edge, deepest first.
45+ *
46+ * Deepest-first matters: an overflowing leaf drags every ancestor out with it, so
47+ * the shallow entries are consequences and the deep ones are the cause. Reports
48+ * the computed properties that actually produce this class of bug (`white-space`,
49+ * `max-width`, `overflow-wrap`, `min-width`) so the fix is usually readable
50+ * straight off the failure message.
51+ *
52+ * Elements clipped by a scrollable ancestor are EXCLUDED. A carousel or shelf row
53+ * with `overflow-x: auto` legitimately holds children past the viewport edge, and
54+ * their excess never reaches `documentElement.scrollWidth` — the catalog grid
55+ * reports dozens of them while the page overflow is 0. Listing those would point
56+ * the reader at innocent elements, which is worse than printing nothing. */
57+ export async function describeOverflowingElements ( page : Page , limit = 8 ) : Promise < string > {
58+ const rows = await page . evaluate ( ( max ) => {
59+ const viewport = document . documentElement . clientWidth ;
60+ const out : Array < Record < string , string | number > > = [ ] ;
61+ /** True when some ancestor clips/scrolls horizontally, absorbing the excess. */
62+ const isAbsorbed = ( el : HTMLElement ) => {
63+ for ( let p = el . parentElement ; p && p !== document . documentElement ; p = p . parentElement ) {
64+ const ox = getComputedStyle ( p ) . overflowX ;
65+ if ( ox === 'auto' || ox === 'scroll' || ox === 'hidden' || ox === 'clip' ) return true ;
66+ }
67+ return false ;
68+ } ;
69+ document . querySelectorAll < HTMLElement > ( '*' ) . forEach ( ( el ) => {
70+ const r = el . getBoundingClientRect ( ) ;
71+ if ( r . width === 0 && r . height === 0 ) return ;
72+ if ( r . right <= viewport + 1 ) return ;
73+ if ( isAbsorbed ( el ) ) return ;
74+ const cs = getComputedStyle ( el ) ;
75+ let depth = 0 ;
76+ for ( let p : HTMLElement | null = el ; p ?. parentElement ; p = p . parentElement ) depth += 1 ;
77+ out . push ( {
78+ depth,
79+ tag : el . tagName . toLowerCase ( ) ,
80+ cls : String ( ( el as unknown as { className ?: string } ) . className ?? '' ) . slice ( 0 , 60 ) ,
81+ testid : el . getAttribute ( 'data-testid' ) ?? '' ,
82+ text : ( el . textContent ?? '' ) . trim ( ) . replace ( / \s + / g, ' ' ) . slice ( 0 , 40 ) ,
83+ past : Math . round ( r . right - viewport ) ,
84+ width : Math . round ( r . width ) ,
85+ whiteSpace : cs . whiteSpace ,
86+ maxWidth : cs . maxWidth ,
87+ overflowWrap : cs . overflowWrap ,
88+ minWidth : cs . minWidth ,
89+ } ) ;
90+ } ) ;
91+ out . sort ( ( a , b ) => ( b . depth as number ) - ( a . depth as number ) ) ;
92+ return out . slice ( 0 , max ) ;
93+ } , limit ) ;
94+
95+ if ( rows . length === 0 ) {
96+ // A border box can sit inside the viewport and still push the document wider:
97+ // scrollable overflow counts a child's MARGIN box, and a pseudo-element has no
98+ // node for the sweep above to find. CI's long-standing 35px detail-page
99+ // overflow lands here — every element is within the edge — so an empty list
100+ // would be the least useful thing to print. Fall back to the two measurements
101+ // that do explain this case.
102+ return describeIndirectOverflow ( page , limit ) ;
103+ }
104+ return rows
105+ . map (
106+ ( r ) =>
107+ ` ${ r . past } px past @ depth ${ r . depth } : <${ r . tag } ${ r . testid ? ` data-testid="${ r . testid } "` : '' } class="${ r . cls } ">` +
108+ ` w=${ r . width } white-space=${ r . whiteSpace } max-width=${ r . maxWidth } ` +
109+ ` overflow-wrap=${ r . overflowWrap } min-width=${ r . minWidth } ` +
110+ ( r . text ? `\n text: ${ JSON . stringify ( r . text ) } ` : '' ) ,
111+ )
112+ . join ( '\n' ) ;
31113}
32114
33115/** Horizontal overflow in px, for specs that need to compare one render against
@@ -40,3 +122,94 @@ export async function pageOverflow(page: Page): Promise<number> {
40122 return el . scrollWidth - el . clientWidth ;
41123 } ) ;
42124}
125+
126+ /** Explains overflow that no element's border box accounts for.
127+ *
128+ * Two causes produce a wider document while every rect stays inside the viewport:
129+ *
130+ * 1. A **margin**. Scrollable overflow includes a child's margin box, so an
131+ * element ending at 388px with `margin-right: 37px` widens the document to
132+ * 425px while its own `right` is comfortably within the edge.
133+ * 2. Content wider than its own box — a **pseudo-element**, a transform, or a
134+ * min-width floor — on an element that is not a scroll container. Such an
135+ * element has `scrollWidth > clientWidth` while its rect stays put, and it
136+ * hands the excess up to its parent.
137+ *
138+ * Reporting the containers in (2) deepest-first localises the cause to one box
139+ * even when the thing inside it has no node of its own. */
140+ async function describeIndirectOverflow ( page : Page , limit : number ) : Promise < string > {
141+ const found = await page . evaluate ( ( max ) => {
142+ const viewport = document . documentElement . clientWidth ;
143+ const depthOf = ( el : HTMLElement ) => {
144+ let d = 0 ;
145+ for ( let p : HTMLElement | null = el ; p ?. parentElement ; p = p . parentElement ) d += 1 ;
146+ return d ;
147+ } ;
148+ const scrolls = ( el : HTMLElement ) => {
149+ const ox = getComputedStyle ( el ) . overflowX ;
150+ return ox === 'auto' || ox === 'scroll' || ox === 'hidden' || ox === 'clip' ;
151+ } ;
152+ const absorbed = ( el : HTMLElement ) => {
153+ for ( let p = el . parentElement ; p && p !== document . documentElement ; p = p . parentElement ) {
154+ if ( scrolls ( p ) ) return true ;
155+ }
156+ return false ;
157+ } ;
158+
159+ const margins : string [ ] = [ ] ;
160+ const inner : string [ ] = [ ] ;
161+
162+ document . querySelectorAll < HTMLElement > ( '*' ) . forEach ( ( el ) => {
163+ const r = el . getBoundingClientRect ( ) ;
164+ if ( r . width === 0 && r . height === 0 ) return ;
165+ if ( absorbed ( el ) ) return ;
166+ const cs = getComputedStyle ( el ) ;
167+ const label =
168+ `<${ el . tagName . toLowerCase ( ) } ` +
169+ `${ el . getAttribute ( 'data-testid' ) ? ` data-testid="${ el . getAttribute ( 'data-testid' ) } "` : '' } ` +
170+ ` class="${ String ( ( el as unknown as { className ?: string } ) . className ?? '' ) . slice ( 0 , 60 ) } ">` ;
171+
172+ const marginRight = parseFloat ( cs . marginRight ) || 0 ;
173+ if ( marginRight > 0 && r . right + marginRight > viewport + 1 ) {
174+ margins . push (
175+ ` ${ Math . round ( r . right + marginRight - viewport ) } px past (margin box) @ depth ${ depthOf ( el ) } : ${ label } ` +
176+ ` right=${ Math . round ( r . right ) } margin-right=${ cs . marginRight } ` ,
177+ ) ;
178+ }
179+
180+ const excess = el . scrollWidth - el . clientWidth ;
181+ if ( excess > 1 && ! scrolls ( el ) ) {
182+ const before = getComputedStyle ( el , '::before' ) . content ;
183+ const after = getComputedStyle ( el , '::after' ) . content ;
184+ const pseudo = [
185+ before && before !== 'none' ? '::before' : '' ,
186+ after && after !== 'none' ? '::after' : '' ,
187+ ]
188+ . filter ( Boolean )
189+ . join ( '+' ) ;
190+ inner . push (
191+ ` holds ${ excess } px more content than its box @ depth ${ depthOf ( el ) } : ${ label } ` +
192+ ` client=${ el . clientWidth } scroll=${ el . scrollWidth } min-width=${ cs . minWidth } ` +
193+ ` transform=${ cs . transform === 'none' ? 'none' : 'yes' } ${ pseudo ? ` pseudo=${ pseudo } ` : '' } ` ,
194+ ) ;
195+ }
196+ } ) ;
197+
198+ const byDepthDesc = ( a : string , b : string ) =>
199+ Number ( / @ d e p t h ( \d + ) / . exec ( b ) ?. [ 1 ] ?? 0 ) - Number ( / @ d e p t h ( \d + ) / . exec ( a ) ?. [ 1 ] ?? 0 ) ;
200+ return {
201+ margins : margins . sort ( byDepthDesc ) . slice ( 0 , max ) ,
202+ inner : inner . sort ( byDepthDesc ) . slice ( 0 , max ) ,
203+ } ;
204+ } , limit ) ;
205+
206+ const parts : string [ ] = [
207+ ' (no element’s border box crosses the viewport edge — the width comes from a margin, a pseudo-element, a transform, or a min-width floor)' ,
208+ ] ;
209+ if ( found . margins . length ) parts . push ( ' margin boxes past the edge:' , ...found . margins ) ;
210+ if ( found . inner . length ) parts . push ( ' boxes whose content is wider than they are:' , ...found . inner ) ;
211+ if ( ! found . margins . length && ! found . inner . length ) {
212+ parts . push ( ' nothing matched either probe — inspect documentElement/body padding directly' ) ;
213+ }
214+ return parts . join ( '\n' ) ;
215+ }
0 commit comments