test-reporter.ts•9.12 kB
/**
* Test Report Generator
*
* Generates a comprehensive test report from Vitest results
*/
import { readFileSync, existsSync, writeFileSync } from 'fs';
import { join } from 'path';
interface TestResult {
name: string;
status: 'passed' | 'failed' | 'skipped';
duration: number;
error?: string;
}
interface TestSuite {
name: string;
tests: TestResult[];
totalTests: number;
passed: number;
failed: number;
skipped: number;
duration: number;
}
interface TestReport {
timestamp: string;
totalSuites: number;
totalTests: number;
passed: number;
failed: number;
skipped: number;
duration: number;
coverage?: {
lines: number;
functions: number;
branches: number;
statements: number;
};
suites: TestSuite[];
}
function generateMarkdownReport(report: TestReport): string {
const passRate = report.totalTests > 0
? ((report.passed / report.totalTests) * 100).toFixed(2)
: '0';
let markdown = `# Test Report
**Generated**: ${report.timestamp}
## Summary
| Metric | Value |
|--------|-------|
| Total Test Suites | ${report.totalSuites} |
| Total Tests | ${report.totalTests} |
| ✅ Passed | ${report.passed} |
| ❌ Failed | ${report.failed} |
| ⏭️ Skipped | ${report.skipped} |
| Pass Rate | ${passRate}% |
| Duration | ${(report.duration / 1000).toFixed(2)}s |
`;
if (report.coverage) {
markdown += `## Coverage
| Type | Percentage |
|------|------------|
| Lines | ${report.coverage.lines}% |
| Functions | ${report.coverage.functions}% |
| Branches | ${report.coverage.branches}% |
| Statements | ${report.coverage.statements}% |
`;
}
if (report.failed > 0) {
markdown += `## ❌ Failed Tests
`;
for (const suite of report.suites) {
const failedTests = suite.tests.filter(t => t.status === 'failed');
if (failedTests.length > 0) {
markdown += `### ${suite.name}\n\n`;
for (const test of failedTests) {
markdown += `- **${test.name}** (${test.duration}ms)\n`;
if (test.error) {
markdown += ` \`\`\`\n ${test.error}\n \`\`\`\n`;
}
}
markdown += '\n';
}
}
}
markdown += `## Test Suites
`;
for (const suite of report.suites) {
const icon = suite.failed > 0 ? '❌' : '✅';
markdown += `### ${icon} ${suite.name}
- **Tests**: ${suite.totalTests}
- **Passed**: ${suite.passed}
- **Failed**: ${suite.failed}
- **Skipped**: ${suite.skipped}
- **Duration**: ${(suite.duration / 1000).toFixed(2)}s
`;
}
markdown += `---
*Generated by In-Memoria Test Reporter*
`;
return markdown;
}
function generateHtmlReport(report: TestReport): string {
const passRate = report.totalTests > 0
? ((report.passed / report.totalTests) * 100).toFixed(2)
: '0';
const statusColor = report.failed === 0 ? '#28a745' : '#dc3545';
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Report - In-Memoria</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}
.header {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.header h1 {
margin: 0 0 10px 0;
color: #333;
}
.timestamp {
color: #666;
font-size: 14px;
}
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.metric {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.metric-label {
font-size: 14px;
color: #666;
margin-bottom: 5px;
}
.metric-value {
font-size: 32px;
font-weight: bold;
color: ${statusColor};
}
.coverage {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.coverage h2 {
margin-top: 0;
}
.coverage-bar {
height: 20px;
background: #e0e0e0;
border-radius: 10px;
overflow: hidden;
margin: 10px 0;
}
.coverage-fill {
height: 100%;
background: linear-gradient(to right, #28a745, #20c997);
transition: width 0.3s;
}
.suites {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.suite {
border-left: 4px solid #28a745;
padding-left: 15px;
margin-bottom: 20px;
}
.suite.failed {
border-left-color: #dc3545;
}
.suite-name {
font-size: 18px;
font-weight: bold;
margin-bottom: 10px;
}
.test {
padding: 8px 0;
border-bottom: 1px solid #eee;
}
.test:last-child {
border-bottom: none;
}
.test.passed { color: #28a745; }
.test.failed { color: #dc3545; }
.test.skipped { color: #ffc107; }
.error {
background: #f8f9fa;
padding: 10px;
border-left: 3px solid #dc3545;
margin-top: 5px;
font-family: monospace;
font-size: 12px;
white-space: pre-wrap;
}
</style>
</head>
<body>
<div class="header">
<h1>Test Report</h1>
<div class="timestamp">${report.timestamp}</div>
</div>
<div class="summary">
<div class="metric">
<div class="metric-label">Pass Rate</div>
<div class="metric-value">${passRate}%</div>
</div>
<div class="metric">
<div class="metric-label">Total Tests</div>
<div class="metric-value">${report.totalTests}</div>
</div>
<div class="metric">
<div class="metric-label">Passed</div>
<div class="metric-value" style="color: #28a745;">${report.passed}</div>
</div>
<div class="metric">
<div class="metric-label">Failed</div>
<div class="metric-value" style="color: #dc3545;">${report.failed}</div>
</div>
</div>
${report.coverage ? `
<div class="coverage">
<h2>Coverage</h2>
<div>
<div class="metric-label">Lines</div>
<div class="coverage-bar">
<div class="coverage-fill" style="width: ${report.coverage.lines}%"></div>
</div>
<div>${report.coverage.lines}%</div>
</div>
<div>
<div class="metric-label">Functions</div>
<div class="coverage-bar">
<div class="coverage-fill" style="width: ${report.coverage.functions}%"></div>
</div>
<div>${report.coverage.functions}%</div>
</div>
<div>
<div class="metric-label">Branches</div>
<div class="coverage-bar">
<div class="coverage-fill" style="width: ${report.coverage.branches}%"></div>
</div>
<div>${report.coverage.branches}%</div>
</div>
</div>
` : ''}
<div class="suites">
<h2>Test Suites</h2>
${report.suites.map(suite => `
<div class="suite ${suite.failed > 0 ? 'failed' : ''}">
<div class="suite-name">${suite.failed > 0 ? '❌' : '✅'} ${suite.name}</div>
<div>Tests: ${suite.totalTests} | Passed: ${suite.passed} | Failed: ${suite.failed} | Duration: ${(suite.duration / 1000).toFixed(2)}s</div>
${suite.tests.filter(t => t.status === 'failed').map(test => `
<div class="test failed">
❌ ${test.name} (${test.duration}ms)
${test.error ? `<div class="error">${test.error}</div>` : ''}
</div>
`).join('')}
</div>
`).join('')}
</div>
</body>
</html>`;
}
// Export for use as a module
export { TestReport, generateMarkdownReport, generateHtmlReport };
// CLI usage
if (import.meta.url === `file://${process.argv[1]}`) {
const reportPath = process.argv[2] || './test-results.json';
const outputFormat = process.argv[3] || 'markdown';
if (!existsSync(reportPath)) {
console.error(`Error: Report file not found: ${reportPath}`);
process.exit(1);
}
const reportData: TestReport = JSON.parse(readFileSync(reportPath, 'utf-8'));
if (outputFormat === 'markdown') {
const markdown = generateMarkdownReport(reportData);
const outputPath = reportPath.replace('.json', '.md');
writeFileSync(outputPath, markdown);
console.log(`✅ Markdown report generated: ${outputPath}`);
} else if (outputFormat === 'html') {
const html = generateHtmlReport(reportData);
const outputPath = reportPath.replace('.json', '.html');
writeFileSync(outputPath, html);
console.log(`✅ HTML report generated: ${outputPath}`);
} else {
console.error(`Unknown format: ${outputFormat}. Use 'markdown' or 'html'.`);
process.exit(1);
}
}