Files
张翔 08ea5fbe98 feat(admin): 添加用户管理相关文件
添加用户管理视图、API和状态管理文件
2026-03-28 14:37:29 +08:00

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
}
export const testReporter = new TestReporter();