Files
everything-is-suitable/everything-is-suitable-test/e2e/core/test-coverage-reporter.ts
T
张翔 08ea5fbe98 feat(admin): 添加用户管理相关文件
添加用户管理视图、API和状态管理文件
2026-03-28 14:37:29 +08:00

530 lines
16 KiB
TypeScript

import { test } from '@playwright/test';
import { testLogger } from '../shared/utils/test-logger';
import * as fs from 'fs';
import * as path from 'path';
export interface TestCoverage {
totalTests: number;
passedTests: number;
failedTests: number;
skippedTests: number;
passRate: number;
testSuites: TestSuiteCoverage[];
executionTime: number;
timestamp: string;
}
export interface TestSuiteCoverage {
name: string;
totalTests: number;
passedTests: number;
failedTests: number;
skippedTests: number;
passRate: number;
tests: TestCaseCoverage[];
}
export interface TestCaseCoverage {
name: string;
status: 'passed' | 'failed' | 'skipped';
duration: number;
tags: string[];
file: string;
}
export class TestCoverageReporter {
private coverageData: TestCoverage;
private testResults: Map<string, TestCaseCoverage[]> = new Map();
private suiteResults: Map<string, TestSuiteCoverage> = new Map();
private startTime: number = 0;
private endTime: number = 0;
constructor() {
this.coverageData = {
totalTests: 0,
passedTests: 0,
failedTests: 0,
skippedTests: 0,
passRate: 0,
testSuites: [],
executionTime: 0,
timestamp: new Date().toISOString()
};
}
startCoverage(): void {
this.startTime = Date.now();
testLogger.info('开始收集测试覆盖率数据');
}
endCoverage(): void {
this.endTime = Date.now();
this.coverageData.executionTime = this.endTime - this.startTime;
this.calculateCoverage();
this.generateReport();
testLogger.info('测试覆盖率收集完成');
testLogger.info(`总测试数: ${this.coverageData.totalTests}`);
testLogger.info(`通过测试数: ${this.coverageData.passedTests}`);
testLogger.info(`失败测试数: ${this.coverageData.failedTests}`);
testLogger.info(`跳过测试数: ${this.coverageData.skippedTests}`);
testLogger.info(`通过率: ${this.coverageData.passRate.toFixed(2)}%`);
}
recordTestResult(suiteName: string, testName: string, status: 'passed' | 'failed' | 'skipped', duration: number, tags: string[], file: string): void {
const testCase: TestCaseCoverage = {
name: testName,
status,
duration,
tags,
file
};
if (!this.testResults.has(suiteName)) {
this.testResults.set(suiteName, []);
}
this.testResults.get(suiteName)!.push(testCase);
}
private calculateCoverage(): void {
let totalTests = 0;
let passedTests = 0;
let failedTests = 0;
let skippedTests = 0;
for (const [suiteName, testCases] of this.testResults.entries()) {
const suiteCoverage = this.calculateSuiteCoverage(suiteName, testCases);
this.suiteResults.set(suiteName, suiteCoverage);
totalTests += suiteCoverage.totalTests;
passedTests += suiteCoverage.passedTests;
failedTests += suiteCoverage.failedTests;
skippedTests += suiteCoverage.skippedTests;
}
this.coverageData.totalTests = totalTests;
this.coverageData.passedTests = passedTests;
this.coverageData.failedTests = failedTests;
this.coverageData.skippedTests = skippedTests;
this.coverageData.passRate = totalTests > 0 ? (passedTests / totalTests) * 100 : 0;
this.coverageData.testSuites = Array.from(this.suiteResults.values());
}
private calculateSuiteCoverage(suiteName: string, testCases: TestCaseCoverage[]): TestSuiteCoverage {
const totalTests = testCases.length;
const passedTests = testCases.filter(tc => tc.status === 'passed').length;
const failedTests = testCases.filter(tc => tc.status === 'failed').length;
const skippedTests = testCases.filter(tc => tc.status === 'skipped').length;
const passRate = totalTests > 0 ? (passedTests / totalTests) * 100 : 0;
return {
name: suiteName,
totalTests,
passedTests,
failedTests,
skippedTests,
passRate,
tests: testCases
};
}
private generateReport(): void {
const reportDir = path.join(process.cwd(), 'test-results', 'coverage');
if (!fs.existsSync(reportDir)) {
fs.mkdirSync(reportDir, { recursive: true });
}
this.generateJSONReport(reportDir);
this.generateHTMLReport(reportDir);
this.generateMarkdownReport(reportDir);
this.generateConsoleReport();
}
private generateJSONReport(reportDir: string): void {
const jsonPath = path.join(reportDir, 'coverage.json');
fs.writeFileSync(jsonPath, JSON.stringify(this.coverageData, null, 2), 'utf-8');
testLogger.info(`JSON覆盖率报告已生成: ${jsonPath}`);
}
private generateHTMLReport(reportDir: string): void {
const htmlPath = path.join(reportDir, 'coverage.html');
const html = this.generateHTMLContent();
fs.writeFileSync(htmlPath, html, 'utf-8');
testLogger.info(`HTML覆盖率报告已生成: ${htmlPath}`);
}
private generateMarkdownReport(reportDir: string): void {
const mdPath = path.join(reportDir, 'coverage.md');
const markdown = this.generateMarkdownContent();
fs.writeFileSync(mdPath, markdown, 'utf-8');
testLogger.info(`Markdown覆盖率报告已生成: ${mdPath}`);
}
private generateHTMLContent(): string {
const { totalTests, passedTests, failedTests, skippedTests, passRate, executionTime, testSuites, timestamp } = this.coverageData;
const passRateColor = passRate >= 80 ? '#52c41a' : passRate >= 60 ? '#faad14' : '#f5222d';
const passRateClass = passRate >= 80 ? 'success' : passRate >= 60 ? 'warning' : 'danger';
return `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>测试覆盖率报告</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background-color: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 32px;
font-weight: 600;
}
.header p {
margin: 10px 0 0 0;
font-size: 14px;
opacity: 0.9;
}
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
padding: 30px;
background-color: #fafafa;
}
.summary-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
text-align: center;
}
.summary-card .label {
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.summary-card .value {
font-size: 28px;
font-weight: 600;
color: #333;
}
.summary-card .value.${passRateClass} {
color: ${passRateColor};
}
.suites {
padding: 30px;
}
.suites h2 {
margin: 0 0 20px 0;
font-size: 24px;
color: #333;
}
.suite {
margin-bottom: 30px;
border: 1px solid #e8e8e8;
border-radius: 8px;
overflow: hidden;
}
.suite-header {
background-color: #f8f9fa;
padding: 15px 20px;
border-bottom: 1px solid #e8e8e8;
display: flex;
justify-content: space-between;
align-items: center;
}
.suite-header h3 {
margin: 0;
font-size: 18px;
color: #333;
}
.suite-stats {
display: flex;
gap: 20px;
font-size: 14px;
}
.suite-stats span {
padding: 4px 12px;
border-radius: 12px;
font-weight: 500;
}
.suite-stats .passed {
background-color: #e6f7ff;
color: #4a5568;
}
.suite-stats .failed {
background-color: #fee2e2;
color: #991b1b;
}
.suite-stats .skipped {
background-color: #fef3c7;
color: #92400e;
}
.suite-stats .rate {
background-color: ${passRateColor};
color: white;
}
.test-cases {
padding: 20px;
}
.test-case {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 15px;
border-bottom: 1px solid #f0f0f0;
}
.test-case:last-child {
border-bottom: none;
}
.test-case .name {
flex: 1;
font-size: 14px;
color: #333;
}
.test-case .status {
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.test-case .status.passed {
background-color: #d4edda;
color: #0f5132;
}
.test-case .status.failed {
background-color: #f8d7da;
color: #721c24;
}
.test-case .status.skipped {
background-color: #fff3cd;
color: #856404;
}
.test-case .duration {
font-size: 12px;
color: #999;
min-width: 80px;
text-align: right;
}
.test-case .tags {
display: flex;
gap: 5px;
}
.test-case .tag {
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
background-color: #e9ecef;
color: #495057;
}
.footer {
text-align: center;
padding: 20px;
color: #999;
font-size: 12px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🧪 测试覆盖率报告</h1>
<p>生成时间: ${timestamp}</p>
<p>执行时间: ${(executionTime / 1000).toFixed(2)}秒</p>
</div>
<div class="summary">
<div class="summary-card">
<div class="label">总测试数</div>
<div class="value">${totalTests}</div>
</div>
<div class="summary-card">
<div class="label">通过测试</div>
<div class="value" style="color: #52c41a">${passedTests}</div>
</div>
<div class="summary-card">
<div class="label">失败测试</div>
<div class="value" style="color: #f5222d">${failedTests}</div>
</div>
<div class="summary-card">
<div class="label">跳过测试</div>
<div class="value" style="color: #faad14">${skippedTests}</div>
</div>
<div class="summary-card">
<div class="label">通过率</div>
<div class="value ${passRateClass}">${passRate.toFixed(2)}%</div>
</div>
</div>
<div class="suites">
<h2>📊 测试套件详情</h2>
${testSuites.map(suite => `
<div class="suite">
<div class="suite-header">
<h3>${suite.name}</h3>
<div class="suite-stats">
<span class="passed">✓ ${suite.passedTests}</span>
<span class="failed">✗ ${suite.failedTests}</span>
<span class="skipped">⊘ ${suite.skippedTests}</span>
<span class="rate">${suite.passRate.toFixed(1)}%</span>
</div>
</div>
<div class="test-cases">
${suite.tests.map(testCase => `
<div class="test-case">
<div class="name">${testCase.name}</div>
<div class="status ${testCase.status}">${testCase.status}</div>
<div class="duration">${testCase.duration}ms</div>
<div class="tags">
${testCase.tags.map(tag => `<span class="tag">${tag}</span>`).join('')}
</div>
</div>
`).join('')}
</div>
</div>
`).join('')}
</div>
<div class="footer">
<p>Generated by TestCoverageReporter</p>
</div>
</div>
</body>
</html>
`;
}
private generateMarkdownContent(): string {
const { totalTests, passedTests, failedTests, skippedTests, passRate, executionTime, testSuites, timestamp } = this.coverageData;
return `# 测试覆盖率报告
## 概要
- **生成时间**: ${timestamp}
- **执行时间**: ${(executionTime / 1000).toFixed(2)}
- **总测试数**: ${totalTests}
- **通过测试**: ${passedTests}
- **失败测试**: ${failedTests}
- **跳过测试**: ${skippedTests}
- **通过率**: ${passRate.toFixed(2)}%
## 测试套件详情
${testSuites.map(suite => `
### ${suite.name}
- **总测试数**: ${suite.totalTests}
- **通过测试**: ${suite.passedTests}
- **失败测试**: ${suite.failedTests}
- **跳过测试**: ${suite.skippedTests}
- **通过率**: ${suite.passRate.toFixed(2)}%
#### 测试用例
| 测试用例 | 状态 | 耗时 | 标签 |
|---------|------|------|------|
${suite.tests.map(testCase => `
| ${testCase.name} | ${testCase.status} | ${testCase.duration}ms | ${testCase.tags.join(', ')} |
`).join('')}
`).join('')}
## 总结
${passRate >= 80 ? '✅ 测试覆盖率优秀' : passRate >= 60 ? '⚠️ 测试覆盖率良好' : '❌ 测试覆盖率需要改进'}
---
*Generated by TestCoverageReporter*
`;
}
private generateConsoleReport(): void {
const { totalTests, passedTests, failedTests, skippedTests, passRate, executionTime } = this.coverageData;
console.log('\n' + '='.repeat(60));
console.log('📊 测试覆盖率报告');
console.log('='.repeat(60));
console.log(`生成时间: ${this.coverageData.timestamp}`);
console.log(`执行时间: ${(executionTime / 1000).toFixed(2)}`);
console.log('');
console.log('📈 总体统计:');
console.log(` 总测试数: ${totalTests}`);
console.log(` 通过测试: ${passedTests} (${(passedTests / totalTests * 100).toFixed(2)}%)`);
console.log(` 失败测试: ${failedTests} (${(failedTests / totalTests * 100).toFixed(2)}%)`);
console.log(` 跳过测试: ${skippedTests} (${(skippedTests / totalTests * 100).toFixed(2)}%)`);
console.log(` 通过率: ${passRate.toFixed(2)}%`);
console.log('');
console.log('📋 测试套件详情:');
for (const suite of this.coverageData.testSuites) {
console.log(`\n ${suite.name}:`);
console.log(` 总测试数: ${suite.totalTests}`);
console.log(` 通过测试: ${suite.passedTests} (${suite.passRate.toFixed(2)}%)`);
console.log(` 失败测试: ${suite.failedTests}`);
console.log(` 跳过测试: ${suite.skippedTests}`);
}
console.log('\n' + '='.repeat(60));
if (passRate >= 80) {
console.log('✅ 测试覆盖率优秀');
} else if (passRate >= 60) {
console.log('⚠️ 测试覆盖率良好');
} else {
console.log('❌ 测试覆盖率需要改进');
}
console.log('='.repeat(60) + '\n');
}
getCoverage(): TestCoverage {
return this.coverageData;
}
getSuiteCoverage(suiteName: string): TestSuiteCoverage | undefined {
return this.suiteResults.get(suiteName);
}
exportCoverage(format: 'json' | 'html' | 'markdown' = 'json'): string {
switch (format) {
case 'json':
return JSON.stringify(this.coverageData, null, 2);
case 'html':
return this.generateHTMLContent();
case 'markdown':
return this.generateMarkdownContent();
default:
return JSON.stringify(this.coverageData, null, 2);
}
}
}
export const testCoverageReporter = new TestCoverageReporter();