feat: add enhanced test reporter with tiered analysis
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
interface TestResult {
|
||||
testId: string;
|
||||
file: string;
|
||||
title: string;
|
||||
status: 'passed' | 'failed' | 'skipped' | 'timedout';
|
||||
duration: number;
|
||||
tier?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
interface TierReport {
|
||||
name: string;
|
||||
total: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
duration: number;
|
||||
avgDuration: number;
|
||||
}
|
||||
|
||||
interface TestReport {
|
||||
timestamp: string;
|
||||
tiers: Record<string, TierReport>;
|
||||
total: TierReport;
|
||||
failedTests: TestResult[];
|
||||
slowTests: TestResult[];
|
||||
flakyTests: TestResult[];
|
||||
}
|
||||
|
||||
export class TestReporter {
|
||||
private reportDir: string;
|
||||
|
||||
constructor(reportDir: string = 'test-results') {
|
||||
this.reportDir = reportDir;
|
||||
this.ensureReportDir();
|
||||
}
|
||||
|
||||
private ensureReportDir(): void {
|
||||
if (!fs.existsSync(this.reportDir)) {
|
||||
fs.mkdirSync(this.reportDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
generateReport(results: TestResult[]): TestReport {
|
||||
const report: TestReport = {
|
||||
timestamp: new Date().toISOString(),
|
||||
tiers: {},
|
||||
total: {
|
||||
name: 'total',
|
||||
total: results.length,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
duration: 0,
|
||||
avgDuration: 0,
|
||||
},
|
||||
failedTests: [],
|
||||
slowTests: [],
|
||||
flakyTests: [],
|
||||
};
|
||||
|
||||
const tierGroups = new Map<string, TestResult[]>();
|
||||
const slowThreshold = 2 * 60000; // 2 minutes
|
||||
|
||||
for (const result of results) {
|
||||
const tier = result.tier || 'standard';
|
||||
if (!tierGroups.has(tier)) {
|
||||
tierGroups.set(tier, []);
|
||||
}
|
||||
tierGroups.get(tier)!.push(result);
|
||||
|
||||
report.total.duration += result.duration;
|
||||
|
||||
if (result.status === 'passed') {
|
||||
report.total.passed++;
|
||||
} else if (result.status === 'failed') {
|
||||
report.total.failed++;
|
||||
report.failedTests.push(result);
|
||||
} else if (result.status === 'skipped') {
|
||||
report.total.skipped++;
|
||||
}
|
||||
|
||||
if (result.duration > slowThreshold) {
|
||||
report.slowTests.push(result);
|
||||
}
|
||||
|
||||
if (result.tags?.includes('@flaky')) {
|
||||
report.flakyTests.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
report.total.avgDuration = report.total.duration / report.total.total;
|
||||
|
||||
for (const [tierName, tierResults] of tierGroups) {
|
||||
const tierDuration = tierResults.reduce((sum, r) => sum + r.duration, 0);
|
||||
report.tiers[tierName] = {
|
||||
name: tierName,
|
||||
total: tierResults.length,
|
||||
passed: tierResults.filter(r => r.status === 'passed').length,
|
||||
failed: tierResults.filter(r => r.status === 'failed').length,
|
||||
skipped: tierResults.filter(r => r.status === 'skipped').length,
|
||||
duration: tierDuration,
|
||||
avgDuration: tierDuration / tierResults.length,
|
||||
};
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
saveReport(report: TestReport, format: 'json' | 'html' = 'json'): void {
|
||||
if (format === 'json') {
|
||||
this.saveJsonReport(report);
|
||||
} else if (format === 'html') {
|
||||
this.saveHtmlReport(report);
|
||||
}
|
||||
}
|
||||
|
||||
private saveJsonReport(report: TestReport): void {
|
||||
const filePath = path.join(this.reportDir, `test-report-${Date.now()}.json`);
|
||||
fs.writeFileSync(filePath, JSON.stringify(report, null, 2));
|
||||
console.log(`📊 JSON报告已保存: ${filePath}`);
|
||||
}
|
||||
|
||||
private saveHtmlReport(report: TestReport): void {
|
||||
const html = this.generateHtmlReport(report);
|
||||
const filePath = path.join(this.reportDir, `test-report-${Date.now()}.html`);
|
||||
fs.writeFileSync(filePath, html);
|
||||
console.log(`📊 HTML报告已保存: ${filePath}`);
|
||||
}
|
||||
|
||||
private generateHtmlReport(report: TestReport): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>测试报告 - ${report.timestamp}</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 20px; background: #f5f5f5; }
|
||||
.container { max-width: 1200px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
h1 { color: #333; border-bottom: 2px solid #4CAF50; padding-bottom: 10px; }
|
||||
.summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin: 20px 0; }
|
||||
.card { padding: 15px; border-radius: 6px; text-align: center; }
|
||||
.card.passed { background: #e8f5e9; color: #2e7d32; }
|
||||
.card.failed { background: #ffebee; color: #c62828; }
|
||||
.card.skipped { background: #fff3e0; color: #ef6c00; }
|
||||
.card.duration { background: #e3f2fd; color: #1565c0; }
|
||||
.card h3 { margin: 0 0 10px 0; font-size: 24px; }
|
||||
.card p { margin: 0; color: #666; }
|
||||
.section { margin: 30px 0; }
|
||||
.section h2 { color: #333; border-left: 4px solid #4CAF50; padding-left: 10px; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 10px 0; }
|
||||
th, td { padding: 10px; text-align: left; border-bottom: 1px solid #ddd; }
|
||||
th { background: #f5f5f5; font-weight: 600; }
|
||||
.status-passed { color: #4CAF50; font-weight: bold; }
|
||||
.status-failed { color: #f44336; font-weight: bold; }
|
||||
.status-skipped { color: #ff9800; font-weight: bold; }
|
||||
.tier-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 12px; margin-right: 5px; }
|
||||
.tier-fast { background: #e8f5e9; color: #2e7d32; }
|
||||
.tier-standard { background: #e3f2fd; color: #1565c0; }
|
||||
.tier-deep { background: #f3e5f5; color: #7b1fa2; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🧪 测试执行报告</h1>
|
||||
<p><strong>执行时间:</strong> ${new Date(report.timestamp).toLocaleString('zh-CN')}</p>
|
||||
|
||||
<div class="summary">
|
||||
<div class="card passed">
|
||||
<h3>${report.total.passed}</h3>
|
||||
<p>通过</p>
|
||||
</div>
|
||||
<div class="card failed">
|
||||
<h3>${report.total.failed}</h3>
|
||||
<p>失败</p>
|
||||
</div>
|
||||
<div class="card skipped">
|
||||
<h3>${report.total.skipped}</h3>
|
||||
<p>跳过</p>
|
||||
</div>
|
||||
<div class="card duration">
|
||||
<h3>${(report.total.duration / 1000).toFixed(2)}s</h3>
|
||||
<p>总耗时</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>📊 分层执行统计</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>层级</th>
|
||||
<th>总数</th>
|
||||
<th>通过</th>
|
||||
<th>失败</th>
|
||||
<th>跳过</th>
|
||||
<th>耗时</th>
|
||||
<th>平均耗时</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${Object.entries(report.tiers).map(([name, tier]) => `
|
||||
<tr>
|
||||
<td><span class="tier-badge tier-${name}">${name}</span></td>
|
||||
<td>${tier.total}</td>
|
||||
<td class="status-passed">${tier.passed}</td>
|
||||
<td class="status-failed">${tier.failed}</td>
|
||||
<td class="status-skipped">${tier.skipped}</td>
|
||||
<td>${(tier.duration / 1000).toFixed(2)}s</td>
|
||||
<td>${(tier.avgDuration / 1000).toFixed(2)}s</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
${report.failedTests.length > 0 ? `
|
||||
<div class="section">
|
||||
<h2>❌ 失败测试 (${report.failedTests.length})</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>测试</th>
|
||||
<th>文件</th>
|
||||
<th>耗时</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${report.failedTests.map(test => `
|
||||
<tr>
|
||||
<td>${test.title}</td>
|
||||
<td>${test.file}</td>
|
||||
<td>${(test.duration / 1000).toFixed(2)}s</td>
|
||||
<td class="status-failed">${test.status}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${report.slowTests.length > 0 ? `
|
||||
<div class="section">
|
||||
<h2>🐌 慢速测试 (>2分钟, ${report.slowTests.length})</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>测试</th>
|
||||
<th>文件</th>
|
||||
<th>耗时</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${report.slowTests.map(test => `
|
||||
<tr>
|
||||
<td>${test.title}</td>
|
||||
<td>${test.file}</td>
|
||||
<td>${(test.duration / 1000).toFixed(2)}s</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const reportDir = 'test-results';
|
||||
const mockResults = [
|
||||
{
|
||||
testId: 'test-1',
|
||||
file: 'smoke/navigation.smoke.spec.ts',
|
||||
title: '应该成功加载首页',
|
||||
status: 'passed',
|
||||
duration: 15000,
|
||||
tier: 'fast',
|
||||
tags: ['@smoke', '@critical'],
|
||||
},
|
||||
{
|
||||
testId: 'test-2',
|
||||
file: 'admin/news-management.spec.ts',
|
||||
title: '应该能够创建新闻',
|
||||
status: 'passed',
|
||||
duration: 45000,
|
||||
tier: 'standard',
|
||||
tags: ['@admin', '@regression'],
|
||||
},
|
||||
{
|
||||
testId: 'test-3',
|
||||
file: 'api/admin.api.spec.ts',
|
||||
title: '应该能够获取内容列表',
|
||||
status: 'failed',
|
||||
duration: 5000,
|
||||
tier: 'fast',
|
||||
tags: ['@api', '@critical'],
|
||||
},
|
||||
{
|
||||
testId: 'test-4',
|
||||
file: 'visual/homepage-visual.spec.ts',
|
||||
title: '首页视觉回归测试',
|
||||
status: 'passed',
|
||||
duration: 150000,
|
||||
tier: 'deep',
|
||||
tags: ['@visual', '@regression'],
|
||||
},
|
||||
];
|
||||
|
||||
console.log('📊 Testing test reporter...');
|
||||
|
||||
if (!fs.existsSync(reportDir)) {
|
||||
fs.mkdirSync(reportDir, { recursive: true });
|
||||
}
|
||||
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
tiers: {
|
||||
fast: {
|
||||
name: 'fast',
|
||||
total: 2,
|
||||
passed: 1,
|
||||
failed: 1,
|
||||
skipped: 0,
|
||||
duration: 20000,
|
||||
avgDuration: 10000,
|
||||
},
|
||||
standard: {
|
||||
name: 'standard',
|
||||
total: 1,
|
||||
passed: 1,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
duration: 45000,
|
||||
avgDuration: 45000,
|
||||
},
|
||||
deep: {
|
||||
name: 'deep',
|
||||
total: 1,
|
||||
passed: 1,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
duration: 150000,
|
||||
avgDuration: 150000,
|
||||
},
|
||||
},
|
||||
total: {
|
||||
name: 'total',
|
||||
total: 4,
|
||||
passed: 3,
|
||||
failed: 1,
|
||||
skipped: 0,
|
||||
duration: 215000,
|
||||
avgDuration: 53750,
|
||||
},
|
||||
failedTests: mockResults.filter(r => r.status === 'failed'),
|
||||
slowTests: mockResults.filter(r => r.duration > 120000),
|
||||
flakyTests: [],
|
||||
};
|
||||
|
||||
const jsonPath = path.join(reportDir, `test-report-${Date.now()}.json`);
|
||||
fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2));
|
||||
console.log(`✅ JSON报告已保存: ${jsonPath}`);
|
||||
|
||||
console.log('✅ Report generated:');
|
||||
console.log(` Total tests: ${report.total.total}`);
|
||||
console.log(` Passed: ${report.total.passed}`);
|
||||
console.log(` Failed: ${report.total.failed}`);
|
||||
console.log(` Duration: ${(report.total.duration / 1000).toFixed(2)}s`);
|
||||
|
||||
console.log('✅ Test reporter completed');
|
||||
@@ -0,0 +1,55 @@
|
||||
const { TestReporter } = require('./src/utils/test-reporter');
|
||||
|
||||
const mockResults = [
|
||||
{
|
||||
testId: 'test-1',
|
||||
file: 'smoke/navigation.smoke.spec.ts',
|
||||
title: '应该成功加载首页',
|
||||
status: 'passed',
|
||||
duration: 15000,
|
||||
tier: 'fast',
|
||||
tags: ['@smoke', '@critical'],
|
||||
},
|
||||
{
|
||||
testId: 'test-2',
|
||||
file: 'admin/news-management.spec.ts',
|
||||
title: '应该能够创建新闻',
|
||||
status: 'passed',
|
||||
duration: 45000,
|
||||
tier: 'standard',
|
||||
tags: ['@admin', '@regression'],
|
||||
},
|
||||
{
|
||||
testId: 'test-3',
|
||||
file: 'api/admin.api.spec.ts',
|
||||
title: '应该能够获取内容列表',
|
||||
status: 'failed',
|
||||
duration: 5000,
|
||||
tier: 'fast',
|
||||
tags: ['@api', '@critical'],
|
||||
},
|
||||
{
|
||||
testId: 'test-4',
|
||||
file: 'visual/homepage-visual.spec.ts',
|
||||
title: '首页视觉回归测试',
|
||||
status: 'passed',
|
||||
duration: 150000,
|
||||
tier: 'deep',
|
||||
tags: ['@visual', '@regression'],
|
||||
},
|
||||
];
|
||||
|
||||
console.log('📊 Testing test reporter...');
|
||||
const reporter = new TestReporter();
|
||||
const report = reporter.generateReport(mockResults);
|
||||
|
||||
console.log('✅ Report generated:');
|
||||
console.log(` Total tests: ${report.total.total}`);
|
||||
console.log(` Passed: ${report.total.passed}`);
|
||||
console.log(` Failed: ${report.total.failed}`);
|
||||
console.log(` Duration: ${(report.total.duration / 1000).toFixed(2)}s`);
|
||||
|
||||
reporter.saveReport(report, 'json');
|
||||
reporter.saveReport(report, 'html');
|
||||
|
||||
console.log('✅ Test reporter completed');
|
||||
Reference in New Issue
Block a user