375 lines
12 KiB
TypeScript
375 lines
12 KiB
TypeScript
|
|
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<DropdownTestResult> {
|
||
|
|
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<ModalTestResult> {
|
||
|
|
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<FormFieldInfo[]> {
|
||
|
|
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<KeyboardNavResult> {
|
||
|
|
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<TransitionResult> {
|
||
|
|
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<HeadingHierarchyIssue[]> {
|
||
|
|
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;
|
||
|
|
}
|