430 lines
14 KiB
TypeScript
430 lines
14 KiB
TypeScript
import { FullConfig, FullResult, Reporter, Suite, TestCase, TestResult } from '@playwright/test/reporter';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
class CustomReporter implements Reporter {
|
|
private results: Map<string, TestCase[]> = new Map();
|
|
private suiteResults: Map<string, Suite> = new Map();
|
|
private startTime: number = Date.now();
|
|
private testResults: TestResult[] = [];
|
|
|
|
onBegin(config: FullConfig) {
|
|
console.log(`🚀 开始测试执行: ${config.projects.map(p => p.name).join(', ')}`);
|
|
this.startTime = Date.now();
|
|
}
|
|
|
|
onTestBegin(test: TestCase, result: TestResult) {
|
|
console.log(`📝 开始测试: ${test.title}`);
|
|
}
|
|
|
|
onTestEnd(test: TestCase, result: TestResult) {
|
|
console.log(`✅ 测试完成: ${test.title} - ${result.status}`);
|
|
this.testResults.push(result);
|
|
}
|
|
|
|
onEnd(result: FullResult) {
|
|
const endTime = Date.now();
|
|
const duration = endTime - this.startTime;
|
|
|
|
console.log(`🎉 测试执行完成`);
|
|
console.log(`⏱️ 总耗时: ${this.formatDuration(duration)}`);
|
|
|
|
const stats = this.calculateStats(result);
|
|
this.generateConsoleReport(stats);
|
|
this.generateHtmlReport(result, stats);
|
|
this.generateJsonReport(result, stats);
|
|
}
|
|
|
|
private calculateStats(result: FullResult): TestStats {
|
|
const allTests = this.testResults;
|
|
|
|
if (allTests.length === 0) {
|
|
return {
|
|
total: 0,
|
|
passed: 0,
|
|
failed: 0,
|
|
skipped: 0,
|
|
flaky: 0,
|
|
passRate: 0,
|
|
failRate: 0,
|
|
skipRate: 0,
|
|
flakyRate: 0,
|
|
totalDuration: 0,
|
|
avgDuration: 0,
|
|
slowestTests: [],
|
|
failedTests: [],
|
|
};
|
|
}
|
|
|
|
const passed = allTests.filter(t => t.status === 'passed');
|
|
const failed = allTests.filter(t => t.status === 'failed');
|
|
const skipped = allTests.filter(t => t.status === 'skipped');
|
|
const flaky = allTests.filter(t => t.status === 'passed' && t.retry >= 1);
|
|
|
|
const totalDuration = allTests.reduce((sum, t) => sum + t.duration, 0);
|
|
const avgDuration = totalDuration / allTests.length;
|
|
|
|
const passRate = (passed.length / allTests.length) * 100;
|
|
const failRate = (failed.length / allTests.length) * 100;
|
|
const skipRate = (skipped.length / allTests.length) * 100;
|
|
const flakyRate = (flaky.length / allTests.length) * 100;
|
|
|
|
return {
|
|
total: allTests.length,
|
|
passed: passed.length,
|
|
failed: failed.length,
|
|
skipped: skipped.length,
|
|
flaky: flaky.length,
|
|
passRate,
|
|
failRate,
|
|
skipRate,
|
|
flakyRate,
|
|
totalDuration,
|
|
avgDuration,
|
|
slowestTests: allTests
|
|
.filter(t => t.duration > 0)
|
|
.sort((a, b) => b.duration - a.duration)
|
|
.slice(0, 10),
|
|
failedTests: failed,
|
|
};
|
|
}
|
|
|
|
private generateConsoleReport(stats: TestStats) {
|
|
console.log('');
|
|
console.log('═══════════════════════════════════════════');
|
|
console.log('📊 测试统计报告');
|
|
console.log('═══════════════════════════════════════════');
|
|
console.log('');
|
|
console.log(`📈 总测试数: ${stats.total}`);
|
|
console.log(`✅ 通过: ${stats.passed} (${stats.passRate.toFixed(2)}%)`);
|
|
console.log(`❌ 失败: ${stats.failed} (${stats.failRate.toFixed(2)}%)`);
|
|
console.log(`⏭️ 跳过: ${stats.skipped} (${stats.skipRate.toFixed(2)}%)`);
|
|
console.log(`🔄 不稳定: ${stats.flaky} (${stats.flakyRate.toFixed(2)}%)`);
|
|
console.log('');
|
|
console.log(`⏱️ 总耗时: ${this.formatDuration(stats.totalDuration)}`);
|
|
console.log(`⏱️ 平均耗时: ${this.formatDuration(stats.avgDuration)}`);
|
|
console.log('');
|
|
console.log('🐌 最慢的10个测试:');
|
|
stats.slowestTests.forEach((test, index) => {
|
|
console.log(` ${index + 1}. ${test.title} - ${this.formatDuration(test.duration || 0)}`);
|
|
});
|
|
console.log('');
|
|
|
|
if (stats.failedTests.length > 0) {
|
|
console.log('❌ 失败的测试:');
|
|
stats.failedTests.forEach((test, index) => {
|
|
console.log(` ${index + 1}. ${test.title || '未命名测试'}`);
|
|
if (test.location?.file) {
|
|
console.log(` 位置: ${test.location.file}:${test.location.line || 0}`);
|
|
}
|
|
if (test.error?.message) {
|
|
console.log(` 错误: ${test.error.message}`);
|
|
}
|
|
});
|
|
console.log('');
|
|
}
|
|
}
|
|
|
|
private generateHtmlReport(result: FullResult, stats: TestStats) {
|
|
const html = `
|
|
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>测试报告 - Novalon管理系统</title>
|
|
<style>
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
|
margin: 0;
|
|
padding: 20px;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
}
|
|
.container {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
background: white;
|
|
border-radius: 10px;
|
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
|
padding: 30px;
|
|
}
|
|
.header {
|
|
text-align: center;
|
|
margin-bottom: 30px;
|
|
padding-bottom: 20px;
|
|
border-bottom: 2px solid #e0e0e0;
|
|
}
|
|
.header h1 {
|
|
margin: 0;
|
|
color: #333;
|
|
font-size: 28px;
|
|
}
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 30px;
|
|
}
|
|
.stat-card {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
padding: 20px;
|
|
border-radius: 10px;
|
|
text-align: center;
|
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
}
|
|
.stat-card h3 {
|
|
margin: 0 0 10px 0;
|
|
font-size: 14px;
|
|
opacity: 0.9;
|
|
}
|
|
.stat-card .value {
|
|
font-size: 32px;
|
|
font-weight: bold;
|
|
margin: 0;
|
|
}
|
|
.stat-card .label {
|
|
font-size: 12px;
|
|
opacity: 0.8;
|
|
margin-top: 5px;
|
|
}
|
|
.stat-card.passed {
|
|
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
|
}
|
|
.stat-card.failed {
|
|
background: linear-gradient(135deg, #ef4444 0%, #f44336 100%);
|
|
}
|
|
.stat-card.flaky {
|
|
background: linear-gradient(135deg, #f59e0b 0%, #f093fb 100%);
|
|
}
|
|
.section {
|
|
margin-bottom: 30px;
|
|
}
|
|
.section h2 {
|
|
color: #333;
|
|
border-bottom: 2px solid #667eea;
|
|
padding-bottom: 10px;
|
|
margin-bottom: 20px;
|
|
}
|
|
.test-list {
|
|
list-style: none;
|
|
padding: 0;
|
|
}
|
|
.test-item {
|
|
padding: 15px;
|
|
margin-bottom: 10px;
|
|
border-left: 4px solid #ddd;
|
|
background: #f9f9f9;
|
|
border-radius: 5px;
|
|
}
|
|
.test-item.passed {
|
|
border-left-color: #38ef7d;
|
|
background: #f0fff4;
|
|
}
|
|
.test-item.failed {
|
|
border-left-color: #ef4444;
|
|
background: #fff5f5;
|
|
}
|
|
.test-item.skipped {
|
|
border-left-color: #f59e0b;
|
|
background: #fef9c3;
|
|
}
|
|
.test-item.flaky {
|
|
border-left-color: #f093fb;
|
|
background: #fef3c7;
|
|
}
|
|
.test-item .test-name {
|
|
font-weight: bold;
|
|
margin-bottom: 5px;
|
|
color: #333;
|
|
}
|
|
.test-item .test-duration {
|
|
color: #666;
|
|
font-size: 12px;
|
|
}
|
|
.test-item .test-error {
|
|
color: #ef4444;
|
|
font-size: 12px;
|
|
margin-top: 5px;
|
|
padding: 10px;
|
|
background: #fee;
|
|
border-radius: 3px;
|
|
}
|
|
.progress-bar {
|
|
height: 20px;
|
|
background: #e0e0e0;
|
|
border-radius: 10px;
|
|
overflow: hidden;
|
|
margin: 20px 0;
|
|
}
|
|
.progress-bar-fill {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, #11998e 0%, #38ef7d 100%);
|
|
transition: width 0.5s ease;
|
|
}
|
|
.footer {
|
|
text-align: center;
|
|
margin-top: 30px;
|
|
padding-top: 20px;
|
|
border-top: 1px solid #e0e0e0;
|
|
color: #666;
|
|
font-size: 12px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1>🧪 Novalon管理系统测试报告</h1>
|
|
<p>生成时间: ${new Date().toLocaleString('zh-CN')}</p>
|
|
</div>
|
|
|
|
<div class="stats-grid">
|
|
<div class="stat-card passed">
|
|
<h3>通过测试</h3>
|
|
<div class="value">${stats.passed}</div>
|
|
<div class="label">${stats.passRate.toFixed(2)}%</div>
|
|
</div>
|
|
<div class="stat-card failed">
|
|
<h3>失败测试</h3>
|
|
<div class="value">${stats.failed}</div>
|
|
<div class="label">${stats.failRate.toFixed(2)}%</div>
|
|
</div>
|
|
<div class="stat-card flaky">
|
|
<h3>不稳定测试</h3>
|
|
<div class="value">${stats.flaky}</div>
|
|
<div class="label">${stats.flakyRate.toFixed(2)}%</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<h3>总测试数</h3>
|
|
<div class="value">${stats.total}</div>
|
|
<div class="label">100%</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="progress-bar">
|
|
<div class="progress-bar-fill" style="width: ${stats.passRate}%"></div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2>📈 测试统计</h2>
|
|
<ul class="test-list">
|
|
<li class="test-item">
|
|
<div class="test-name">总耗时</div>
|
|
<div class="test-duration">${this.formatDuration(stats.totalDuration)}</div>
|
|
</li>
|
|
<li class="test-item">
|
|
<div class="test-name">平均耗时</div>
|
|
<div class="test-duration">${this.formatDuration(stats.avgDuration)}</div>
|
|
</li>
|
|
<li class="test-item">
|
|
<div class="test-name">跳过测试</div>
|
|
<div class="test-duration">${stats.skipped} (${stats.skipRate.toFixed(2)}%)</div>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
${stats.failedTests.length > 0 ? `
|
|
<div class="section">
|
|
<h2>❌ 失败测试详情</h2>
|
|
<ul class="test-list">
|
|
${stats.failedTests.map(test => `
|
|
<li class="test-item failed">
|
|
<div class="test-name">${test.title}</div>
|
|
<div class="test-duration">${this.formatDuration(test.duration || 0)}</div>
|
|
<div class="test-error">
|
|
<strong>错误:</strong> ${test.error?.message || '未知错误'}
|
|
</div>
|
|
</li>
|
|
`).join('')}
|
|
</ul>
|
|
</div>
|
|
` : ''}
|
|
|
|
<div class="section">
|
|
<h2>🐌 最慢的10个测试</h2>
|
|
<ul class="test-list">
|
|
${stats.slowestTests.map((test, index) => `
|
|
<li class="test-item ${test.status}">
|
|
<div class="test-name">${index + 1}. ${test.title}</div>
|
|
<div class="test-duration">${this.formatDuration(test.duration || 0)}</div>
|
|
</li>
|
|
`).join('')}
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="footer">
|
|
<p>🧪 Novalon管理系统 - 自动化测试报告</p>
|
|
<p>生成时间: ${new Date().toLocaleString('zh-CN')}</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
const reportPath = path.join(process.cwd(), 'test-results', 'custom-report.html');
|
|
fs.writeFileSync(reportPath, html, 'utf-8');
|
|
console.log(`📄 HTML报告已生成: ${reportPath}`);
|
|
}
|
|
|
|
private generateJsonReport(result: FullResult, stats: TestStats) {
|
|
const report = {
|
|
summary: {
|
|
timestamp: new Date().toISOString(),
|
|
total: stats.total,
|
|
passed: stats.passed,
|
|
failed: stats.failed,
|
|
skipped: stats.skipped,
|
|
flaky: stats.flaky,
|
|
passRate: stats.passRate,
|
|
failRate: stats.failRate,
|
|
skipRate: stats.skipRate,
|
|
flakyRate: stats.flakyRate,
|
|
totalDuration: stats.totalDuration,
|
|
avgDuration: stats.avgDuration,
|
|
},
|
|
failedTests: stats.failedTests.map(test => ({
|
|
title: test.title,
|
|
location: test.location,
|
|
error: test.error?.message,
|
|
duration: test.duration,
|
|
})),
|
|
slowestTests: stats.slowestTests.map(test => ({
|
|
title: test.title,
|
|
duration: test.duration,
|
|
})),
|
|
};
|
|
|
|
const reportPath = path.join(process.cwd(), 'test-results', 'custom-report.json');
|
|
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2), 'utf-8');
|
|
console.log(`📄 JSON报告已生成: ${reportPath}`);
|
|
}
|
|
|
|
private formatDuration(ms: number): string {
|
|
if (ms < 1000) {
|
|
return `${ms}ms`;
|
|
} else if (ms < 60000) {
|
|
return `${(ms / 1000).toFixed(1)}s`;
|
|
} else {
|
|
return `${(ms / 60000).toFixed(1)}m`;
|
|
}
|
|
}
|
|
}
|
|
|
|
interface TestStats {
|
|
total: number;
|
|
passed: number;
|
|
failed: number;
|
|
skipped: number;
|
|
flaky: number;
|
|
passRate: number;
|
|
failRate: number;
|
|
skipRate: number;
|
|
flakyRate: number;
|
|
totalDuration: number;
|
|
avgDuration: number;
|
|
slowestTests: TestCase[];
|
|
}
|
|
|
|
export default CustomReporter;
|