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 { 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 { 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 }> = []; // 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; } 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; if (isClippedByOverflow(el)) return; 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 { 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 { 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 { 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 { 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 { const textElements = await page.evaluate(() => { const results: Array<{ selector: string; text: string; fg: string; bg: string; fontSize: string; fontWeight: string }> = []; const seen = new Set(); 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 { 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 { 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(); 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); }); }