#!/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 += `
${esc(test.title)} ${severityLabel(sev)}
${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

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

VEZA — Rapport d'Audit E2E

${esc(dateStr)} · ${fmtDuration(totalMs)}
${passRate}%
${passed.length}
Passes
${failed.length}
Echoues
${flaky.length}
Flaky
${skipped.length}
Ignores
${failed.length > 0 ? `` : ''} ${failed.length > 0 ? `` : ''}

Resume par domaine

${domainRows}
DomaineProgressionTestsStatus

✅ Ce qui fonctionne

❌ Ce qui ne fonctionne pas

${domainSections}

⚠️ Points d'attention

Elements non testables automatiquement

${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`); }