08ea5fbe98
添加用户管理视图、API和状态管理文件
388 lines
11 KiB
TypeScript
388 lines
11 KiB
TypeScript
/**
|
|
* 测试报告生成器
|
|
* 生成多种格式的测试报告:HTML、JSON、JUnit XML、Markdown
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { TestResult, TestStep } from './test-logger';
|
|
|
|
export interface TestSuite {
|
|
name: string;
|
|
tests: TestResult[];
|
|
startTime?: string;
|
|
endTime?: string;
|
|
}
|
|
|
|
export interface ReportSummary {
|
|
totalTests: number;
|
|
passed: number;
|
|
failed: number;
|
|
skipped: number;
|
|
totalDuration: number;
|
|
passRate: number;
|
|
startTime: string;
|
|
endTime: string;
|
|
}
|
|
|
|
class TestReporter {
|
|
private suites: TestSuite[] = [];
|
|
private startTime: string = '';
|
|
private endTime: string = '';
|
|
|
|
startReport(): void {
|
|
this.startTime = new Date().toISOString();
|
|
this.suites = [];
|
|
}
|
|
|
|
endReport(): void {
|
|
this.endTime = new Date().toISOString();
|
|
}
|
|
|
|
addTestSuite(suite: TestSuite): void {
|
|
this.suites.push(suite);
|
|
}
|
|
|
|
recordTestResult(test: TestResult): void {
|
|
// 查找或创建测试套件
|
|
let suite = this.suites.find(s => s.name === 'Default Suite');
|
|
if (!suite) {
|
|
suite = { name: 'Default Suite', tests: [] };
|
|
this.suites.push(suite);
|
|
}
|
|
suite.tests.push(test);
|
|
}
|
|
|
|
generateSummary(): ReportSummary {
|
|
const allTests = this.suites.flatMap(s => s.tests);
|
|
const passed = allTests.filter(t => t.status === 'passed').length;
|
|
const failed = allTests.filter(t => t.status === 'failed').length;
|
|
const skipped = allTests.filter(t => t.status === 'skipped').length;
|
|
const totalDuration = allTests.reduce((sum, t) => sum + t.duration, 0);
|
|
|
|
return {
|
|
totalTests: allTests.length,
|
|
passed,
|
|
failed,
|
|
skipped,
|
|
totalDuration,
|
|
passRate: allTests.length > 0 ? (passed / allTests.length) * 100 : 0,
|
|
startTime: this.startTime,
|
|
endTime: this.endTime,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 生成JSON格式报告
|
|
*/
|
|
generateJSONReport(outputPath: string): void {
|
|
const report = {
|
|
summary: this.generateSummary(),
|
|
suites: this.suites,
|
|
generatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
this.ensureDirectoryExists(path.dirname(outputPath));
|
|
fs.writeFileSync(outputPath, JSON.stringify(report, null, 2), 'utf-8');
|
|
console.log(`JSON报告已生成: ${outputPath}`);
|
|
}
|
|
|
|
/**
|
|
* 生成HTML格式报告
|
|
*/
|
|
generateHTMLReport(outputPath: string): void {
|
|
const summary = this.generateSummary();
|
|
const html = this.buildHTMLReport(summary);
|
|
|
|
this.ensureDirectoryExists(path.dirname(outputPath));
|
|
fs.writeFileSync(outputPath, html, 'utf-8');
|
|
console.log(`HTML报告已生成: ${outputPath}`);
|
|
}
|
|
|
|
/**
|
|
* 生成JUnit XML格式报告
|
|
*/
|
|
generateJUnitReport(outputPath: string): void {
|
|
const summary = this.generateSummary();
|
|
const xml = this.buildJUnitReport(summary);
|
|
|
|
this.ensureDirectoryExists(path.dirname(outputPath));
|
|
fs.writeFileSync(outputPath, xml, 'utf-8');
|
|
console.log(`JUnit报告已生成: ${outputPath}`);
|
|
}
|
|
|
|
/**
|
|
* 生成Markdown格式报告
|
|
*/
|
|
generateMarkdownReport(outputPath: string): void {
|
|
const summary = this.generateSummary();
|
|
const markdown = this.buildMarkdownReport(summary);
|
|
|
|
this.ensureDirectoryExists(path.dirname(outputPath));
|
|
fs.writeFileSync(outputPath, markdown, 'utf-8');
|
|
console.log(`Markdown报告已生成: ${outputPath}`);
|
|
}
|
|
|
|
/**
|
|
* 生成所有报告
|
|
*/
|
|
generateAllReports(outputDir: string): void {
|
|
this.endReport();
|
|
|
|
this.generateJSONReport(path.join(outputDir, 'e2e-report.json'));
|
|
this.generateHTMLReport(path.join(outputDir, 'e2e-report.html'));
|
|
this.generateJUnitReport(path.join(outputDir, 'junit-report.xml'));
|
|
this.generateMarkdownReport(path.join(outputDir, 'e2e-report.md'));
|
|
|
|
// 打印摘要
|
|
this.printSummary();
|
|
}
|
|
|
|
private printSummary(): void {
|
|
const summary = this.generateSummary();
|
|
console.log('\n========== 测试执行摘要 ==========');
|
|
console.log(`总测试数: ${summary.totalTests}`);
|
|
console.log(`通过: ${summary.passed} ✅`);
|
|
console.log(`失败: ${summary.failed} ❌`);
|
|
console.log(`跳过: ${summary.skipped} ⏭️`);
|
|
console.log(`通过率: ${summary.passRate.toFixed(2)}%`);
|
|
console.log(`总耗时: ${(summary.totalDuration / 1000).toFixed(2)}s`);
|
|
console.log('===================================\n');
|
|
}
|
|
|
|
private ensureDirectoryExists(dirPath: string): void {
|
|
if (!fs.existsSync(dirPath)) {
|
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
}
|
|
}
|
|
|
|
private buildHTMLReport(summary: ReportSummary): string {
|
|
const allTests = this.suites.flatMap(s => s.tests);
|
|
const statusColor = {
|
|
passed: '#28a745',
|
|
failed: '#dc3545',
|
|
skipped: '#ffc107',
|
|
};
|
|
|
|
return `<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>E2E测试报告</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: #f5f5f5;
|
|
padding: 20px;
|
|
}
|
|
.container { max-width: 1200px; margin: 0 auto; }
|
|
.header {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
padding: 30px;
|
|
border-radius: 10px;
|
|
margin-bottom: 20px;
|
|
}
|
|
.header h1 { font-size: 28px; margin-bottom: 10px; }
|
|
.summary-cards {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 15px;
|
|
margin-bottom: 20px;
|
|
}
|
|
.card {
|
|
background: white;
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
}
|
|
.card h3 { font-size: 14px; color: #666; margin-bottom: 8px; }
|
|
.card .value { font-size: 32px; font-weight: bold; }
|
|
.card.passed .value { color: #28a745; }
|
|
.card.failed .value { color: #dc3545; }
|
|
.card.skipped .value { color: #ffc107; }
|
|
.progress-bar {
|
|
background: #e9ecef;
|
|
height: 20px;
|
|
border-radius: 10px;
|
|
overflow: hidden;
|
|
margin-top: 10px;
|
|
}
|
|
.progress-fill {
|
|
background: linear-gradient(90deg, #28a745 0%, #20c997 100%);
|
|
height: 100%;
|
|
transition: width 0.3s ease;
|
|
}
|
|
.test-list {
|
|
background: white;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
overflow: hidden;
|
|
}
|
|
.test-list-header {
|
|
background: #f8f9fa;
|
|
padding: 15px 20px;
|
|
font-weight: bold;
|
|
border-bottom: 1px solid #dee2e6;
|
|
}
|
|
.test-item {
|
|
padding: 15px 20px;
|
|
border-bottom: 1px solid #dee2e6;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.test-item:last-child { border-bottom: none; }
|
|
.test-name { font-weight: 500; }
|
|
.test-status {
|
|
padding: 4px 12px;
|
|
border-radius: 20px;
|
|
font-size: 12px;
|
|
font-weight: bold;
|
|
text-transform: uppercase;
|
|
}
|
|
.test-status.passed { background: #d4edda; color: #155724; }
|
|
.test-status.failed { background: #f8d7da; color: #721c24; }
|
|
.test-status.skipped { background: #fff3cd; color: #856404; }
|
|
.test-duration { color: #666; font-size: 14px; margin-left: 10px; }
|
|
.footer {
|
|
text-align: center;
|
|
padding: 20px;
|
|
color: #666;
|
|
font-size: 14px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1>E2E测试报告</h1>
|
|
<p>生成时间: ${new Date().toLocaleString('zh-CN')}</p>
|
|
</div>
|
|
|
|
<div class="summary-cards">
|
|
<div class="card">
|
|
<h3>总测试数</h3>
|
|
<div class="value">${summary.totalTests}</div>
|
|
</div>
|
|
<div class="card passed">
|
|
<h3>通过</h3>
|
|
<div class="value">${summary.passed}</div>
|
|
</div>
|
|
<div class="card failed">
|
|
<h3>失败</h3>
|
|
<div class="value">${summary.failed}</div>
|
|
</div>
|
|
<div class="card skipped">
|
|
<h3>跳过</h3>
|
|
<div class="value">${summary.skipped}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card" style="margin-bottom: 20px;">
|
|
<h3>通过率: ${summary.passRate.toFixed(2)}%</h3>
|
|
<div class="progress-bar">
|
|
<div class="progress-fill" style="width: ${summary.passRate}%"></div>
|
|
</div>
|
|
<p style="margin-top: 10px; color: #666;">
|
|
总耗时: ${(summary.totalDuration / 1000).toFixed(2)}秒
|
|
</p>
|
|
</div>
|
|
|
|
<div class="test-list">
|
|
<div class="test-list-header">测试详情</div>
|
|
${allTests.map(test => `
|
|
<div class="test-item">
|
|
<div>
|
|
<span class="test-name">${test.testName}</span>
|
|
<span class="test-duration">${(test.duration / 1000).toFixed(2)}s</span>
|
|
</div>
|
|
<span class="test-status ${test.status}">${test.status}</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
|
|
<div class="footer">
|
|
<p>由 Playwright E2E 测试框架生成</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
private buildJUnitReport(summary: ReportSummary): string {
|
|
const allTests = this.suites.flatMap(s => s.tests);
|
|
const failures = allTests.filter(t => t.status === 'failed').length;
|
|
|
|
let xml = `<?xml version="1.0" encoding="UTF-8"?>\n`;
|
|
xml += `<testsuites name="E2E Tests" tests="${summary.totalTests}" failures="${failures}" skipped="${summary.skipped}" time="${summary.totalDuration / 1000}">\n`;
|
|
|
|
this.suites.forEach(suite => {
|
|
xml += ` <testsuite name="${this.escapeXml(suite.name)}" tests="${suite.tests.length}" failures="${suite.tests.filter(t => t.status === 'failed').length}">\n`;
|
|
|
|
suite.tests.forEach(test => {
|
|
xml += ` <testcase name="${this.escapeXml(test.testName)}" time="${test.duration / 1000}">\n`;
|
|
|
|
if (test.status === 'failed' && test.error) {
|
|
xml += ` <failure message="${this.escapeXml(test.error.message)}">\n`;
|
|
xml += ` ${this.escapeXml(test.error.stack || '')}\n`;
|
|
xml += ` </failure>\n`;
|
|
} else if (test.status === 'skipped') {
|
|
xml += ` <skipped/>\n`;
|
|
}
|
|
|
|
xml += ` </testcase>\n`;
|
|
});
|
|
|
|
xml += ` </testsuite>\n`;
|
|
});
|
|
|
|
xml += `</testsuites>`;
|
|
return xml;
|
|
}
|
|
|
|
private buildMarkdownReport(summary: ReportSummary): string {
|
|
const allTests = this.suites.flatMap(s => s.tests);
|
|
|
|
let md = `# E2E测试报告\n\n`;
|
|
md += `**生成时间**: ${new Date().toLocaleString('zh-CN')}\n\n`;
|
|
|
|
md += `## 执行摘要\n\n`;
|
|
md += `| 指标 | 数值 |\n`;
|
|
md += `|------|------|\n`;
|
|
md += `| 总测试数 | ${summary.totalTests} |\n`;
|
|
md += `| 通过 | ${summary.passed} ✅ |\n`;
|
|
md += `| 失败 | ${summary.failed} ❌ |\n`;
|
|
md += `| 跳过 | ${summary.skipped} ⏭️ |\n`;
|
|
md += `| 通过率 | ${summary.passRate.toFixed(2)}% |\n`;
|
|
md += `| 总耗时 | ${(summary.totalDuration / 1000).toFixed(2)}秒 |\n\n`;
|
|
|
|
md += `## 测试详情\n\n`;
|
|
md += `| 测试名称 | 状态 | 耗时 |\n`;
|
|
md += `|----------|------|------|\n`;
|
|
|
|
allTests.forEach(test => {
|
|
const statusIcon = test.status === 'passed' ? '✅' : test.status === 'failed' ? '❌' : '⏭️';
|
|
md += `| ${test.testName} | ${statusIcon} ${test.status} | ${(test.duration / 1000).toFixed(2)}s |\n`;
|
|
});
|
|
|
|
md += `\n---\n\n`;
|
|
md += `*由 Playwright E2E 测试框架生成*\n`;
|
|
|
|
return md;
|
|
}
|
|
|
|
private escapeXml(str: string): string {
|
|
return str
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
}
|
|
|
|
export const testReporter = new TestReporter();
|