#!/usr/bin/env node /** * Analyze and summarize test results from multiple formats * Usage: node analyze-test-results.js [options] * * Options: * --playwright Path to Playwright JSON results * --vitest Path to Vitest JSON results * --junit Path to JUnit XML results * --output Output format: text, json, markdown (default: text) * --fail-threshold Exit with code 1 if pass rate below n% (default: 0) */ const fs = require('fs'); const path = require('path'); // Parse arguments const args = process.argv.slice(2); function getArg(name) { const index = args.indexOf(`--${name}`); return index !== -1 ? args[index + 1] : null; } const playwrightPath = getArg('playwright'); const vitestPath = getArg('vitest'); const junitPath = getArg('junit'); const outputFormat = getArg('output') || 'text'; const failThreshold = parseInt(getArg('fail-threshold') || '0', 10); // Result aggregator const summary = { total: 0, passed: 0, failed: 0, skipped: 0, flaky: 0, duration: 0, suites: [], failures: [], }; // Parse Playwright JSON results function parsePlaywright(filePath) { if (!fs.existsSync(filePath)) { console.error(`Playwright results not found: ${filePath}`); return; } const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); for (const suite of data.suites || []) { parseSuite(suite, 'playwright'); } summary.duration += data.stats?.duration || 0; } function parseSuite(suite, source) { const suiteSummary = { name: suite.title || suite.file || 'Unknown', source, passed: 0, failed: 0, skipped: 0, }; for (const spec of suite.specs || []) { for (const test of spec.tests || []) { summary.total++; suiteSummary[test.status === 'expected' ? 'passed' : test.status]++; if (test.status === 'expected') { summary.passed++; } else if (test.status === 'unexpected') { summary.failed++; summary.failures.push({ name: `${suite.title} > ${spec.title}`, source, error: test.results?.[0]?.error?.message || 'Unknown error', }); } else if (test.status === 'skipped') { summary.skipped++; } else if (test.status === 'flaky') { summary.flaky++; summary.passed++; // Flaky tests that eventually passed } } } // Recurse into nested suites for (const child of suite.suites || []) { parseSuite(child, source); } if (suiteSummary.passed + suiteSummary.failed + suiteSummary.skipped > 0) { summary.suites.push(suiteSummary); } } // Parse Vitest JSON results function parseVitest(filePath) { if (!fs.existsSync(filePath)) { console.error(`Vitest results not found: ${filePath}`); return; } const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); for (const file of data.testResults || []) { const suiteSummary = { name: path.basename(file.name), source: 'vitest', passed: 0, failed: 0, skipped: 0, }; for (const test of file.assertionResults || []) { summary.total++; if (test.status === 'passed') { summary.passed++; suiteSummary.passed++; } else if (test.status === 'failed') { summary.failed++; suiteSummary.failed++; summary.failures.push({ name: test.fullName || test.title, source: 'vitest', error: test.failureMessages?.[0] || 'Unknown error', }); } else if (test.status === 'skipped' || test.status === 'pending') { summary.skipped++; suiteSummary.skipped++; } } summary.suites.push(suiteSummary); } summary.duration += data.startTime ? Date.now() - data.startTime : 0; } // Parse JUnit XML results function parseJunit(filePath) { if (!fs.existsSync(filePath)) { console.error(`JUnit results not found: ${filePath}`); return; } const xml = fs.readFileSync(filePath, 'utf-8'); // Simple XML parsing (avoid external dependencies) const testsuites = xml.match(/]*>/g) || []; for (const testsuite of testsuites) { const name = testsuite.match(/name="([^"]+)"/)?.[1] || 'Unknown'; const tests = parseInt(testsuite.match(/tests="(\d+)"/)?.[1] || '0', 10); const failures = parseInt(testsuite.match(/failures="(\d+)"/)?.[1] || '0', 10); const skipped = parseInt(testsuite.match(/skipped="(\d+)"/)?.[1] || '0', 10); const time = parseFloat(testsuite.match(/time="([\d.]+)"/)?.[1] || '0'); summary.total += tests; summary.passed += tests - failures - skipped; summary.failed += failures; summary.skipped += skipped; summary.duration += time * 1000; summary.suites.push({ name, source: 'junit', passed: tests - failures - skipped, failed: failures, skipped, }); } // Extract failure details const failureMatches = xml.matchAll(/]*name="([^"]+)"[^>]*>[\s\S]*?]*>([\s\S]*?)<\/failure>/g); for (const match of failureMatches) { summary.failures.push({ name: match[1], source: 'junit', error: match[2].trim().slice(0, 200), }); } } // Output formatters function outputText() { const passRate = summary.total > 0 ? ((summary.passed / summary.total) * 100).toFixed(1) : 0; console.log('\nšŸ“Š Test Results Summary'); console.log('='.repeat(50)); console.log(`Total: ${summary.total}`); console.log(`Passed: ${summary.passed} āœ…`); console.log(`Failed: ${summary.failed} āŒ`); console.log(`Skipped: ${summary.skipped} ā­ļø`); if (summary.flaky > 0) { console.log(`Flaky: ${summary.flaky} āš ļø`); } console.log(`Pass Rate: ${passRate}%`); console.log(`Duration: ${(summary.duration / 1000).toFixed(2)}s`); if (summary.failures.length > 0) { console.log('\nāŒ Failures:'); for (const failure of summary.failures.slice(0, 10)) { console.log(` - [${failure.source}] ${failure.name}`); console.log(` ${failure.error.slice(0, 100)}...`); } if (summary.failures.length > 10) { console.log(` ... and ${summary.failures.length - 10} more`); } } console.log(''); } function outputJson() { console.log(JSON.stringify(summary, null, 2)); } function outputMarkdown() { const passRate = summary.total > 0 ? ((summary.passed / summary.total) * 100).toFixed(1) : 0; console.log('## Test Results Summary\n'); console.log('| Metric | Value |'); console.log('|--------|-------|'); console.log(`| Total | ${summary.total} |`); console.log(`| Passed | ${summary.passed} āœ… |`); console.log(`| Failed | ${summary.failed} āŒ |`); console.log(`| Skipped | ${summary.skipped} |`); console.log(`| Pass Rate | ${passRate}% |`); console.log(`| Duration | ${(summary.duration / 1000).toFixed(2)}s |`); if (summary.failures.length > 0) { console.log('\n### Failures\n'); for (const failure of summary.failures.slice(0, 10)) { console.log(`- **[${failure.source}]** ${failure.name}`); } } } // Main execution if (playwrightPath) parsePlaywright(playwrightPath); if (vitestPath) parseVitest(vitestPath); if (junitPath) parseJunit(junitPath); if (summary.total === 0) { console.log('No test results found. Specify at least one input:'); console.log(' --playwright Playwright JSON results'); console.log(' --vitest Vitest JSON results'); console.log(' --junit JUnit XML results'); process.exit(1); } // Output results switch (outputFormat) { case 'json': outputJson(); break; case 'markdown': outputMarkdown(); break; default: outputText(); } // Check threshold const passRate = (summary.passed / summary.total) * 100; if (failThreshold > 0 && passRate < failThreshold) { console.error(`\nāŒ Pass rate ${passRate.toFixed(1)}% below threshold ${failThreshold}%`); process.exit(1); }