New tests/e2e/ suite covering: - Auth, navigation, player, tracks, playlists - Search, discover, social, marketplace, chat - Accessibility, API, workflows, edge cases - Routes coverage, forms validation, modals - Empty states, responsive, network errors - Error boundary, performance, visual regression - Cross-browser, profile, smoke, upload - Storybook, deep pages, visual bugs - Includes fixtures, helpers, global setup/teardown - Playwright config and coverage map Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
774 lines
42 KiB
JavaScript
774 lines
42 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* VEZA E2E Audit Report Generator
|
|
*
|
|
* Reads Playwright JSON results and produces:
|
|
* 1. tests/e2e/VEZA_AUDIT_REPORT.html — self-contained visual report
|
|
* 2. tests/e2e/VEZA_AUDIT_REPORT.json — structured data for reuse
|
|
*
|
|
* Usage:
|
|
* node tests/e2e/scripts/generate-audit-report.mjs [results-file]
|
|
*/
|
|
|
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
import { resolve, dirname } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = resolve(__dirname, '..'); // tests/e2e/
|
|
const PROJECT_ROOT = resolve(ROOT, '../..'); // repo root
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 1. LOAD RESULTS
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
// Primary path: must match playwright.config.ts reporter outputFile
|
|
const RESULTS_PATH = resolve(ROOT, 'test-results', 'results.json');
|
|
|
|
const candidatePaths = [
|
|
process.argv[2], // CLI argument (highest priority)
|
|
RESULTS_PATH, // Primary: tests/e2e/test-results/results.json
|
|
resolve(PROJECT_ROOT, 'e2e-results.json'), // Legacy fallback
|
|
].filter(Boolean);
|
|
|
|
let results;
|
|
let loadedFrom = '';
|
|
|
|
// First pass: find a file that has actual suites (real test run)
|
|
for (const p of candidatePaths) {
|
|
try {
|
|
const parsed = JSON.parse(readFileSync(p, 'utf-8'));
|
|
if (parsed.suites && parsed.suites.length > 0) {
|
|
results = parsed;
|
|
loadedFrom = p;
|
|
break;
|
|
}
|
|
} catch { /* try next */ }
|
|
}
|
|
|
|
// Second pass: accept any parseable file (even with 0 suites / errors)
|
|
if (!results) {
|
|
for (const p of candidatePaths) {
|
|
try {
|
|
const parsed = JSON.parse(readFileSync(p, 'utf-8'));
|
|
results = parsed;
|
|
loadedFrom = p;
|
|
break;
|
|
} catch { /* try next */ }
|
|
}
|
|
}
|
|
|
|
if (!results) {
|
|
console.error('❌ results.json non trouvé. Chemins testés :');
|
|
for (const p of candidatePaths) {
|
|
const exists = existsSync(p);
|
|
console.error(` ${exists ? '✓' : '✗'} ${p}`);
|
|
}
|
|
console.error('');
|
|
console.error(' Le reporter JSON est-il configuré dans playwright.config.ts ?');
|
|
console.error(' Lancez d\'abord : npm run e2e');
|
|
writePlaceholder();
|
|
process.exit(0);
|
|
}
|
|
|
|
console.log(`📂 Loaded results from ${loadedFrom}`);
|
|
|
|
// Warn about errors in the results file
|
|
if (results.errors && results.errors.length > 0) {
|
|
console.warn(`⚠️ ${results.errors.length} error(s) in results:`);
|
|
for (const e of results.errors) {
|
|
console.warn(` ${(e.message || '').split('\n')[0]}`);
|
|
}
|
|
}
|
|
if (!results.suites || results.suites.length === 0) {
|
|
console.warn('⚠️ No test suites found in results. The report will be empty.');
|
|
console.warn(' This usually means the tests failed to start. Check the errors above.');
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 2. PARSE
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function flattenSuites(suites, parentTitle = '') {
|
|
const out = [];
|
|
for (const suite of suites || []) {
|
|
const title = parentTitle ? `${parentTitle} > ${suite.title}` : suite.title;
|
|
for (const spec of suite.specs || []) {
|
|
for (const test of spec.tests || []) {
|
|
const r = test.results?.[0] || {};
|
|
out.push({
|
|
title: spec.title,
|
|
fullTitle: `${title} > ${spec.title}`,
|
|
suite: title,
|
|
file: suite.file || fileFromTitle(title),
|
|
status: test.status || r.status || 'unknown',
|
|
duration: r.duration || 0,
|
|
error: r.error?.message || r.errors?.[0]?.message || null,
|
|
errorSnippet: snippet(r.error?.message || r.errors?.[0]?.message),
|
|
tags: tags(spec.title),
|
|
attachments: r.attachments || [],
|
|
retries: (test.results || []).length - 1,
|
|
});
|
|
}
|
|
}
|
|
if (suite.suites?.length) out.push(...flattenSuites(suite.suites, title));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function fileFromTitle(t) { return (t.match(/(\d{2}-[\w-]+\.spec\.ts)/) || [])[1] || 'unknown'; }
|
|
function snippet(m) { if (!m) return null; const l = m.split('\n').filter(x => x.trim())[0] || ''; return l.length > 200 ? l.slice(0, 200) + '...' : l; }
|
|
function tags(t) {
|
|
const o = [];
|
|
if (/@critical/i.test(t)) o.push('critical');
|
|
if (/@smoke/i.test(t)) o.push('smoke');
|
|
if (/@mobile/i.test(t)) o.push('mobile');
|
|
if (/@a11y/i.test(t)) o.push('a11y');
|
|
if (/@ethical/i.test(t)) o.push('ethical');
|
|
const fm = t.match(/@feature-(\w+)/i); if (fm) o.push(fm[1]);
|
|
return o;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 3. CATEGORISE
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
const DOMAINS = {
|
|
'01-auth': 'Auth', '02-navigation': 'Navigation', '03-player': 'Player',
|
|
'04-tracks': 'Tracks', '05-playlists': 'Playlists', '06-search': 'Search & Discover',
|
|
'07-social': 'Social', '08-marketplace': 'Marketplace',
|
|
'09-chat': 'Chat, Notifications & Settings', '10-features': 'Features avancees',
|
|
'11-accessibility': 'Accessibilite & Ethique', '12-api': 'API Backend',
|
|
'13-workflows': 'Workflows E2E', '14-edge': 'Edge Cases',
|
|
'15-routes': 'Routes Coverage', '16-forms': 'Forms Validation',
|
|
'17-modals': 'Modals & Dialogs', '18-empty': 'Empty States',
|
|
'19-responsive': 'Responsive', '20-network': 'Network Errors',
|
|
};
|
|
const DOMAIN_ORDER = Object.values(DOMAINS);
|
|
|
|
const FALLBACK_RE = [
|
|
[/auth/i, 'Auth'], [/player/i, 'Player'], [/track/i, 'Tracks'],
|
|
[/playlist/i, 'Playlists'], [/search|discover/i, 'Search & Discover'],
|
|
[/social|profil/i, 'Social'], [/market/i, 'Marketplace'],
|
|
[/chat|notif|setting/i, 'Chat, Notifications & Settings'],
|
|
[/access|ethic/i, 'Accessibilite & Ethique'], [/api/i, 'API Backend'],
|
|
[/workflow/i, 'Workflows E2E'], [/edge|error|network/i, 'Edge Cases'],
|
|
[/route/i, 'Routes Coverage'], [/form|valid/i, 'Forms Validation'],
|
|
[/modal|dialog/i, 'Modals & Dialogs'], [/empty/i, 'Empty States'],
|
|
[/responsive|mobile/i, 'Responsive'],
|
|
];
|
|
|
|
function domain(test) {
|
|
const f = test.file || '';
|
|
for (const [k, v] of Object.entries(DOMAINS)) if (f.includes(k)) return v;
|
|
const s = test.suite || test.fullTitle || '';
|
|
for (const [re, d] of FALLBACK_RE) if (re.test(s)) return d;
|
|
return 'Autre';
|
|
}
|
|
|
|
function severity(t) { return t.tags.includes('critical') ? 'critical' : t.tags.includes('smoke') ? 'minor' : 'medium'; }
|
|
function severityLabel(s) { return s === 'critical' ? 'Critique' : s === 'medium' ? 'Moyen' : 'Mineur'; }
|
|
function severityIcon(s) { return s === 'critical' ? '\u{1F534}' : s === 'medium' ? '\u{1F7E1}' : '\u{1F7E2}'; }
|
|
|
|
function impact(test) {
|
|
const t = (test.title || '').toLowerCase();
|
|
if (/login|connexion|auth/.test(t)) return "Les utilisateurs ne peuvent pas se connecter";
|
|
if (/register|inscription/.test(t)) return "Les nouveaux utilisateurs ne peuvent pas s'inscrire";
|
|
if (/play|lecture|player/.test(t)) return "La lecture de musique ne fonctionne pas";
|
|
if (/upload/.test(t)) return "Les createurs ne peuvent pas publier de musique";
|
|
if (/search|recherche/.test(t)) return "La recherche ne fonctionne pas";
|
|
if (/playlist/.test(t)) return "La gestion des playlists ne fonctionne pas";
|
|
if (/market|product/.test(t)) return "Le marketplace n'est pas fonctionnel";
|
|
if (/pay|checkout|order/.test(t)) return "Les paiements ne fonctionnent pas";
|
|
if (/chat|message/.test(t)) return "La messagerie ne fonctionne pas";
|
|
if (/notification/.test(t)) return "Les notifications ne fonctionnent pas";
|
|
if (/admin/.test(t)) return "L'administration est inaccessible";
|
|
if (/setting|param/.test(t)) return "Les parametres ne sont pas modifiables";
|
|
if (/404|500|error|crash/.test(t)) return "Erreur de navigation ou crash";
|
|
if (/mobile|responsive/.test(t)) return "L'interface mobile est cassee";
|
|
if (/access|wcag|a11y/.test(t)) return "Probleme d'accessibilite";
|
|
if (/ethic|gamif|dark.?pattern/.test(t)) return "Violation des principes ethiques VEZA";
|
|
return "Fonctionnalite degradee";
|
|
}
|
|
|
|
function isPassed(t) { return t.status === 'expected' || t.status === 'passed'; }
|
|
function isFailed(t) { return t.status === 'unexpected' || t.status === 'failed'; }
|
|
function isFlaky(t) { return t.retries > 0 && isPassed(t); }
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 4. BUILD DATA
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
const allTests = flattenSuites(results.suites || []);
|
|
const passed = allTests.filter(isPassed);
|
|
const failed = allTests.filter(isFailed);
|
|
const skipped = allTests.filter(t => t.status === 'skipped');
|
|
const flaky = allTests.filter(isFlaky);
|
|
const total = allTests.length;
|
|
const totalMs = allTests.reduce((s, t) => s + (t.duration || 0), 0);
|
|
const passRate = total > 0 ? Math.round((passed.length / total) * 100) : 0;
|
|
|
|
const byDomain = {};
|
|
for (const t of allTests) {
|
|
const d = domain(t);
|
|
if (!byDomain[d]) byDomain[d] = { passed: [], failed: [], skipped: [], flaky: [] };
|
|
if (isPassed(t)) byDomain[d].passed.push(t);
|
|
else if (t.status === 'skipped') byDomain[d].skipped.push(t);
|
|
else byDomain[d].failed.push(t);
|
|
if (isFlaky(t)) byDomain[d].flaky.push(t);
|
|
}
|
|
|
|
// ordered list
|
|
const orderedDomains = [...DOMAIN_ORDER];
|
|
for (const d of Object.keys(byDomain)) if (!orderedDomains.includes(d)) orderedDomains.push(d);
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 5. HTML GENERATION
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function esc(s) { return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
|
function ms(d) { return d < 1000 ? `${d}ms` : `${(d / 1000).toFixed(1)}s`; }
|
|
function fmtDuration(totalMs) { const m = Math.floor(totalMs / 60000); const s = Math.round((totalMs % 60000) / 1000); return `${m} min ${s} sec`; }
|
|
function slug(s) { return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+$/, ''); }
|
|
function progressColor(pct) { return pct >= 90 ? '#7a9e6c' : pct >= 60 ? '#c9a84c' : '#d4634a'; }
|
|
|
|
const now = new Date();
|
|
const dateStr = now.toISOString().replace('T', ' ').slice(0, 16);
|
|
|
|
// -- domain summary rows
|
|
let domainRows = '';
|
|
let domainSections = '';
|
|
|
|
for (const d of orderedDomains) {
|
|
const data = byDomain[d];
|
|
if (!data) continue;
|
|
const p = data.passed.length, f = data.failed.length, sk = data.skipped.length;
|
|
const t = p + f + sk;
|
|
const pct = t > 0 ? Math.round((p / t) * 100) : 0;
|
|
const ran = p + f; // tests that actually ran (not skipped)
|
|
const statusClass = ran === 0 ? (sk > 0 ? 'partial' : 'ok') : f === 0 ? 'ok' : f <= p / 2 ? 'partial' : 'ko';
|
|
const statusText = ran === 0 ? (sk > 0 ? 'SKIP' : 'OK') : f === 0 ? 'OK' : f <= p / 2 ? 'PARTIEL' : 'KO';
|
|
const id = slug(d);
|
|
|
|
domainRows += `
|
|
<tr class="domain-row" data-domain="${esc(d)}" onclick="scrollToDomain('${id}')">
|
|
<td class="domain-name">${esc(d)}</td>
|
|
<td>
|
|
<div class="mini-bar"><div class="mini-fill" style="width:${pct}%;background:${progressColor(pct)}"></div></div>
|
|
</td>
|
|
<td>${p}/${t}</td>
|
|
<td><span class="badge badge-${statusClass}">${statusText}</span></td>
|
|
</tr>`;
|
|
|
|
// -- passed list for this domain
|
|
let passedHtml = '';
|
|
for (const test of data.passed) {
|
|
passedHtml += `<div class="test-row test-passed" data-domain="${esc(d)}" data-status="passed">
|
|
<span class="icon pass-icon">✅</span>
|
|
<span class="test-title">${esc(test.title)}</span>
|
|
<span class="test-dur">${ms(test.duration)}</span>
|
|
</div>`;
|
|
}
|
|
|
|
// -- failed cards for this domain
|
|
let failedHtml = '';
|
|
for (const test of data.failed) {
|
|
const sev = severity(test);
|
|
const screenshot = test.attachments.find(a => a.name === 'screenshot' || (a.contentType || '').includes('image'));
|
|
const copyText = `${test.file} > "${test.title}"\\n${test.errorSnippet || ''}`;
|
|
failedHtml += `
|
|
<div class="fail-card test-row" data-domain="${esc(d)}" data-status="failed">
|
|
<div class="fail-header">
|
|
<span class="icon">❌</span>
|
|
<span class="test-title">${esc(test.title)}</span>
|
|
<span class="badge badge-sev-${sev}">${severityLabel(sev)}</span>
|
|
<button class="copy-btn" onclick="copyText(\`${esc(copyText)}\`)" title="Copier">📋</button>
|
|
</div>
|
|
${test.errorSnippet ? `<pre class="error-snippet"><code>${esc(test.errorSnippet)}</code></pre>` : ''}
|
|
<p class="impact">${esc(impact(test))}</p>
|
|
${screenshot?.path ? `<a class="screenshot-link" href="${esc(screenshot.path)}" target="_blank">Voir le screenshot</a>` : ''}
|
|
<div class="fail-meta">${esc(test.file)}</div>
|
|
</div>`;
|
|
}
|
|
|
|
// -- skipped list for this domain
|
|
let skippedHtml = '';
|
|
for (const test of data.skipped) {
|
|
skippedHtml += `<div class="test-row test-skipped" data-domain="${esc(d)}" data-status="skipped">
|
|
<span class="icon" style="color:var(--fg-dim)">⏭</span>
|
|
<span class="test-title" style="color:var(--fg-dim)">${esc(test.title)}</span>
|
|
<span class="test-dur">${ms(test.duration)}</span>
|
|
</div>`;
|
|
}
|
|
|
|
const hasFailures = data.failed.length > 0;
|
|
const hasPassed = data.passed.length > 0;
|
|
const hasSkipped = data.skipped.length > 0;
|
|
const totalVisible = p + f + sk;
|
|
|
|
domainSections += `
|
|
<section id="${id}" class="domain-section" data-domain="${esc(d)}">
|
|
<h3 class="domain-heading">${esc(d)} <span class="domain-count">${totalVisible} tests</span></h3>
|
|
${hasFailures ? `<div class="fail-group">${failedHtml}</div>` : ''}
|
|
${hasPassed ? `<details class="pass-group" ${!hasFailures && p <= 10 ? 'open' : ''}>
|
|
<summary>${p} test${p > 1 ? 's' : ''} OK</summary>${passedHtml}</details>` : ''}
|
|
${hasSkipped ? `<details class="pass-group" style="border-color:var(--fg-dim)">
|
|
<summary style="color:var(--fg-dim)">${sk} test${sk > 1 ? 's' : ''} ignoré${sk > 1 ? 's' : ''} (skipped)</summary>${skippedHtml}</details>` : ''}
|
|
</section>`;
|
|
}
|
|
|
|
// -- flaky list
|
|
let flakyHtml = '';
|
|
if (flaky.length > 0) {
|
|
flakyHtml = `<h4>Tests instables (flaky) — passes apres retry</h4><ul>`;
|
|
for (const t of flaky) flakyHtml += `<li>${esc(t.title)} (${t.retries} retry${t.retries > 1 ? 's' : ''}) — <code>${esc(t.file)}</code></li>`;
|
|
flakyHtml += '</ul>';
|
|
}
|
|
|
|
// -- correction plan
|
|
let planHtml = '';
|
|
if (failed.length > 0) {
|
|
const crit = failed.filter(t => t.tags.includes('critical'));
|
|
const med = failed.filter(t => !t.tags.includes('critical') && !t.tags.includes('smoke'));
|
|
const low = failed.filter(t => t.tags.includes('smoke') && !t.tags.includes('critical'));
|
|
|
|
const planGroup = (label, color, items) => {
|
|
if (items.length === 0) return '';
|
|
let h = `<h4 style="color:${color}">${label}</h4><ul class="plan-list">`;
|
|
for (const t of items) {
|
|
h += `<li><label><input type="checkbox"> <strong>${esc(domain(t))}</strong> : ${esc(t.title)} — <em>${esc(impact(t))}</em></label></li>`;
|
|
}
|
|
return h + '</ul>';
|
|
};
|
|
planHtml = planGroup('\u{1F534} P0 — Bloquants', '#d4634a', crit)
|
|
+ planGroup('\u{1F7E1} P1 — Importants', '#c9a84c', med)
|
|
+ planGroup('\u{1F7E2} P2 — Ameliorations', '#7a9e6c', low);
|
|
}
|
|
|
|
// -- copy all failures text
|
|
let allFailText = 'Corrige ces tests :\\n\\n';
|
|
for (const t of failed) {
|
|
allFailText += `- ${t.file} > "${t.title}"\\n Erreur: ${(t.errorSnippet || 'N/A').replace(/"/g, '\\"')}\\n\\n`;
|
|
}
|
|
|
|
// -- copy plan as markdown
|
|
let planMdText = '## Plan de correction\\n\\n';
|
|
const planGroups = [
|
|
['P0 Bloquants', failed.filter(t => t.tags.includes('critical'))],
|
|
['P1 Importants', failed.filter(t => !t.tags.includes('critical') && !t.tags.includes('smoke'))],
|
|
['P2 Ameliorations', failed.filter(t => t.tags.includes('smoke') && !t.tags.includes('critical'))],
|
|
];
|
|
for (const [label, items] of planGroups) {
|
|
if (items.length === 0) continue;
|
|
planMdText += `### ${label}\\n`;
|
|
for (const t of items) planMdText += `- [ ] **${domain(t)}** : ${t.title} — ${impact(t)}\\n`;
|
|
planMdText += '\\n';
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 6. FULL HTML TEMPLATE
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
const html = `<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>VEZA — Rapport d'Audit E2E</title>
|
|
<style>
|
|
/* ── SUMI dark theme tokens ───────────────────────────────────────── */
|
|
:root {
|
|
--bg: #0c0c0f; --bg-raised: #1a1a1f; --bg-card: #16161b;
|
|
--fg: #f0ede8; --fg-muted: #8a8a96; --fg-dim: #55555e;
|
|
--accent: #7c9dd6; --sage: #7a9e6c; --vermillion: #d4634a; --gold: #c9a84c;
|
|
--border: rgba(255,255,255,.08); --glass: rgba(18,18,21,.85);
|
|
--radius: 10px; --font: system-ui,-apple-system,sans-serif; --mono: 'SF Mono',SFMono-Regular,Menlo,monospace;
|
|
}
|
|
.light {
|
|
--bg: #f4f4f7; --bg-raised: #ffffff; --bg-card: #f9f9fb;
|
|
--fg: #1a1a2e; --fg-muted: #6b6b80; --fg-dim: #a0a0b0;
|
|
--border: rgba(0,0,0,.1); --glass: rgba(255,255,255,.9);
|
|
}
|
|
|
|
/* ── Reset & base ─────────────────────────────────────────────────── */
|
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
html{scroll-behavior:smooth}
|
|
body{font-family:var(--font);background:var(--bg);color:var(--fg);line-height:1.6;min-height:100vh}
|
|
a{color:var(--accent);text-decoration:none} a:hover{text-decoration:underline}
|
|
code,pre{font-family:var(--mono);font-size:.85em}
|
|
|
|
/* ── Layout ───────────────────────────────────────────────────────── */
|
|
.shell{display:flex;min-height:100vh}
|
|
.sidebar{position:sticky;top:0;width:230px;height:100vh;overflow-y:auto;padding:1.2rem .8rem;
|
|
background:var(--bg-raised);border-right:1px solid var(--border);flex-shrink:0;z-index:10}
|
|
.sidebar a{display:block;padding:.45rem .7rem;border-radius:6px;color:var(--fg-muted);font-size:.82rem;transition:.15s}
|
|
.sidebar a:hover,.sidebar a.active{background:rgba(124,157,214,.1);color:var(--accent);text-decoration:none}
|
|
.sidebar h4{font-size:.7rem;text-transform:uppercase;letter-spacing:.08em;color:var(--fg-dim);margin:1rem 0 .3rem .7rem}
|
|
.main{flex:1;min-width:0;padding:0 2rem 4rem}
|
|
|
|
@media(max-width:860px){
|
|
.shell{flex-direction:column}
|
|
.sidebar{position:relative;width:100%;height:auto;max-height:none;border-right:none;border-bottom:1px solid var(--border);
|
|
display:flex;flex-wrap:wrap;gap:.3rem;padding:.6rem}
|
|
.sidebar h4{display:none}
|
|
.sidebar a{font-size:.75rem;padding:.3rem .5rem}
|
|
.main{padding:0 1rem 3rem}
|
|
table{font-size:.78rem}
|
|
.fail-card{padding:.8rem}
|
|
}
|
|
|
|
/* ── Header ───────────────────────────────────────────────────────── */
|
|
.header{position:sticky;top:0;z-index:20;padding:1rem 2rem;
|
|
background:var(--glass);backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);
|
|
border-bottom:1px solid var(--border);display:flex;flex-wrap:wrap;align-items:center;gap:1rem}
|
|
.header-left{flex:1;min-width:200px}
|
|
.header h1{font-size:1.15rem;font-weight:700;letter-spacing:-.02em}
|
|
.header .meta{font-size:.78rem;color:var(--fg-muted)}
|
|
.counters{display:flex;gap:.8rem;flex-wrap:wrap}
|
|
.counter{text-align:center;min-width:60px}
|
|
.counter .num{font-size:1.6rem;font-weight:800;line-height:1.1}
|
|
.counter .label{font-size:.65rem;text-transform:uppercase;letter-spacing:.06em;color:var(--fg-muted)}
|
|
.c-pass .num{color:var(--sage)} .c-fail .num{color:var(--vermillion)} .c-flaky .num{color:var(--gold)} .c-skip .num{color:var(--fg-dim)}
|
|
|
|
/* ── Progress bar ─────────────────────────────────────────────────── */
|
|
.progress-wrap{width:100%;max-width:500px}
|
|
.progress-bar{height:10px;background:var(--bg);border-radius:6px;overflow:hidden;margin-top:.2rem}
|
|
.progress-fill{height:100%;border-radius:6px;transition:width 1.2s cubic-bezier(.22,1,.36,1)}
|
|
.progress-pct{font-size:.8rem;font-weight:700;margin-bottom:.1rem}
|
|
|
|
/* ── Toolbar ──────────────────────────────────────────────────────── */
|
|
.toolbar{display:flex;flex-wrap:wrap;gap:.5rem;padding:.8rem 0;align-items:center}
|
|
.filter-btn{padding:.35rem .7rem;border-radius:99px;border:1px solid var(--border);background:transparent;
|
|
color:var(--fg-muted);cursor:pointer;font-size:.78rem;transition:.15s}
|
|
.filter-btn:hover,.filter-btn.active{background:var(--accent);color:#000;border-color:var(--accent)}
|
|
.search-box{padding:.4rem .7rem;border-radius:99px;border:1px solid var(--border);background:transparent;
|
|
color:var(--fg);font-size:.8rem;width:200px;outline:none;transition:.15s}
|
|
.search-box:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(124,157,214,.2)}
|
|
.action-btn{padding:.35rem .8rem;border-radius:8px;border:1px solid var(--border);background:var(--bg-raised);
|
|
color:var(--fg);cursor:pointer;font-size:.78rem;transition:.15s}
|
|
.action-btn:hover{border-color:var(--accent);color:var(--accent)}
|
|
|
|
/* ── Theme toggle ─────────────────────────────────────────────────── */
|
|
.theme-toggle{position:fixed;top:.7rem;right:.7rem;z-index:30;width:34px;height:34px;border-radius:50%;
|
|
border:1px solid var(--border);background:var(--bg-raised);cursor:pointer;font-size:1rem;display:flex;
|
|
align-items:center;justify-content:center;transition:.15s}
|
|
.theme-toggle:hover{border-color:var(--accent)}
|
|
|
|
/* ── Domain summary table ─────────────────────────────────────────── */
|
|
table{width:100%;border-collapse:collapse}
|
|
th{text-align:left;font-size:.7rem;text-transform:uppercase;letter-spacing:.06em;color:var(--fg-dim);padding:.6rem .5rem;border-bottom:1px solid var(--border)}
|
|
td{padding:.55rem .5rem;border-bottom:1px solid var(--border);font-size:.85rem}
|
|
.domain-row{cursor:pointer;transition:.12s} .domain-row:hover{background:rgba(124,157,214,.06)}
|
|
.domain-name{font-weight:600}
|
|
.mini-bar{width:100px;height:6px;background:var(--bg);border-radius:4px;overflow:hidden}
|
|
.mini-fill{height:100%;border-radius:4px;transition:width 1s ease}
|
|
.badge{display:inline-block;padding:.15rem .55rem;border-radius:99px;font-size:.7rem;font-weight:700;letter-spacing:.03em}
|
|
.badge-ok{background:rgba(122,158,108,.15);color:var(--sage)}
|
|
.badge-partial{background:rgba(201,168,76,.15);color:var(--gold)}
|
|
.badge-ko{background:rgba(212,99,74,.15);color:var(--vermillion)}
|
|
.badge-skip{background:rgba(138,138,150,.12);color:var(--fg-dim)}
|
|
.badge-sev-critical{background:rgba(212,99,74,.15);color:var(--vermillion)}
|
|
.badge-sev-medium{background:rgba(201,168,76,.15);color:var(--gold)}
|
|
.badge-sev-minor{background:rgba(122,158,108,.15);color:var(--sage)}
|
|
|
|
/* ── Test rows ────────────────────────────────────────────────────── */
|
|
.domain-section{margin-bottom:2rem}
|
|
.domain-heading{font-size:1.05rem;font-weight:700;margin:1.5rem 0 .6rem;padding-bottom:.4rem;border-bottom:1px solid var(--border)}
|
|
.domain-count{font-size:.75rem;font-weight:400;color:var(--fg-muted);margin-left:.5rem}
|
|
.test-row{display:flex;align-items:center;gap:.5rem;padding:.35rem .4rem;border-radius:6px;font-size:.82rem}
|
|
.test-row:hover{background:rgba(255,255,255,.03)}
|
|
.test-row .icon{flex-shrink:0;width:1.2rem;text-align:center}
|
|
.test-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
.test-dur{color:var(--fg-dim);font-size:.72rem;flex-shrink:0;font-family:var(--mono)}
|
|
.pass-icon{font-size:.85rem}
|
|
|
|
/* ── Fail cards ───────────────────────────────────────────────────── */
|
|
.fail-card{background:var(--bg-card);border:1px solid var(--border);border-left:3px solid var(--vermillion);
|
|
border-radius:var(--radius);padding:1rem 1.1rem;margin-bottom:.7rem;flex-direction:column;align-items:stretch;gap:.5rem}
|
|
.fail-header{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}
|
|
.fail-header .test-title{white-space:normal;font-weight:600}
|
|
.error-snippet{margin:.4rem 0;padding:.5rem .7rem;background:var(--bg);border-radius:6px;overflow-x:auto;
|
|
font-size:.78rem;color:var(--vermillion);border:1px solid rgba(212,99,74,.15);white-space:pre-wrap;word-break:break-word}
|
|
.impact{font-size:.8rem;color:var(--fg-muted);margin:.2rem 0}
|
|
.screenshot-link{font-size:.78rem;color:var(--accent)}
|
|
.fail-meta{font-size:.7rem;color:var(--fg-dim);font-family:var(--mono)}
|
|
.copy-btn{border:none;background:transparent;cursor:pointer;font-size:1rem;padding:.1rem .3rem;border-radius:4px;transition:.12s;color:var(--fg-muted)}
|
|
.copy-btn:hover{background:rgba(124,157,214,.15);color:var(--accent)}
|
|
|
|
/* ── Collapsible ──────────────────────────────────────────────────── */
|
|
details{margin:.4rem 0}
|
|
summary{cursor:pointer;font-size:.82rem;color:var(--fg-muted);padding:.3rem .5rem;border-radius:6px;transition:.15s;list-style:none}
|
|
summary::-webkit-details-marker{display:none}
|
|
summary::before{content:'\\25B6';display:inline-block;margin-right:.4rem;font-size:.6rem;transition:transform .2s}
|
|
details[open] summary::before{transform:rotate(90deg)}
|
|
summary:hover{background:rgba(255,255,255,.03)}
|
|
.pass-group{border-radius:var(--radius);border:1px solid var(--border);overflow:hidden}
|
|
.pass-group summary{padding:.5rem .7rem;font-weight:600;color:var(--sage)}
|
|
|
|
/* ── Plan ─────────────────────────────────────────────────────────── */
|
|
.plan-list{list-style:none;margin:.5rem 0}
|
|
.plan-list li{padding:.35rem 0;font-size:.85rem}
|
|
.plan-list label{display:flex;align-items:flex-start;gap:.4rem;cursor:pointer}
|
|
.plan-list input[type=checkbox]{margin-top:.25rem;accent-color:var(--accent)}
|
|
|
|
/* ── Section headings ─────────────────────────────────────────────── */
|
|
.section-title{font-size:1.2rem;font-weight:800;margin:2.5rem 0 .8rem;display:flex;align-items:center;gap:.5rem}
|
|
|
|
/* ── Attention ────────────────────────────────────────────────────── */
|
|
.attention-list{list-style:disc;margin:.5rem 0 .5rem 1.5rem;font-size:.85rem;color:var(--fg-muted)}
|
|
|
|
/* ── Footer ───────────────────────────────────────────────────────── */
|
|
.footer{margin-top:3rem;padding:1.5rem 0;border-top:1px solid var(--border);font-size:.75rem;color:var(--fg-dim);text-align:center}
|
|
|
|
/* ── Hidden utility ───────────────────────────────────────────────── */
|
|
.hidden{display:none!important}
|
|
|
|
/* ── Responsive cards ─────────────────────────────────────────────── */
|
|
@media(max-width:600px){
|
|
table thead{display:none}
|
|
table tr{display:block;margin-bottom:.5rem;background:var(--bg-card);border-radius:var(--radius);padding:.6rem;border:1px solid var(--border)}
|
|
table td{display:flex;justify-content:space-between;border:none;padding:.2rem 0}
|
|
table td::before{content:attr(data-label);font-size:.7rem;color:var(--fg-dim);font-weight:600;text-transform:uppercase}
|
|
.mini-bar{width:60px}
|
|
.header{padding:.7rem 1rem}
|
|
.counter .num{font-size:1.2rem}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- Theme toggle -->
|
|
<button class="theme-toggle" onclick="toggleTheme()" title="Changer de theme" aria-label="Changer de theme">🌓</button>
|
|
|
|
<!-- Fixed header -->
|
|
<div class="header">
|
|
<div class="header-left">
|
|
<h1>VEZA — Rapport d'Audit E2E</h1>
|
|
<div class="meta">${esc(dateStr)} · ${fmtDuration(totalMs)}</div>
|
|
</div>
|
|
<div class="progress-wrap">
|
|
<div class="progress-pct" style="color:${progressColor(passRate)}">${passRate}%</div>
|
|
<div class="progress-bar"><div class="progress-fill" style="width:${passRate}%;background:${progressColor(passRate)}"></div></div>
|
|
</div>
|
|
<div class="counters">
|
|
<div class="counter c-pass"><div class="num">${passed.length}</div><div class="label">Passes</div></div>
|
|
<div class="counter c-fail"><div class="num">${failed.length}</div><div class="label">Echoues</div></div>
|
|
<div class="counter c-flaky"><div class="num">${flaky.length}</div><div class="label">Flaky</div></div>
|
|
<div class="counter c-skip"><div class="num">${skipped.length}</div><div class="label">Ignores</div></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="shell">
|
|
|
|
<!-- Sidebar nav -->
|
|
<nav class="sidebar">
|
|
<h4>Sections</h4>
|
|
<a href="#summary">Resume par domaine</a>
|
|
<a href="#works">✅ Ce qui fonctionne</a>
|
|
<a href="#broken">❌ Ce qui ne fonctionne pas</a>
|
|
<a href="#attention">⚠️ Points d'attention</a>
|
|
<a href="#plan">📋 Plan de correction</a>
|
|
<h4>Domaines</h4>
|
|
${orderedDomains.filter(d => byDomain[d]).map(d => `<a href="#${slug(d)}" data-nav-domain="${esc(d)}">${esc(d)}</a>`).join('\n ')}
|
|
</nav>
|
|
|
|
<!-- Main content -->
|
|
<div class="main">
|
|
|
|
<!-- Toolbar -->
|
|
<div class="toolbar" id="toolbar">
|
|
<button class="filter-btn active" data-filter="all" onclick="filterStatus('all')">Tout (${total})</button>
|
|
<button class="filter-btn" data-filter="passed" onclick="filterStatus('passed')">Passes (${passed.length})</button>
|
|
<button class="filter-btn" data-filter="failed" onclick="filterStatus('failed')">Echoues (${failed.length})</button>
|
|
<button class="filter-btn" data-filter="flaky" onclick="filterStatus('flaky')">Flaky (${flaky.length})</button>
|
|
<button class="filter-btn" data-filter="skipped" onclick="filterStatus('skipped')">Ignores (${skipped.length})</button>
|
|
<input class="search-box" type="search" placeholder="Rechercher un test..." oninput="filterSearch(this.value)" aria-label="Rechercher">
|
|
<span style="flex:1"></span>
|
|
${failed.length > 0 ? `<button class="action-btn" onclick="copyAllFailures()" title="Copier tous les echecs">📋 Copier echecs</button>` : ''}
|
|
${failed.length > 0 ? `<button class="action-btn" onclick="copyPlanMd()" title="Copier le plan en Markdown">📋 Copier plan</button>` : ''}
|
|
</div>
|
|
|
|
<!-- ── Summary table ──────────────────────────────────────────── -->
|
|
<h2 class="section-title" id="summary">Resume par domaine</h2>
|
|
<table>
|
|
<thead><tr><th>Domaine</th><th>Progression</th><th>Tests</th><th>Status</th></tr></thead>
|
|
<tbody>${domainRows}</tbody>
|
|
</table>
|
|
|
|
<!-- ── Domain details ─────────────────────────────────────────── -->
|
|
<h2 class="section-title" id="works">✅ Ce qui fonctionne</h2>
|
|
<h2 class="section-title" id="broken">❌ Ce qui ne fonctionne pas</h2>
|
|
|
|
${domainSections}
|
|
|
|
<!-- ── Attention ──────────────────────────────────────────────── -->
|
|
<h2 class="section-title" id="attention">⚠️ Points d'attention</h2>
|
|
<h4>Elements non testables automatiquement</h4>
|
|
<ul class="attention-list">
|
|
<li>Qualite audio reelle (transcodage, HLS adaptatif)</li>
|
|
<li>Integrations tierces en production (Stripe reel, OAuth providers reels)</li>
|
|
<li>Performance sous charge (utiliser k6 ou Artillery)</li>
|
|
<li>Emails transactionnels (verification, reset password)</li>
|
|
<li>WebSocket temps reel multi-clients</li>
|
|
<li>Rendu audio/video reel dans le navigateur headless</li>
|
|
</ul>
|
|
${flakyHtml}
|
|
|
|
<!-- ── Correction plan ────────────────────────────────────────── -->
|
|
<h2 class="section-title" id="plan">📋 Plan de correction</h2>
|
|
${planHtml || '<p style="color:var(--sage)">Aucun test en echec — rien a corriger.</p>'}
|
|
|
|
<!-- ── Footer ─────────────────────────────────────────────────── -->
|
|
<div class="footer">
|
|
${total} tests · ${Object.keys(byDomain).length} domaines · Node ${process.version}<br>
|
|
<code>npm run e2e:audit</code>
|
|
</div>
|
|
|
|
</div><!-- .main -->
|
|
</div><!-- .shell -->
|
|
|
|
<script>
|
|
/* ── Theme ───────────────────────────────────────────────────────── */
|
|
function toggleTheme(){document.body.classList.toggle('light')}
|
|
|
|
/* ── Filter by status ────────────────────────────────────────────── */
|
|
let currentFilter='all';
|
|
function filterStatus(s){
|
|
currentFilter=s;
|
|
document.querySelectorAll('.filter-btn').forEach(b=>b.classList.toggle('active',b.dataset.filter===s));
|
|
applyFilters();
|
|
}
|
|
|
|
/* ── Filter by search ────────────────────────────────────────────── */
|
|
let searchQuery='';
|
|
function filterSearch(q){searchQuery=q.toLowerCase();applyFilters()}
|
|
|
|
/* ── Filter by domain (click summary row) ────────────────────────── */
|
|
let activeDomain=null;
|
|
function scrollToDomain(id){
|
|
activeDomain=null; applyFilters();
|
|
document.getElementById(id)?.scrollIntoView({behavior:'smooth',block:'start'});
|
|
}
|
|
function filterByDomain(d){
|
|
activeDomain = activeDomain===d ? null : d;
|
|
document.querySelectorAll('[data-nav-domain]').forEach(a=>a.classList.toggle('active',a.dataset.navDomain===activeDomain));
|
|
applyFilters();
|
|
}
|
|
document.querySelectorAll('[data-nav-domain]').forEach(a=>{
|
|
a.addEventListener('click',e=>{e.preventDefault();filterByDomain(a.dataset.navDomain)});
|
|
});
|
|
|
|
/* ── Unified filter ──────────────────────────────────────────────── */
|
|
function applyFilters(){
|
|
// Flaky IDs
|
|
const flakyTitles=new Set(${JSON.stringify(flaky.map(t=>t.title))});
|
|
|
|
document.querySelectorAll('.test-row').forEach(el=>{
|
|
const status=el.dataset.status||'';
|
|
const dom=el.dataset.domain||'';
|
|
const title=(el.querySelector('.test-title')?.textContent||'').toLowerCase();
|
|
const isFlaky=flakyTitles.has(el.querySelector('.test-title')?.textContent||'');
|
|
|
|
let show=true;
|
|
if(currentFilter==='passed' && status!=='passed') show=false;
|
|
if(currentFilter==='failed' && status!=='failed') show=false;
|
|
if(currentFilter==='skipped' && status!=='skipped') show=false;
|
|
if(currentFilter==='flaky' && !isFlaky) show=false;
|
|
if(activeDomain && dom!==activeDomain) show=false;
|
|
if(searchQuery && !title.includes(searchQuery)) show=false;
|
|
|
|
el.classList.toggle('hidden',!show);
|
|
});
|
|
|
|
// Hide empty sections
|
|
document.querySelectorAll('.domain-section').forEach(sec=>{
|
|
const dom=sec.dataset.domain||'';
|
|
if(activeDomain && dom!==activeDomain){sec.classList.add('hidden');return}
|
|
const visible=sec.querySelectorAll('.test-row:not(.hidden)').length;
|
|
sec.classList.toggle('hidden',visible===0 && (currentFilter!=='all' || searchQuery || activeDomain));
|
|
});
|
|
|
|
// Hide domain rows in summary
|
|
document.querySelectorAll('.domain-row').forEach(row=>{
|
|
const dom=row.dataset.domain||'';
|
|
if(activeDomain && dom!==activeDomain) row.classList.add('hidden');
|
|
else row.classList.remove('hidden');
|
|
});
|
|
}
|
|
|
|
/* ── Copy helpers ────────────────────────────────────────────────── */
|
|
function copyText(t){navigator.clipboard.writeText(t.replace(/\\\\n/g,'\\n')).then(()=>flash('Copie !'))}
|
|
function copyAllFailures(){copyText(\`${allFailText}\`)}
|
|
function copyPlanMd(){copyText(\`${planMdText}\`)}
|
|
function flash(msg){
|
|
const el=document.createElement('div');
|
|
el.textContent=msg;
|
|
el.style.cssText='position:fixed;bottom:1.5rem;right:1.5rem;padding:.5rem 1rem;background:var(--accent);color:#000;border-radius:8px;font-size:.85rem;font-weight:600;z-index:999;animation:fadeout .8s .5s forwards';
|
|
document.body.appendChild(el);
|
|
setTimeout(()=>el.remove(),1400);
|
|
}
|
|
|
|
/* ── Animate progress bars on load ───────────────────────────────── */
|
|
document.addEventListener('DOMContentLoaded',()=>{
|
|
document.querySelectorAll('.progress-fill,.mini-fill').forEach(el=>{
|
|
const w=el.style.width; el.style.width='0'; requestAnimationFrame(()=>{requestAnimationFrame(()=>{el.style.width=w})});
|
|
});
|
|
});
|
|
</script>
|
|
|
|
<style>@keyframes fadeout{to{opacity:0;transform:translateY(8px)}}</style>
|
|
|
|
</body>
|
|
</html>`;
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 7. WRITE OUTPUTS
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
const htmlPath = resolve(ROOT, 'VEZA_AUDIT_REPORT.html');
|
|
writeFileSync(htmlPath, html, 'utf-8');
|
|
|
|
// JSON output
|
|
const jsonData = {
|
|
generated: now.toISOString(),
|
|
durationMs: totalMs,
|
|
total, passed: passed.length, failed: failed.length, skipped: skipped.length, flaky: flaky.length,
|
|
passRate,
|
|
domains: orderedDomains.filter(d => byDomain[d]).map(d => {
|
|
const data = byDomain[d];
|
|
return {
|
|
name: d,
|
|
passed: data.passed.length,
|
|
failed: data.failed.length,
|
|
skipped: data.skipped.length,
|
|
flaky: data.flaky.length,
|
|
failedTests: data.failed.map(t => ({ title: t.title, file: t.file, error: t.errorSnippet, severity: severity(t), impact: impact(t) })),
|
|
};
|
|
}),
|
|
allFailed: failed.map(t => ({ title: t.title, file: t.file, error: t.errorSnippet, severity: severity(t), impact: impact(t), tags: t.tags })),
|
|
};
|
|
const jsonPath = resolve(ROOT, 'VEZA_AUDIT_REPORT.json');
|
|
writeFileSync(jsonPath, JSON.stringify(jsonData, null, 2), 'utf-8');
|
|
|
|
console.log(`\n${'═'.repeat(55)}`);
|
|
console.log(` VEZA E2E AUDIT: ${passed.length}/${total} (${passRate}%) — ${failed.length} echec(s)`);
|
|
console.log(`${'═'.repeat(55)}`);
|
|
console.log(` ✅ HTML : ${htmlPath}`);
|
|
console.log(` 📊 JSON : ${jsonPath}`);
|
|
console.log(`${'═'.repeat(55)}\n`);
|
|
|
|
if (failed.length > 0) process.exit(1);
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Placeholder when no results
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
function writePlaceholder() {
|
|
const ph = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>VEZA Audit</title>
|
|
<style>body{font-family:system-ui;background:#0c0c0f;color:#f0ede8;display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center}
|
|
code{background:#1a1a1f;padding:.2rem .5rem;border-radius:4px;font-size:.9rem}</style></head>
|
|
<body><div><h1>VEZA — Rapport d'Audit E2E</h1><p style="color:#8a8a96">Aucun resultat de test trouve.</p>
|
|
<p style="margin-top:1rem">Lancez : <code>npm run e2e:audit</code></p></div></body></html>`;
|
|
writeFileSync(resolve(ROOT, 'VEZA_AUDIT_REPORT.html'), ph, 'utf-8');
|
|
console.log(`📄 Placeholder written to tests/e2e/VEZA_AUDIT_REPORT.html`);
|
|
}
|