import type { Page, Locator } from '@playwright/test'; // ============================================================================= // TESTS DE DROPDOWN / MENU // ============================================================================= export interface DropdownTestResult { trigger: string; opens: boolean; closesOnEscape: boolean; closesOnClickOutside: boolean; optionsVisible: boolean; optionCount: number; overflowsViewport: boolean; issues: string[]; } /** * Teste le comportement complet d'un dropdown */ export async function testDropdown( page: Page, triggerLocator: Locator, menuSelector: string, ): Promise { const trigger = await triggerLocator.evaluate(el => el.textContent?.trim().slice(0, 30) || ''); const issues: string[] = []; // 1. Ouvrir await triggerLocator.click(); await page.waitForTimeout(300); const menu = page.locator(menuSelector).first(); const opens = await menu.isVisible().catch(() => false); if (!opens) { issues.push(`Le menu ne s'ouvre pas au clic sur le trigger`); return { trigger, opens, closesOnEscape: false, closesOnClickOutside: false, optionsVisible: false, optionCount: 0, overflowsViewport: false, issues }; } // 2. Options visibles const options = await menu.locator('[role="menuitem"], [role="option"], li, button').all(); const optionCount = options.length; const optionsVisible = optionCount > 0; if (!optionsVisible) { issues.push(`Le menu s'ouvre mais ne contient aucune option`); } // 3. Overflow const overflowsViewport = await menu.evaluate(el => { const rect = el.getBoundingClientRect(); return rect.right > window.innerWidth || rect.bottom > window.innerHeight; }); if (overflowsViewport) { issues.push(`Le menu déborde du viewport`); } // 4. Escape ferme await page.keyboard.press('Escape'); await page.waitForTimeout(200); const closesOnEscape = !(await menu.isVisible().catch(() => false)); if (!closesOnEscape) { issues.push(`Escape ne ferme pas le menu`); } // 5. Ré-ouvrir puis clic dehors await triggerLocator.click(); await page.waitForTimeout(300); await page.mouse.click(10, 10); // Clic en haut à gauche (hors du menu) await page.waitForTimeout(200); const closesOnClickOutside = !(await menu.isVisible().catch(() => false)); if (!closesOnClickOutside) { issues.push(`Clic en dehors ne ferme pas le menu`); } return { trigger, opens, closesOnEscape, closesOnClickOutside, optionsVisible, optionCount, overflowsViewport, issues }; } // ============================================================================= // TESTS DE MODAL / DIALOG // ============================================================================= export interface ModalTestResult { trigger: string; opens: boolean; hasBackdrop: boolean; closesOnEscape: boolean; closesOnBackdropClick: boolean; hasCloseButton: boolean; closesOnCloseButton: boolean; focusTrapped: boolean; issues: string[]; } /** * Teste le comportement complet d'un modal */ export async function testModal( page: Page, triggerLocator: Locator, dialogSelector = '[role="dialog"]', ): Promise { const trigger = await triggerLocator.evaluate(el => el.textContent?.trim().slice(0, 30) || ''); const issues: string[] = []; // 1. Ouvrir await triggerLocator.click(); await page.waitForTimeout(500); const dialog = page.locator(dialogSelector).first(); const opens = await dialog.isVisible().catch(() => false); if (!opens) { issues.push(`Le modal ne s'ouvre pas au clic sur le trigger`); return { trigger, opens, hasBackdrop: false, closesOnEscape: false, closesOnBackdropClick: false, hasCloseButton: false, closesOnCloseButton: false, focusTrapped: false, issues }; } // 2. Backdrop const hasBackdrop = await page.evaluate(() => { const overlays = document.querySelectorAll('.fixed.inset-0, [data-dialog-backdrop]'); return Array.from(overlays).some(el => { const s = getComputedStyle(el); return s.backgroundColor !== 'rgba(0, 0, 0, 0)' || s.backdropFilter !== 'none'; }); }); if (!hasBackdrop) { issues.push(`Pas de backdrop visible derrière le modal`); } // 3. Escape ferme await page.keyboard.press('Escape'); await page.waitForTimeout(300); const closesOnEscape = !(await dialog.isVisible().catch(() => false)); if (!closesOnEscape) { issues.push(`Escape ne ferme pas le modal`); } // Ré-ouvrir pour les tests suivants if (closesOnEscape) { await triggerLocator.click(); await page.waitForTimeout(500); } // 4. Bouton close const closeBtn = dialog.locator('button[aria-label*="close" i], button[aria-label*="fermer" i], button:has(svg)').first(); const hasCloseButton = await closeBtn.isVisible().catch(() => false); let closesOnCloseButton = false; if (hasCloseButton) { await closeBtn.click(); await page.waitForTimeout(300); closesOnCloseButton = !(await dialog.isVisible().catch(() => false)); if (!closesOnCloseButton) { issues.push(`Le bouton X ne ferme pas le modal`); } } else { issues.push(`Pas de bouton close visible dans le modal`); } // Ré-ouvrir pour le test backdrop if (closesOnCloseButton || closesOnEscape) { await triggerLocator.click(); await page.waitForTimeout(500); } // 5. Backdrop click ferme let closesOnBackdropClick = false; if (hasBackdrop) { await page.mouse.click(5, 5); await page.waitForTimeout(300); closesOnBackdropClick = !(await dialog.isVisible().catch(() => false)); if (!closesOnBackdropClick) { issues.push(`Clic sur le backdrop ne ferme pas le modal`); } } // 6. Focus trap (ré-ouvrir) if (closesOnBackdropClick || closesOnCloseButton || closesOnEscape) { await triggerLocator.click(); await page.waitForTimeout(500); } const focusTrapped = await page.evaluate((sel) => { const dlg = document.querySelector(sel); if (!dlg) return false; const focusable = dlg.querySelectorAll('button, input, select, textarea, a[href], [tabindex]'); return focusable.length > 0; }, dialogSelector); // Nettoyage — fermer le modal await page.keyboard.press('Escape'); await page.waitForTimeout(200); return { trigger, opens, hasBackdrop, closesOnEscape, closesOnBackdropClick, hasCloseButton, closesOnCloseButton, focusTrapped, issues }; } // ============================================================================= // TESTS DE FORMULAIRE // ============================================================================= export interface FormFieldInfo { name: string; type: string; required: boolean; selector: string; label: string; } export interface FormValidationResult { formSelector: string; fields: FormFieldInfo[]; emptySubmitErrors: string[]; issues: string[]; } /** * Récupère les informations de tous les champs d'un formulaire */ export async function getFormFields(page: Page, formSelector: string): Promise { return page.evaluate((sel) => { const form = document.querySelector(sel); if (!form) return []; const fields: FormFieldInfo[] = []; form.querySelectorAll('input, textarea, select').forEach(el => { const input = el as HTMLInputElement; if (input.type === 'hidden' || input.type === 'submit') return; const label = input.labels?.[0]?.textContent?.trim() || input.getAttribute('aria-label') || input.getAttribute('placeholder') || input.name || ''; fields.push({ name: input.name || input.id || '', type: input.type || 'text', required: input.required || input.getAttribute('aria-required') === 'true', selector: input.id ? `#${input.id}` : `[name="${input.name}"]`, label, }); }); return fields; }, formSelector); } // ============================================================================= // TESTS DE KEYBOARD NAVIGATION // ============================================================================= export interface KeyboardNavResult { totalTabStops: number; focusOrder: Array<{ tag: string; text: string; hasVisibleFocus: boolean }>; issues: string[]; } /** * Simule la navigation Tab à travers la page et vérifie que chaque élément focusé est visible */ export async function testKeyboardNav(page: Page, maxTabs = 30): Promise { const focusOrder: KeyboardNavResult['focusOrder'] = []; const issues: string[] = []; // Reset focus au body await page.evaluate(() => (document.activeElement as HTMLElement)?.blur?.()); for (let i = 0; i < maxTabs; i++) { await page.keyboard.press('Tab'); await page.waitForTimeout(100); const focusInfo = await page.evaluate(() => { const el = document.activeElement; if (!el || el === document.body) return null; const style = getComputedStyle(el); const hasVisibleFocus = style.outlineStyle !== 'none' || style.boxShadow !== 'none' || style.outlineWidth !== '0px'; return { tag: el.tagName.toLowerCase(), text: el.textContent?.trim().slice(0, 30) || el.getAttribute('aria-label') || '', hasVisibleFocus, }; }); if (!focusInfo) continue; focusOrder.push(focusInfo); if (!focusInfo.hasVisibleFocus) { issues.push( `<${focusInfo.tag}> "${focusInfo.text}" — aucun indicateur de focus visible. ` + `Ajouter focus-visible:ring-2 focus-visible:ring-primary/50` ); } } return { totalTabStops: focusOrder.length, focusOrder, issues, }; } // ============================================================================= // TESTS D'ANIMATION / TRANSITION // ============================================================================= export interface TransitionResult { selector: string; property: string; duration: string; hasTransition: boolean; respectsReducedMotion: boolean; issue: string | null; } /** * Vérifie qu'un élément a des transitions déclarées (pas de changement brusque) */ export async function checkTransitions(page: Page, locator: Locator): Promise { return locator.evaluate(el => { const style = getComputedStyle(el); const transition = style.transition; const hasTransition = transition !== 'all 0s ease 0s' && transition !== '' && transition !== 'none'; const selector = el.getAttribute('data-testid') ? `[data-testid="${el.getAttribute('data-testid')}"]` : `${el.tagName.toLowerCase()}`; return { selector, property: style.transitionProperty, duration: style.transitionDuration, hasTransition, respectsReducedMotion: true, // Checked at CSS level via @media issue: hasTransition ? null : `Pas de transition déclarée — les changements visuels seront brusques`, }; }); } // ============================================================================= // VÉRIFICATION DES HEADING HIERARCHY // ============================================================================= export interface HeadingHierarchyIssue { issue: string; headings: Array<{ level: number; text: string }>; } /** * Vérifie la hiérarchie des titres (h1 > h2 > h3, pas de sauts) */ export async function checkHeadingHierarchy(page: Page): Promise { const headings = await page.evaluate(() => { return Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6')) .filter(h => getComputedStyle(h).display !== 'none') .map(h => ({ level: parseInt(h.tagName[1]), text: h.textContent?.trim().slice(0, 30) || '', })); }); const issues: HeadingHierarchyIssue[] = []; for (let i = 1; i < headings.length; i++) { const prev = headings[i - 1].level; const curr = headings[i].level; if (curr > prev + 1) { issues.push({ issue: `Saut de heading: h${prev} "${headings[i - 1].text}" → h${curr} "${headings[i].text}" (manque h${prev + 1})`, headings, }); } } return issues; }