veza/tests/e2e/audit/scripts/generate-report.mjs
senke 463ad5386b test: update e2e test suite and add audit tests
Refine auth, player, tracks, playlists, search, workflows, edge cases,
forms, responsive, network errors, error boundary, performance, visual
regression, cross-browser, profile, smoke, storybook, chat, and session
tests. Add audit test suite (accessibility, ethical, functional, design
tokens). Update test helpers and visual snapshots.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:06:26 +01:00

439 lines
20 KiB
JavaScript

#!/usr/bin/env node
/**
* Génère un rapport HTML ultra-détaillé à partir des résultats Playwright JSON.
*
* Usage: node tests/e2e/audit/scripts/generate-report.mjs
* Entrée: tests/e2e/audit/results/results.json
* Sortie: tests/e2e/audit/results/AUDIT_REPORT.html
*/
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const RESULTS_DIR = join(__dirname, '..', 'results');
const RESULTS_FILE = join(RESULTS_DIR, 'results.json');
const OUTPUT_FILE = join(RESULTS_DIR, 'AUDIT_REPORT.html');
const SCREENSHOTS_DIR = join(RESULTS_DIR, 'screenshots');
// Lire les résultats
if (!existsSync(RESULTS_FILE)) {
console.error(`❌ Fichier de résultats introuvable: ${RESULTS_FILE}`);
console.error(' Lancez d\'abord: npm run audit');
process.exit(1);
}
const raw = readFileSync(RESULTS_FILE, 'utf-8');
const results = JSON.parse(raw);
// Classifier les tests
const categories = {
functional: { name: 'Fonctionnel', icon: '⚙️', tests: [] },
'pixel-perfect': { name: 'Pixel-Perfect', icon: '🎨', tests: [] },
interaction: { name: 'Interactions', icon: '🖱️', tests: [] },
accessibility: { name: 'Accessibilité', icon: '♿', tests: [] },
ethical: { name: 'Éthique', icon: '🛡️', tests: [] },
screenshots: { name: 'Screenshots', icon: '📸', tests: [] },
};
const suites = results.suites || [];
function flattenTests(suite, path = '') {
const tests = [];
const suitePath = path ? `${path} > ${suite.title}` : suite.title;
for (const spec of suite.specs || []) {
for (const test of spec.tests || []) {
for (const result of test.results || []) {
tests.push({
title: spec.title,
suite: suitePath,
status: result.status,
duration: result.duration,
error: result.error?.message || '',
stdout: (result.stdout || []).map(s => typeof s === 'string' ? s : s.text || '').join('\n'),
attachments: result.attachments || [],
});
}
}
}
for (const child of suite.suites || []) {
tests.push(...flattenTests(child, suitePath));
}
return tests;
}
const allTests = [];
for (const suite of suites) {
allTests.push(...flattenTests(suite));
}
// Classifier — uses both suite name and file path for accuracy
const pixelKeywords = [
'pixel', 'chevauche', 'hover', 'focus', 'spacing', 'espacement',
'typograph', 'couleur', 'contraste', 'bordure', 'transition',
'icône', 'image', 'responsive', 'texte', 'tap target', 'disabled',
'loading', 'scroll', 'dark mode', 'lisibilit', 'overflow', 'text',
'ombres', 'borders', 'shadows', 'readability',
];
const interactionKeywords = [
'dropdown', 'modal', 'formulaire', 'toast', 'drag', 'keyboard',
'interaction', 'forms', 'menus', 'dialogs', 'notification', 'toasts',
];
for (const test of allTests) {
const text = (test.suite + ' ' + test.title).toLowerCase();
if (text.includes('accessib') || text.includes('axe') || text.includes('wcag')) {
categories.accessibility.tests.push(test);
} else if (text.includes('éthique') || text.includes('ethical') || text.includes('gamification') || text.includes('dark pattern') || text.includes('principes')) {
categories.ethical.tests.push(test);
} else if (text.includes('screenshot')) {
categories.screenshots.tests.push(test);
} else if (pixelKeywords.some(k => text.includes(k))) {
categories['pixel-perfect'].tests.push(test);
} else if (interactionKeywords.some(k => text.includes(k))) {
categories.interaction.tests.push(test);
} else if (text.includes('fonctionnel') || text.includes('auth') || text.includes('listener') || text.includes('creator') || text.includes('admin') || text.includes('marketplace') || text.includes('intégrité') || text.includes('se charge')) {
categories.functional.tests.push(test);
} else {
categories.functional.tests.push(test);
}
}
// Scores
const totalTests = allTests.length;
const passed = allTests.filter(t => t.status === 'passed' || t.status === 'expected').length;
const failed = allTests.filter(t => t.status === 'failed' || t.status === 'unexpected').length;
const skipped = allTests.filter(t => t.status === 'skipped').length;
const globalScore = totalTests > 0 ? Math.round((passed / totalTests) * 100) : 0;
function categoryScore(cat) {
const total = cat.tests.length;
if (total === 0) return 100;
const pass = cat.tests.filter(t => t.status === 'passed' || t.status === 'expected').length;
return Math.round((pass / total) * 100);
}
// Extraire les problèmes depuis stdout et error messages des tests échoués
function extractProblems(test) {
const problems = [];
const fullText = (test.stdout || '') + '\n' + (test.error || '');
const lines = fullText.split('\n');
// Pattern 1: structured format — ÉLÉMENT: ... | PAGE: ... | MESURÉ: ... | FIX TAILWIND: ...
const structuredPattern = /ÉLÉMENT:\s*(.+?)\s*\|\s*PAGE:\s*(.+?)\s*\|\s*(?:MESURÉ:\s*(.+?)\s*\|)?\s*(?:ATTENDU:\s*(.+?)\s*\|)?\s*FIX\s*(?:TAILWIND|CSS)?:\s*(.+)/;
// Pattern 2: console log tags — [CATEGORY] description
const tagPattern = /\[(HOVER ISSUE|ALIGNMENT|CONTRASTE|OVERFLOW|FOCUS|RADIUS|ICONS|IMAGE|AXE|ETHICAL|PLAYER OVERLAP|HEADING|TRANSITION|REDUCED MOTION|TOAST|KEYBOARD|MODAL|DROPDOWN|SELECT|DRAG|TEXT OVERFLOW|TAP TARGET|SPACING|DISABLED|DARK MODE|TEXT SIZE|LINE HEIGHT|LOADING|SCROLL|COHÉRENCE|TOAST STYLE|IMAGE ALT|IMAGE DISTORTED|SPACING MOBILE)\]\s*(.+)/;
for (const line of lines) {
// Try structured format first
const sm = line.match(structuredPattern);
if (sm) {
problems.push({
element: sm[1].trim(),
page: sm[2].trim(),
measured: (sm[3] || '').trim(),
expected: (sm[4] || '').trim(),
fix: sm[5].trim(),
category: 'structured',
description: line.trim(),
});
continue;
}
// Try tag format
const tm = line.match(tagPattern);
if (tm) {
const desc = tm[2].trim();
// Extract FIX from the description if it contains one
const fixInDesc = desc.match(/FIX(?:\s*TAILWIND)?:\s*(.+)/);
problems.push({
category: tm[1],
description: desc,
fix: fixInDesc ? fixInDesc[1].trim() : '',
element: '',
page: '',
measured: '',
expected: '',
});
continue;
}
// Pattern 3: standalone FIX line — attach to previous problem
const fixMatch = line.match(/^\s*FIX:\s*(.+)/);
if (fixMatch && problems.length > 0 && !problems[problems.length - 1].fix) {
problems[problems.length - 1].fix = fixMatch[1].trim();
}
}
// Also parse the error message for bullet-point fixes
if (test.error) {
const bulletFixes = test.error.match(/•\s*(.+)/g);
if (bulletFixes) {
for (const bullet of bulletFixes) {
const text = bullet.replace(/^•\s*/, '').trim();
if (text.includes('FIX') || text.includes('Ajouter') || text.includes('Changer') || text.includes('Remplacer')) {
// Check if this fix is already captured
if (!problems.some(p => p.description.includes(text.slice(0, 40)))) {
problems.push({
category: 'error',
description: text,
fix: text,
element: '',
page: '',
measured: '',
expected: '',
});
}
}
}
}
}
return problems;
}
// Build the global "Copy all FIX" TODO list
function buildAllFixesList() {
const fixes = [];
for (const [, cat] of Object.entries(categories)) {
const failedTests = cat.tests.filter(t => t.status === 'failed' || t.status === 'unexpected');
for (const test of failedTests) {
const problems = extractProblems(test);
for (const p of problems) {
if (p.fix) {
const page = p.page || extractPageFromTitle(test.title);
fixes.push(`- [ ] ${page}: ${p.fix}`);
}
}
}
}
return fixes;
}
function extractPageFromTitle(title) {
const m = title.match(/\(([/][^)]+)\)/);
return m ? m[1] : title.split(' — ')[0] || '';
}
// Collecter les screenshots
let screenshots = [];
if (existsSync(SCREENSHOTS_DIR)) {
screenshots = readdirSync(SCREENSHOTS_DIR).filter(f => f.endsWith('.png')).sort();
}
// Générer le HTML
const html = `<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Audit Veza — Rapport Complet</title>
<style>
:root { --bg: #0c0c0f; --bg-card: #1a1a1f; --bg-hover: #2a2a31; --text: #f0ede8; --text-secondary: #a8a4a0; --text-muted: #706c68; --accent: #7c9dd6; --success: #7a9e6c; --error: #d4634a; --warning: #c9a84c; --border: rgba(255,255,255,0.1); --radius: 12px; --font-body: 'Inter', sans-serif; --font-heading: 'Space Grotesk', sans-serif; --font-mono: 'JetBrains Mono', monospace; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: var(--bg); color: var(--text); font-family: var(--font-body); font-size: 14px; line-height: 1.6; padding: 2rem; }
h1 { font-family: var(--font-heading); font-size: 2rem; margin-bottom: 1rem; }
h2 { font-family: var(--font-heading); font-size: 1.5rem; margin: 2rem 0 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; }
h3 { font-family: var(--font-heading); font-size: 1.125rem; margin: 1.5rem 0 0.5rem; }
.container { max-width: 1200px; margin: 0 auto; }
.score-card { display: flex; gap: 1.5rem; flex-wrap: wrap; margin: 1.5rem 0; }
.score { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.5rem; flex: 1; min-width: 180px; text-align: center; }
.score .value { font-size: 2.5rem; font-weight: 700; font-family: var(--font-heading); }
.score .label { color: var(--text-secondary); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 0.25rem; }
.score.pass .value { color: var(--success); }
.score.fail .value { color: var(--error); }
.score.warn .value { color: var(--warning); }
.score.info .value { color: var(--accent); }
.category { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); margin: 1rem 0; overflow: hidden; }
.category-header { padding: 1rem 1.5rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
.category-header:hover { background: var(--bg-hover); }
.category-body { padding: 0 1.5rem 1.5rem; }
.test-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0; border-bottom: 1px solid rgba(255,255,255,0.04); font-size: 0.8125rem; }
.test-row:last-child { border-bottom: none; }
.badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 9999px; font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; }
.badge.pass { background: rgba(122,158,108,0.15); color: var(--success); }
.badge.fail { background: rgba(212,99,74,0.15); color: var(--error); }
.badge.skip { background: rgba(168,164,160,0.15); color: var(--text-secondary); }
.problem { background: rgba(212,99,74,0.08); border: 1px solid rgba(212,99,74,0.2); border-radius: 8px; padding: 1rem; margin: 0.5rem 0; }
.problem .fix { font-family: var(--font-mono); font-size: 0.75rem; color: var(--success); margin-top: 0.5rem; }
.copy-btn { background: var(--bg-hover); border: 1px solid var(--border); border-radius: 6px; color: var(--text-secondary); font-size: 0.6875rem; padding: 4px 10px; cursor: pointer; float: right; }
.copy-btn:hover { background: var(--accent); color: var(--bg); }
.error-detail { font-family: var(--font-mono); font-size: 0.75rem; color: var(--error); background: rgba(212,99,74,0.06); padding: 0.75rem; border-radius: 6px; margin-top: 0.5rem; white-space: pre-wrap; max-height: 300px; overflow-y: auto; }
.progress-bar { height: 8px; background: rgba(255,255,255,0.06); border-radius: 4px; overflow: hidden; margin: 0.5rem 0; }
.progress-fill { height: 100%; border-radius: 4px; transition: width 0.3s; }
details summary { cursor: pointer; color: var(--accent); font-size: 0.8125rem; }
details summary:hover { text-decoration: underline; }
.timestamp { color: var(--text-muted); font-size: 0.75rem; }
.screenshot-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1rem; margin: 1rem 0; }
.screenshot-card { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.screenshot-card img { width: 100%; height: auto; }
.screenshot-card .caption { padding: 0.5rem; font-size: 0.75rem; color: var(--text-secondary); }
</style>
</head>
<body>
<div class="container">
<h1>Audit Veza — Rapport Complet</h1>
<p class="timestamp">Généré le ${new Date().toISOString().split('T')[0]} à ${new Date().toTimeString().split(' ')[0]}</p>
${failed > 0 ? `<div style="margin:1.5rem 0;display:flex;gap:1rem;flex-wrap:wrap;">
<button class="copy-btn" style="font-size:0.875rem;padding:10px 20px;float:none;" id="copy-all-fixes">📋 Copier tous les FIX (${failed} problèmes)</button>
<button class="copy-btn" style="font-size:0.875rem;padding:10px 20px;float:none;" id="copy-todo-list">📝 Copier la TODO list Claude Code</button>
</div>` : ''}
<div class="score-card">
<div class="score ${globalScore >= 90 ? 'pass' : globalScore >= 70 ? 'warn' : 'fail'}">
<div class="value">${globalScore}%</div>
<div class="label">Score Global</div>
<div class="progress-bar"><div class="progress-fill" style="width:${globalScore}%;background:${globalScore >= 90 ? 'var(--success)' : globalScore >= 70 ? 'var(--warning)' : 'var(--error)'}"></div></div>
</div>
<div class="score info">
<div class="value">${totalTests}</div>
<div class="label">Tests Total</div>
</div>
<div class="score pass">
<div class="value">${passed}</div>
<div class="label">Passés</div>
</div>
<div class="score fail">
<div class="value">${failed}</div>
<div class="label">Échoués</div>
</div>
<div class="score skip" style="${skipped === 0 ? 'display:none' : ''}">
<div class="value">${skipped}</div>
<div class="label">Ignorés</div>
</div>
</div>
<div class="score-card">
${Object.entries(categories).filter(([, cat]) => cat.tests.length > 0).map(([key, cat]) => {
const score = categoryScore(cat);
return `<div class="score ${score >= 90 ? 'pass' : score >= 70 ? 'warn' : 'fail'}">
<div class="value">${score}%</div>
<div class="label">${cat.icon} ${cat.name} (${cat.tests.length})</div>
<div class="progress-bar"><div class="progress-fill" style="width:${score}%;background:${score >= 90 ? 'var(--success)' : score >= 70 ? 'var(--warning)' : 'var(--error)'}"></div></div>
</div>`;
}).join('\n')}
</div>
${Object.entries(categories).filter(([, cat]) => cat.tests.length > 0).map(([key, cat]) => {
const failedTests = cat.tests.filter(t => t.status === 'failed' || t.status === 'unexpected');
const passedTests = cat.tests.filter(t => t.status === 'passed' || t.status === 'expected');
return `
<h2>${cat.icon} ${cat.name}${categoryScore(cat)}%</h2>
${failedTests.length > 0 ? `
<h3>Échecs (${failedTests.length})</h3>
${failedTests.map(test => {
const problems = extractProblems(test);
const claudeLines = problems.filter(p => p.fix).map(p => {
if (p.element) return `PROBLÈME: ${p.description}\\nÉLÉMENT: ${p.element}\\nPAGE: ${p.page}\\nMESURÉ: ${p.measured}\\nATTENDU: ${p.expected}\\nFIX: ${p.fix}`;
return `PROBLÈME: ${p.description}\\nFIX: ${p.fix}`;
});
const claudeText = claudeLines.join('\\n\\n');
return `
<div class="problem">
<div style="display:flex;justify-content:space-between;align-items:flex-start;">
<div><span class="badge fail">ÉCHOUÉ</span> <strong>${escapeHtml(test.title)}</strong></div>
${claudeLines.length > 0 ? `<button class="copy-btn" onclick="navigator.clipboard.writeText(\`${escapeJs(claudeText)}\`)">📋 Copier pour Claude Code</button>` : ''}
</div>
${problems.length > 0 ? problems.filter(p => p.fix).map(p => {
const parts = [];
if (p.element) parts.push(`<span style="color:var(--accent)">ÉLÉMENT:</span> <code>${escapeHtml(p.element)}</code>`);
if (p.page) parts.push(`<span style="color:var(--accent)">PAGE:</span> ${escapeHtml(p.page)}`);
if (p.measured) parts.push(`<span style="color:var(--warning)">MESURÉ:</span> ${escapeHtml(p.measured)}`);
if (p.expected) parts.push(`<span style="color:var(--success)">ATTENDU:</span> ${escapeHtml(p.expected)}`);
return `<div style="font-size:0.8125rem;margin:0.5rem 0;padding:0.5rem;background:rgba(0,0,0,0.2);border-radius:6px;">
${parts.length > 0 ? `<div style="margin-bottom:0.25rem">${parts.join(' | ')}</div>` : ''}
<div class="fix" style="margin:0;">FIX: ${escapeHtml(p.fix)}</div>
</div>`;
}).join('') : ''}
${test.error && problems.length === 0 ? `<div class="error-detail">${escapeHtml(test.error.slice(0, 1500))}</div>` : ''}
${test.error && problems.length > 0 ? `<details><summary>Message d'erreur complet</summary><div class="error-detail">${escapeHtml(test.error.slice(0, 1500))}</div></details>` : ''}
${test.stdout ? `<details><summary>Détails (stdout)</summary><pre class="error-detail">${escapeHtml(test.stdout.slice(0, 3000))}</pre></details>` : ''}
</div>`;
}).join('\n')}
` : ''}
${passedTests.length > 0 ? `
<details>
<summary>Tests passés (${passedTests.length})</summary>
${passedTests.map(test => `
<div class="test-row">
<span class="badge pass">OK</span>
<span>${escapeHtml(test.title)}</span>
<span class="timestamp">${test.duration}ms</span>
</div>`).join('')}
</details>` : ''}
`;
}).join('\n')}
${screenshots.length > 0 ? `
<h2>📸 Screenshots de référence (${screenshots.length})</h2>
<div class="screenshot-grid">
${screenshots.map(s => `
<div class="screenshot-card">
<img src="screenshots/${s}" alt="${s}" loading="lazy" />
<div class="caption">${s.replace('.png', '').replace(/-/g, ' ')}</div>
</div>`).join('\n')}
</div>
` : ''}
<hr style="border-color: var(--border); margin: 2rem 0;">
<p class="timestamp">Rapport généré par Veza Audit Suite — ${totalTests} tests, ${passed} passés, ${failed} échoués</p>
<p style="font-size:0.75rem;color:var(--text-muted);margin-top:0.5rem;">Pour corriger les problèmes, copiez les blocs "FIX" et donnez-les à Claude Code.</p>
</div>
<script>
// Toggle category details
document.querySelectorAll('.category-header').forEach(header => {
header.addEventListener('click', () => {
const body = header.nextElementSibling;
body.style.display = body.style.display === 'none' ? 'block' : 'none';
});
});
// Copy all fixes
const allFixes = ${JSON.stringify(buildAllFixesList())};
document.getElementById('copy-all-fixes')?.addEventListener('click', () => {
const text = allFixes.join('\\n');
navigator.clipboard.writeText(text).then(() => {
const btn = document.getElementById('copy-all-fixes');
btn.textContent = '✅ Copié !';
setTimeout(() => { btn.textContent = '📋 Copier tous les FIX (${failed} problèmes)'; }, 2000);
});
});
document.getElementById('copy-todo-list')?.addEventListener('click', () => {
const header = '## Corrections à appliquer (audit Veza)\\n\\n';
const text = header + allFixes.join('\\n');
navigator.clipboard.writeText(text).then(() => {
const btn = document.getElementById('copy-todo-list');
btn.textContent = '✅ TODO list copiée !';
setTimeout(() => { btn.textContent = '📝 Copier la TODO list Claude Code'; }, 2000);
});
});
</script>
</body>
</html>`;
function escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function escapeJs(str) {
return str.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
}
writeFileSync(OUTPUT_FILE, html, 'utf-8');
console.log(`\n✅ Rapport généré: ${OUTPUT_FILE}`);
console.log(` ${totalTests} tests — ${passed} passés — ${failed} échoués — Score: ${globalScore}%\n`);