test: update e2e test suite and add audit tests
Refine auth, player, tracks, playlists, search, workflows, edge cases,
forms, responsive, network errors, error boundary, performance, visual
regression, cross-browser, profile, smoke, storybook, chat, and session
tests. Add audit test suite (accessibility, ethical, functional, design
tokens). Update test helpers and visual snapshots.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:06:26 +00:00
import type { Page , Locator } from '@playwright/test' ;
// =============================================================================
// MESURE DE POSITION ET TAILLE
// =============================================================================
export interface ElementMetrics {
tag : string ;
selector : string ;
text : string ;
x : number ;
y : number ;
width : number ;
height : number ;
zIndex : number ;
fontSize : string ;
fontFamily : string ;
fontWeight : string ;
lineHeight : string ;
color : string ;
backgroundColor : string ;
borderRadius : string ;
boxShadow : string ;
padding : { top : number ; right : number ; bottom : number ; left : number } ;
margin : { top : number ; right : number ; bottom : number ; left : number } ;
opacity : string ;
cursor : string ;
overflow : string ;
position : string ;
}
/ * *
* Récupère TOUTES les métriques CSS d ' un é lément
* /
export async function getElementMetrics ( page : Page , selector : string ) : Promise < ElementMetrics > {
return page . evaluate ( ( sel ) = > {
const el = document . querySelector ( sel ) ;
if ( ! el ) throw new Error ( ` Element not found: ${ sel } ` ) ;
const style = getComputedStyle ( el ) ;
const rect = el . getBoundingClientRect ( ) ;
return {
tag : el.tagName.toLowerCase ( ) ,
selector : sel ,
text : el.textContent?.trim ( ) . slice ( 0 , 50 ) || '' ,
x : Math.round ( rect . x ) ,
y : Math.round ( rect . y ) ,
width : Math.round ( rect . width ) ,
height : Math.round ( rect . height ) ,
zIndex : parseInt ( style . zIndex ) || 0 ,
fontSize : style.fontSize ,
fontFamily : style.fontFamily ,
fontWeight : style.fontWeight ,
lineHeight : style.lineHeight ,
color : style.color ,
backgroundColor : style.backgroundColor ,
borderRadius : style.borderRadius ,
boxShadow : style.boxShadow ,
padding : {
top : parseFloat ( style . paddingTop ) ,
right : parseFloat ( style . paddingRight ) ,
bottom : parseFloat ( style . paddingBottom ) ,
left : parseFloat ( style . paddingLeft ) ,
} ,
margin : {
top : parseFloat ( style . marginTop ) ,
right : parseFloat ( style . marginRight ) ,
bottom : parseFloat ( style . marginBottom ) ,
left : parseFloat ( style . marginLeft ) ,
} ,
opacity : style.opacity ,
cursor : style.cursor ,
overflow : style.overflow ,
position : style.position ,
} ;
} , selector ) ;
}
// =============================================================================
// DÉTECTION DE CHEVAUCHEMENTS
// =============================================================================
export interface OverlapReport {
elementA : { selector : string ; text : string ; rect : { x : number ; y : number ; width : number ; height : number } } ;
elementB : { selector : string ; text : string ; rect : { x : number ; y : number ; width : number ; height : number } } ;
overlapX : number ;
overlapY : number ;
severity : 'critical' | 'warning' | 'info' ;
fix : string ;
}
/ * *
* Détecte TOUS les chevauchements entre é léments interactifs sur la page
* /
export async function detectOverlaps ( page : Page ) : Promise < OverlapReport [ ] > {
return page . evaluate ( ( ) = > {
const interactiveElements = document . querySelectorAll (
'button, a, input, select, textarea, [role="button"], [role="link"], [role="tab"], [tabindex]'
) ;
const rects : Array < { el : Element ; rect : DOMRect ; selector : string ; text : string } > = [ ] ;
2026-03-25 07:35:44 +00:00
// Check if element is visually clipped by an overflow:auto/hidden ancestor
// or hidden behind a fixed/sticky element (e.g., player bar)
function isClippedByOverflow ( el : Element ) : boolean {
let parent = el . parentElement ;
while ( parent ) {
const style = getComputedStyle ( parent ) ;
const overflow = style . overflowY || style . overflow ;
if ( overflow === 'auto' || overflow === 'hidden' || overflow === 'scroll' ) {
const parentRect = parent . getBoundingClientRect ( ) ;
const elRect = el . getBoundingClientRect ( ) ;
if ( elRect . bottom > parentRect . bottom + 2 || elRect . top < parentRect . top - 2 ) return true ;
}
parent = parent . parentElement ;
}
// Also skip elements outside the visible viewport
const rect = el . getBoundingClientRect ( ) ;
const vh = window . innerHeight ;
if ( rect . top > vh || rect . bottom < 0 ) return true ;
return false ;
}
test: update e2e test suite and add audit tests
Refine auth, player, tracks, playlists, search, workflows, edge cases,
forms, responsive, network errors, error boundary, performance, visual
regression, cross-browser, profile, smoke, storybook, chat, and session
tests. Add audit test suite (accessibility, ethical, functional, design
tokens). Update test helpers and visual snapshots.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:06:26 +00:00
interactiveElements . forEach ( el = > {
const rect = el . getBoundingClientRect ( ) ;
if ( rect . width === 0 || rect . height === 0 ) return ;
if ( getComputedStyle ( el ) . display === 'none' ) return ;
if ( getComputedStyle ( el ) . visibility === 'hidden' ) return ;
2026-03-25 07:35:44 +00:00
if ( isClippedByOverflow ( el ) ) return ;
test: update e2e test suite and add audit tests
Refine auth, player, tracks, playlists, search, workflows, edge cases,
forms, responsive, network errors, error boundary, performance, visual
regression, cross-browser, profile, smoke, storybook, chat, and session
tests. Add audit test suite (accessibility, ethical, functional, design
tokens). Update test helpers and visual snapshots.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:06:26 +00:00
const classes = ( typeof el . className === 'string' ? el . className : '' ) . slice ( 0 , 60 ) ;
const id = el . id ? ` # ${ el . id } ` : '' ;
const testid = el . getAttribute ( 'data-testid' ) ? ` [data-testid=" ${ el . getAttribute ( 'data-testid' ) } "] ` : '' ;
const selector = testid || id || ` ${ el . tagName . toLowerCase ( ) } . ${ classes . split ( ' ' ) [ 0 ] || 'unknown' } ` ;
rects . push ( {
el ,
rect ,
selector ,
text : el.textContent?.trim ( ) . slice ( 0 , 30 ) || el . getAttribute ( 'aria-label' ) || '' ,
} ) ;
} ) ;
const overlaps : OverlapReport [ ] = [ ] ;
for ( let i = 0 ; i < rects . length ; i ++ ) {
for ( let j = i + 1 ; j < rects . length ; j ++ ) {
const a = rects [ i ] . rect ;
const b = rects [ j ] . rect ;
const overlapX = Math . max ( 0 , Math . min ( a . right , b . right ) - Math . max ( a . left , b . left ) ) ;
const overlapY = Math . max ( 0 , Math . min ( a . bottom , b . bottom ) - Math . max ( a . top , b . top ) ) ;
if ( overlapX > 0 && overlapY > 0 ) {
// Ignorer parent-enfant
if ( rects [ i ] . el . contains ( rects [ j ] . el ) || rects [ j ] . el . contains ( rects [ i ] . el ) ) continue ;
const area = overlapX * overlapY ;
const severity : 'critical' | 'warning' | 'info' = area > 500 ? 'critical' : area > 100 ? 'warning' : 'info' ;
const aCenter = { x : a.left + a . width / 2 , y : a.top + a . height / 2 } ;
const bCenter = { x : b.left + b . width / 2 , y : b.top + b . height / 2 } ;
const fixDirection = aCenter . x < bCenter . x ? 'gauche' : 'droite' ;
const fixAmount = Math . ceil ( overlapX / 2 ) + 2 ;
overlaps . push ( {
elementA : {
selector : rects [ i ] . selector ,
text : rects [ i ] . text ,
rect : { x : Math.round ( a . x ) , y : Math.round ( a . y ) , width : Math.round ( a . width ) , height : Math.round ( a . height ) } ,
} ,
elementB : {
selector : rects [ j ] . selector ,
text : rects [ j ] . text ,
rect : { x : Math.round ( b . x ) , y : Math.round ( b . y ) , width : Math.round ( b . width ) , height : Math.round ( b . height ) } ,
} ,
overlapX : Math.round ( overlapX ) ,
overlapY : Math.round ( overlapY ) ,
severity ,
fix : ` Décaler " ${ rects [ i ] . text || rects [ i ] . selector } " de ${ fixAmount } px vers la ${ fixDirection } , ou ajouter gap/margin de ${ fixAmount } px ` ,
} ) ;
}
}
}
return overlaps ;
} ) ;
}
// =============================================================================
// VÉRIFICATION DES ÉTATS HOVER / FOCUS
// =============================================================================
export interface StateSnapshot {
bg : string ;
color : string ;
border : string ;
shadow : string ;
transform : string ;
cursor : string ;
opacity : string ;
outline : string ;
outlineOffset : string ;
}
export interface StateChangeReport {
selector : string ;
text : string ;
state : 'hover' | 'focus' | 'active' | 'disabled' ;
changed : boolean ;
before : StateSnapshot ;
after : StateSnapshot ;
issues : string [ ] ;
}
function captureSnapshot ( el : Element ) : StateSnapshot {
const s = getComputedStyle ( el ) ;
return {
bg : s.backgroundColor ,
color : s.color ,
border : s.borderColor ,
shadow : s.boxShadow ,
transform : s.transform ,
cursor : s.cursor ,
opacity : s.opacity ,
outline : s.outlineStyle + ' ' + s . outlineWidth + ' ' + s . outlineColor ,
outlineOffset : s.outlineOffset ,
} ;
}
async function getLocatorSelector ( locator : Locator ) : Promise < string > {
return locator . evaluate ( el = > {
const testid = el . getAttribute ( 'data-testid' ) ;
if ( testid ) return ` [data-testid=" ${ testid } "] ` ;
if ( el . id ) return ` # ${ el . id } ` ;
const cls = ( typeof el . className === 'string' ? el . className : '' ) . split ( ' ' ) [ 0 ] ;
return ` ${ el . tagName . toLowerCase ( ) } ${ cls ? '.' + cls : '' } ` ;
} ) ;
}
/ * *
* Vérifie que l 'état hover d' un é lément produit un changement visuel
* /
export async function checkHoverState ( page : Page , locator : Locator ) : Promise < StateChangeReport > {
const selector = await getLocatorSelector ( locator ) ;
const before = await locator . evaluate ( captureSnapshot ) ;
await locator . hover ( ) ;
await page . waitForTimeout ( 250 ) ;
const after = await locator . evaluate ( captureSnapshot ) ;
const text = await locator . textContent ( ) . then ( t = > t ? . trim ( ) . slice ( 0 , 30 ) || '' ) . catch ( ( ) = > '' ) ;
const issues : string [ ] = [ ] ;
const changed = JSON . stringify ( before ) !== JSON . stringify ( after ) ;
if ( ! changed ) {
issues . push ( ` AUCUN changement visuel au hover — le bouton semble inactif ` ) ;
}
if ( after . cursor !== 'pointer' ) {
issues . push ( ` Cursor " ${ after . cursor } " au lieu de "pointer" au hover ` ) ;
}
return { selector , text , state : 'hover' , changed , before , after , issues } ;
}
/ * *
* Vérifie l 'état focus (pour l' accessibilité )
* /
export async function checkFocusState ( page : Page , locator : Locator ) : Promise < StateChangeReport > {
const selector = await getLocatorSelector ( locator ) ;
const before = await locator . evaluate ( captureSnapshot ) ;
await locator . focus ( ) ;
await page . waitForTimeout ( 150 ) ;
const after = await locator . evaluate ( captureSnapshot ) ;
const text = await locator . textContent ( ) . then ( t = > t ? . trim ( ) . slice ( 0 , 30 ) || '' ) . catch ( ( ) = > '' ) ;
const issues : string [ ] = [ ] ;
const changed = JSON . stringify ( before ) !== JSON . stringify ( after ) ;
if ( ! changed ) {
issues . push ( ` AUCUN indicateur de focus visible — violation WCAG 2.4.7 ` ) ;
}
const hasOutline = await locator . evaluate ( el = > {
const s = getComputedStyle ( el ) ;
return s . outlineStyle !== 'none' || s . boxShadow !== 'none' ;
} ) ;
if ( ! hasOutline ) {
issues . push ( ` Pas d'outline ni de ring au focus — les utilisateurs clavier ne voient pas où ils sont ` ) ;
}
return { selector , text , state : 'focus' , changed , before , after , issues } ;
}
// =============================================================================
// VÉRIFICATION DE L'ALIGNEMENT ET DU SPACING
// =============================================================================
export interface AlignmentIssue {
elements : Array < { selector : string ; text : string ; x : number ; y : number ; width : number ; height : number } > ;
issue : string ;
fix : string ;
}
/ * *
* Vérifie que les enfants d ' un conteneur sont alignés et espacés régulièrement
* /
export async function checkAlignment ( page : Page , containerSelector : string ) : Promise < AlignmentIssue [ ] > {
return page . evaluate ( ( sel ) = > {
const container = document . querySelector ( sel ) ;
if ( ! container ) return [ ] ;
const children = Array . from ( container . children ) . filter ( el = > {
const s = getComputedStyle ( el ) ;
return s . display !== 'none' && s . visibility !== 'hidden' ;
} ) ;
if ( children . length < 2 ) return [ ] ;
const issues : AlignmentIssue [ ] = [ ] ;
const rects = children . map ( el = > {
const rect = el . getBoundingClientRect ( ) ;
return {
selector : ( typeof el . className === 'string' ? el . className : '' ) . slice ( 0 , 40 ) || el . tagName ,
text : el.textContent?.trim ( ) . slice ( 0 , 20 ) || '' ,
x : Math.round ( rect . x ) ,
y : Math.round ( rect . y ) ,
width : Math.round ( rect . width ) ,
height : Math.round ( rect . height ) ,
} ;
} ) ;
// Vérifier alignement vertical (les left sont-ils les mêmes ?)
const lefts = rects . map ( r = > r . x ) ;
const uniqueLefts = [ . . . new Set ( lefts ) ] ;
if ( uniqueLefts . length > 1 && uniqueLefts . length < rects . length ) {
const maxDiff = Math . max ( . . . lefts ) - Math . min ( . . . lefts ) ;
if ( maxDiff > 2 && maxDiff < 20 ) {
issues . push ( {
elements : rects ,
issue : ` Désalignement horizontal de ${ maxDiff } px entre les éléments enfants ` ,
fix : ` Ajouter items-start ou aligner les padding-left. Décalage max: ${ maxDiff } px ` ,
} ) ;
}
}
// Vérifier espacement vertical régulier
if ( rects . length >= 3 ) {
const gaps : number [ ] = [ ] ;
for ( let i = 1 ; i < rects . length ; i ++ ) {
gaps . push ( rects [ i ] . y - rects [ i - 1 ] . y - rects [ i - 1 ] . height ) ;
}
const avgGap = gaps . reduce ( ( a , b ) = > a + b , 0 ) / gaps . length ;
const irregularGaps = gaps . filter ( g = > Math . abs ( g - avgGap ) > 4 ) ;
if ( irregularGaps . length > 0 ) {
issues . push ( {
elements : rects ,
issue : ` Espacement vertical irrégulier: gaps = [ ${ gaps . map ( g = > Math . round ( g ) + 'px' ) . join ( ', ' ) } ], moyenne = ${ Math . round ( avgGap ) } px ` ,
fix : ` Utiliser gap- ${ Math . round ( avgGap / 4 ) } ( ${ Math . round ( avgGap ) } px) uniforme au lieu de margins individuels ` ,
} ) ;
}
}
// Vérifier largeurs cohérentes (dans un grid/flex)
const widths = rects . map ( r = > r . width ) ;
const maxWidthDiff = Math . max ( . . . widths ) - Math . min ( . . . widths ) ;
if ( maxWidthDiff > 5 && maxWidthDiff < 50 && new Set ( widths ) . size > 1 ) {
issues . push ( {
elements : rects ,
issue : ` Largeurs inconsistantes: ${ [ . . . new Set ( widths ) ] . map ( w = > w + 'px' ) . join ( ', ' ) } (diff = ${ maxWidthDiff } px) ` ,
fix : ` Les éléments d'une grille/liste devraient avoir la même largeur. Utiliser w-full ou grid-cols avec fr. ` ,
} ) ;
}
return issues ;
} , containerSelector ) ;
}
// =============================================================================
// VÉRIFICATION DU CONTRASTE WCAG
// =============================================================================
function luminance ( r : number , g : number , b : number ) : number {
const [ rs , gs , bs ] = [ r , g , b ] . map ( c = > {
c = c / 255 ;
return c <= 0.03928 ? c / 12.92 : Math.pow ( ( c + 0.055 ) / 1.055 , 2.4 ) ;
} ) ;
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs ;
}
function parseRgb ( color : string ) : [ number , number , number ] | null {
const match = color . match ( /rgba?\((\d+),\s*(\d+),\s*(\d+)/ ) ;
if ( ! match ) return null ;
return [ parseInt ( match [ 1 ] ) , parseInt ( match [ 2 ] ) , parseInt ( match [ 3 ] ) ] ;
}
export function contrastRatio ( fg : string , bg : string ) : number {
const fgRgb = parseRgb ( fg ) ;
const bgRgb = parseRgb ( bg ) ;
if ( ! fgRgb || ! bgRgb ) return 0 ;
const l1 = luminance ( . . . fgRgb ) ;
const l2 = luminance ( . . . bgRgb ) ;
const lighter = Math . max ( l1 , l2 ) ;
const darker = Math . min ( l1 , l2 ) ;
return ( lighter + 0.05 ) / ( darker + 0.05 ) ;
}
export interface ContrastIssue {
selector : string ;
text : string ;
fg : string ;
bg : string ;
ratio : number ;
required : number ;
fontSize : string ;
fix : string ;
}
/ * *
* Vérifie le contraste de CHAQUE é lément texte visible sur la page
* /
export async function checkContrast ( page : Page ) : Promise < ContrastIssue [ ] > {
const textElements = await page . evaluate ( ( ) = > {
const results : Array < { selector : string ; text : string ; fg : string ; bg : string ; fontSize : string ; fontWeight : string } > = [ ] ;
const seen = new Set < string > ( ) ;
document . querySelectorAll ( '*' ) . forEach ( el = > {
const text = el . textContent ? . trim ( ) ;
if ( ! text || text . length === 0 || text . length > 200 ) return ;
// Skip parents whose text is the same as their first child
if ( el . children . length > 0 && el . children [ 0 ] . textContent ? . trim ( ) === text ) return ;
const style = getComputedStyle ( el ) ;
if ( style . display === 'none' || style . visibility === 'hidden' || style . opacity === '0' ) return ;
// Trouver la couleur de fond effective (remonter les parents)
let bgColor = 'rgba(0, 0, 0, 0)' ;
let parent : Element | null = el ;
while ( parent ) {
const bg = getComputedStyle ( parent ) . backgroundColor ;
if ( bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent' ) {
bgColor = bg ;
break ;
}
parent = parent . parentElement ;
}
if ( bgColor === 'rgba(0, 0, 0, 0)' ) bgColor = 'rgb(12, 12, 15)' ; // SUMI void fallback
const key = ` ${ style . color } | ${ bgColor } | ${ text . slice ( 0 , 20 ) } ` ;
if ( seen . has ( key ) ) return ;
seen . add ( key ) ;
const testid = el . getAttribute ( 'data-testid' ) ;
const className = ( typeof el . className === 'string' ? el . className : '' ) . split ( ' ' ) [ 0 ] || '' ;
const selector = testid ? ` [data-testid=" ${ testid } "] ` : ` ${ el . tagName . toLowerCase ( ) } . ${ className } ` ;
results . push ( {
selector ,
text : text.slice ( 0 , 40 ) ,
fg : style.color ,
bg : bgColor ,
fontSize : style.fontSize ,
fontWeight : style.fontWeight ,
} ) ;
} ) ;
return results . slice ( 0 , 200 ) ;
} ) ;
const issues : ContrastIssue [ ] = [ ] ;
for ( const el of textElements ) {
const ratio = contrastRatio ( el . fg , el . bg ) ;
const fontSize = parseFloat ( el . fontSize ) ;
const isBold = parseInt ( el . fontWeight ) >= 700 ;
const isLarge = fontSize >= 18 || ( fontSize >= 14 && isBold ) ;
const required = isLarge ? 3 : 4.5 ;
if ( ratio < required && ratio > 0 ) {
issues . push ( {
selector : el.selector ,
text : el.text ,
fg : el.fg ,
bg : el.bg ,
ratio : Math.round ( ratio * 100 ) / 100 ,
required ,
fontSize : el.fontSize ,
fix : ` Contraste ${ ratio . toFixed ( 1 ) } :1 insuffisant (min ${ required } :1). Texte " ${ el . text } " en ${ el . fg } sur ${ el . bg } . Éclaircir le texte ou assombrir le fond. ` ,
} ) ;
}
}
return issues ;
}
// =============================================================================
// VÉRIFICATION DES IMAGES ET ICÔNES
// =============================================================================
export interface BrokenImageReport {
src : string ;
alt : string ;
selector : string ;
naturalWidth : number ;
naturalHeight : number ;
issue : string ;
}
/ * *
* Détecte les images cassées et les icônes sans dimension cohérente
* /
export async function checkImages ( page : Page ) : Promise < BrokenImageReport [ ] > {
return page . evaluate ( ( ) = > {
const issues : BrokenImageReport [ ] = [ ] ;
document . querySelectorAll ( 'img' ) . forEach ( img = > {
const style = getComputedStyle ( img ) ;
if ( style . display === 'none' || style . visibility === 'hidden' ) return ;
const selector = img . getAttribute ( 'data-testid' )
? ` [data-testid=" ${ img . getAttribute ( 'data-testid' ) } "] `
: img . alt ? ` img[alt=" ${ img . alt . slice ( 0 , 30 ) } "] ` : ` img[src*=" ${ ( img . src || '' ) . split ( '/' ) . pop ( ) ? . slice ( 0 , 20 ) } "] ` ;
if ( ! img . complete || img . naturalWidth === 0 ) {
issues . push ( {
src : img.src?.slice ( 0 , 100 ) || '' ,
alt : img.alt || '' ,
selector ,
naturalWidth : img.naturalWidth ,
naturalHeight : img.naturalHeight ,
issue : ` Image cassée — src=" ${ img . src ? . slice ( 0 , 60 ) } " ne se charge pas ` ,
} ) ;
}
if ( ! img . alt && ! img . getAttribute ( 'aria-hidden' ) && ! img . getAttribute ( 'role' ) ) {
issues . push ( {
src : img.src?.slice ( 0 , 100 ) || '' ,
alt : '' ,
selector ,
naturalWidth : img.naturalWidth ,
naturalHeight : img.naturalHeight ,
issue : ` Image sans alt text — violation WCAG 1.1.1. Ajouter alt="" si décorative ou un texte descriptif. ` ,
} ) ;
}
} ) ;
return issues ;
} ) ;
}
// =============================================================================
// VÉRIFICATION DES OVERFLOW / DÉBORDEMENTS
// =============================================================================
export interface OverflowReport {
selector : string ;
tag : string ;
classes : string ;
text : string ;
overflowX : number ;
overflowY : number ;
width : number ;
right : number ;
viewportWidth : number ;
fix : string ;
}
/ * *
* Vérifie si la page a un scroll horizontal réel , puis identifie les é léments
* root - cause qui débordent . Exclut les faux positifs courants :
* - position : absolute / fixed ( clippés par overflow :hidden sur les parents )
* - SVG sub - elements ( path , circle , etc . )
* - é léments à l 'intérieur d' un ancêtre overflow :hidden
* - enfants d ' un parent déjà signalé
* /
export async function checkOverflow ( page : Page ) : Promise < OverflowReport [ ] > {
return page . evaluate ( ( ) = > {
const docEl = document . documentElement ;
const vw = docEl . clientWidth ;
// Étape 1 — Y a-t-il un vrai scroll horizontal ?
const hasRealScroll = docEl . scrollWidth > vw + 5 ;
if ( ! hasRealScroll ) return [ ] ; // Pas de scroll horizontal → zéro problème
// Étape 2 — Trouver les éléments root-cause
const svgTags = new Set ( [ 'svg' , 'path' , 'circle' , 'rect' , 'line' , 'polygon' , 'polyline' , 'ellipse' , 'g' , 'use' , 'defs' , 'clippath' , 'mask' ] ) ;
function isClippedByAncestor ( el : Element ) : boolean {
let parent = el . parentElement ;
while ( parent && parent !== docEl ) {
const s = getComputedStyle ( parent ) ;
if ( s . overflow === 'hidden' || s . overflowX === 'hidden' ) return true ;
if ( s . overflow === 'clip' || s . overflowX === 'clip' ) return true ;
parent = parent . parentElement ;
}
return false ;
}
function getSelector ( el : Element ) : string {
const testid = el . getAttribute ( 'data-testid' ) ;
if ( testid ) return ` [data-testid=" ${ testid } "] ` ;
const id = el . id ;
if ( id ) return ` # ${ id } ` ;
const cls = ( typeof el . className === 'string' ? el . className : '' ) . trim ( ) . split ( /\s+/ ) . slice ( 0 , 3 ) . join ( '.' ) ;
return ` ${ el . tagName . toLowerCase ( ) } ${ cls ? '.' + cls : '' } ` ;
}
const rootCauses : OverflowReport [ ] = [ ] ;
const reported = new Set < Element > ( ) ;
document . querySelectorAll ( '*' ) . forEach ( el = > {
const style = getComputedStyle ( el ) ;
// Exclure éléments hors flux ou masqués
if ( style . display === 'none' || style . visibility === 'hidden' ) return ;
if ( style . position === 'fixed' || style . position === 'absolute' ) return ;
// Exclure sous-éléments SVG
if ( svgTags . has ( el . tagName . toLowerCase ( ) ) ) return ;
const rect = el . getBoundingClientRect ( ) ;
if ( rect . width === 0 || rect . height === 0 ) return ;
if ( rect . right <= vw + 2 ) return ; // Pas de débordement
// Exclure si un ancêtre a overflow:hidden (visuellement clippé)
if ( isClippedByAncestor ( el ) ) return ;
// Exclure si un ancêtre est déjà signalé (ne garder que le root-cause)
let ancestor = el . parentElement ;
let isChild = false ;
while ( ancestor && ancestor !== docEl ) {
if ( reported . has ( ancestor ) ) { isChild = true ; break ; }
ancestor = ancestor . parentElement ;
}
if ( isChild ) return ;
reported . add ( el ) ;
const overflow = Math . round ( rect . right - vw ) ;
const classes = ( typeof el . className === 'string' ? el . className : '' ) . trim ( ) ;
const firstClass = classes . split ( /\s+/ ) [ 0 ] || '' ;
const text = el . textContent ? . trim ( ) . slice ( 0 , 30 ) || '' ;
rootCauses . push ( {
selector : getSelector ( el ) ,
tag : el.tagName.toLowerCase ( ) ,
classes ,
text ,
overflowX : overflow ,
overflowY : 0 ,
width : Math.round ( rect . width ) ,
right : Math.round ( rect . right ) ,
viewportWidth : vw ,
fix : ` ${ getSelector ( el ) } ( ${ Math . round ( rect . width ) } px) dépasse de ${ overflow } px à droite (viewport ${ vw } px). ` +
` FIX: Ajouter overflow-x-hidden sur le conteneur parent, ou max-w-full / w-full sur . ${ firstClass } ` ,
} ) ;
} ) ;
return rootCauses . slice ( 0 , 15 ) ;
} ) ;
}