API alignment: - Analytics: useAnalyticsView calls /creator/analytics/dashboard (real data) - Chat: chatService uses /conversations + WS from backend token - Dashboard: StatsSection uses real /dashboard API data - Settings: suppress 2FA toast when endpoint unavailable - Marketplace: seed uses 'active' status, admin follows all creators Visual fixes (from pixel-perfect audit tests): - Sidebar: min-h-0 on nav for proper flex scroll boundary - TrackCard: increased action button spacing (gap-3, shrink-0) - Register: flex-wrap on terms links to prevent overlap - Discover: pb-36 for player bar clearance E2E test improvements: - helpers.ts: prepend CONFIG.baseURL for absolute URLs - visual-helpers.ts: skip elements clipped by overflow or outside viewport Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
662 lines
23 KiB
TypeScript
662 lines
23 KiB
TypeScript
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);
|
|
});
|
|
}
|