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

375 lines
12 KiB
TypeScript
Raw Normal View History

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;
}