440 lines
20 KiB
JavaScript
440 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
|
|
}
|
||
|
|
|
||
|
|
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`);
|