#!/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, '"'); }
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 += `
| ${esc(d)} |
|
${p}/${t} |
${statusText} |
`;
// -- passed list for this domain
let passedHtml = '';
for (const test of data.passed) {
passedHtml += `
✅
${esc(test.title)}
${ms(test.duration)}
`;
}
// -- 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 += `
${test.errorSnippet ? `
${esc(test.errorSnippet)}
` : ''}
${esc(impact(test))}
${screenshot?.path ? `
Voir le screenshot` : ''}
${esc(test.file)}
`;
}
// -- skipped list for this domain
let skippedHtml = '';
for (const test of data.skipped) {
skippedHtml += `
⏭
${esc(test.title)}
${ms(test.duration)}
`;
}
const hasFailures = data.failed.length > 0;
const hasPassed = data.passed.length > 0;
const hasSkipped = data.skipped.length > 0;
const totalVisible = p + f + sk;
domainSections += `
${esc(d)} ${totalVisible} tests
${hasFailures ? `${failedHtml}
` : ''}
${hasPassed ? `
${p} test${p > 1 ? 's' : ''} OK
${passedHtml} ` : ''}
${hasSkipped ? `
${sk} test${sk > 1 ? 's' : ''} ignoré${sk > 1 ? 's' : ''} (skipped)
${skippedHtml} ` : ''}
`;
}
// -- flaky list
let flakyHtml = '';
if (flaky.length > 0) {
flakyHtml = `Tests instables (flaky) — passes apres retry
`;
for (const t of flaky) flakyHtml += `- ${esc(t.title)} (${t.retries} retry${t.retries > 1 ? 's' : ''}) —
${esc(t.file)} `;
flakyHtml += '
';
}
// -- 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 = `${label}
';
};
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 = `
VEZA — Rapport d'Audit E2E
${failed.length > 0 ? `` : ''}
${failed.length > 0 ? `` : ''}
Resume par domaine
| Domaine | Progression | Tests | Status |
${domainRows}
✅ Ce qui fonctionne
❌ Ce qui ne fonctionne pas
${domainSections}
⚠️ Points d'attention
Elements non testables automatiquement
- Qualite audio reelle (transcodage, HLS adaptatif)
- Integrations tierces en production (Stripe reel, OAuth providers reels)
- Performance sous charge (utiliser k6 ou Artillery)
- Emails transactionnels (verification, reset password)
- WebSocket temps reel multi-clients
- Rendu audio/video reel dans le navigateur headless
${flakyHtml}
📋 Plan de correction
${planHtml || '
Aucun test en echec — rien a corriger.
'}
`;
// ═══════════════════════════════════════════════════════════════════════════
// 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 = `VEZA Audit
VEZA — Rapport d'Audit E2E
Aucun resultat de test trouve.
Lancez : npm run e2e:audit
`;
writeFileSync(resolve(ROOT, 'VEZA_AUDIT_REPORT.html'), ph, 'utf-8');
console.log(`📄 Placeholder written to tests/e2e/VEZA_AUDIT_REPORT.html`);
}