veza/tests/e2e/audit/helpers/visual-helpers.ts

663 lines
23 KiB
TypeScript
Raw Normal View History

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 }> = [];
// 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<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);
});
}