08ea5fbe98
添加用户管理视图、API和状态管理文件
407 lines
15 KiB
TypeScript
407 lines
15 KiB
TypeScript
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { TestResult, TestModule, TestSuite, TestCase, TestStatus, TDDIteration } from '../models/test-result';
|
|
import { logger } from '../utils/logger';
|
|
|
|
export interface ReportConfig {
|
|
outputDir: string;
|
|
formats: ('html' | 'json' | 'junit' | 'markdown')[];
|
|
includeScreenshots: boolean;
|
|
includeVideos: boolean;
|
|
}
|
|
|
|
export class ReportGenerator {
|
|
private config: ReportConfig;
|
|
|
|
constructor(config?: Partial<ReportConfig>) {
|
|
this.config = {
|
|
outputDir: config?.outputDir ?? 'test-results/reports',
|
|
formats: config?.formats ?? ['html', 'json', 'markdown'],
|
|
includeScreenshots: config?.includeScreenshots ?? true,
|
|
includeVideos: config?.includeVideos ?? true
|
|
};
|
|
this.ensureOutputDir();
|
|
}
|
|
|
|
private ensureOutputDir(): void {
|
|
try {
|
|
if (!fs.existsSync(this.config.outputDir)) {
|
|
fs.mkdirSync(this.config.outputDir, { recursive: true });
|
|
}
|
|
} catch (error) {
|
|
logger.error('创建报告目录失败', error);
|
|
}
|
|
}
|
|
|
|
async generate(result: TestResult, iterations?: TDDIteration[]): Promise<string[]> {
|
|
logger.section('生成测试报告');
|
|
|
|
this.ensureOutputDir();
|
|
|
|
const generatedFiles: string[] = [];
|
|
|
|
for (const format of this.config.formats) {
|
|
try {
|
|
const filePath = await this.generateFormat(result, format, iterations);
|
|
generatedFiles.push(filePath);
|
|
logger.info(`生成 ${format.toUpperCase()} 报告: ${filePath}`);
|
|
} catch (error) {
|
|
logger.error(`生成 ${format.toUpperCase()} 报告失败`, error);
|
|
}
|
|
}
|
|
|
|
return generatedFiles;
|
|
}
|
|
|
|
private async generateFormat(
|
|
result: TestResult,
|
|
format: 'html' | 'json' | 'junit' | 'markdown',
|
|
iterations?: TDDIteration[]
|
|
): Promise<string> {
|
|
switch (format) {
|
|
case 'html':
|
|
return this.generateHTMLReport(result, iterations);
|
|
case 'json':
|
|
return this.generateJSONReport(result, iterations);
|
|
case 'junit':
|
|
return this.generateJUnitReport(result);
|
|
case 'markdown':
|
|
return this.generateMarkdownReport(result, iterations);
|
|
default:
|
|
throw new Error(`不支持的报告格式: ${format}`);
|
|
}
|
|
}
|
|
|
|
private generateHTMLReport(result: TestResult, iterations?: TDDIteration[]): string {
|
|
const filePath = path.join(this.config.outputDir, 'test-report.html');
|
|
|
|
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>E2E 测试报告</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
|
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
|
.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; }
|
|
.header .timestamp { opacity: 0.8; font-size: 14px; }
|
|
.summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-bottom: 20px; }
|
|
.summary-card { background: white; padding: 20px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
|
.summary-card h3 { font-size: 14px; color: #666; margin-bottom: 10px; }
|
|
.summary-card .value { font-size: 32px; font-weight: bold; }
|
|
.summary-card.passed .value { color: #10b981; }
|
|
.summary-card.failed .value { color: #ef4444; }
|
|
.summary-card.skipped .value { color: #f59e0b; }
|
|
.summary-card.rate .value { color: #3b82f6; }
|
|
.progress-bar { height: 10px; background: #e5e7eb; border-radius: 5px; overflow: hidden; margin-top: 10px; }
|
|
.progress-bar .fill { height: 100%; transition: width 0.3s; }
|
|
.progress-bar .fill.passed { background: #10b981; }
|
|
.progress-bar .fill.failed { background: #ef4444; }
|
|
.modules { background: white; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); overflow: hidden; }
|
|
.module-header { padding: 15px 20px; background: #f9fafb; border-bottom: 1px solid #e5e7eb; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
|
|
.module-header:hover { background: #f3f4f6; }
|
|
.module-content { padding: 20px; display: none; }
|
|
.module-content.active { display: block; }
|
|
.suite { margin-bottom: 15px; border: 1px solid #e5e7eb; border-radius: 8px; }
|
|
.suite-header { padding: 10px 15px; background: #f9fafb; display: flex; justify-content: space-between; }
|
|
.test-case { padding: 10px 15px; border-top: 1px solid #e5e7eb; display: flex; justify-content: space-between; align-items: center; }
|
|
.test-case:hover { background: #f9fafb; }
|
|
.status-badge { padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: 500; }
|
|
.status-badge.passed { background: #d1fae5; color: #065f46; }
|
|
.status-badge.failed { background: #fee2e2; color: #991b1b; }
|
|
.status-badge.skipped { background: #fef3c7; color: #92400e; }
|
|
.error-details { margin-top: 10px; padding: 10px; background: #fef2f2; border-radius: 5px; font-family: monospace; font-size: 12px; white-space: pre-wrap; }
|
|
.iterations { margin-top: 20px; }
|
|
.iteration { background: white; border-radius: 10px; padding: 20px; margin-bottom: 15px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
|
.iteration h4 { margin-bottom: 10px; }
|
|
.footer { text-align: center; padding: 20px; color: #666; font-size: 14px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1>E2E 自动化测试报告</h1>
|
|
<div class="timestamp">生成时间: ${result.startTime.toLocaleString('zh-CN')}</div>
|
|
</div>
|
|
|
|
<div class="summary">
|
|
<div class="summary-card">
|
|
<h3>总测试数</h3>
|
|
<div class="value">${result.totalTests}</div>
|
|
</div>
|
|
<div class="summary-card passed">
|
|
<h3>通过</h3>
|
|
<div class="value">${result.passedTests}</div>
|
|
</div>
|
|
<div class="summary-card failed">
|
|
<h3>失败</h3>
|
|
<div class="value">${result.failedTests}</div>
|
|
</div>
|
|
<div class="summary-card skipped">
|
|
<h3>跳过</h3>
|
|
<div class="value">${result.skippedTests}</div>
|
|
</div>
|
|
<div class="summary-card rate">
|
|
<h3>通过率</h3>
|
|
<div class="value">${result.passRate.toFixed(1)}%</div>
|
|
<div class="progress-bar">
|
|
<div class="fill passed" style="width: ${result.passRate}%"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modules">
|
|
${result.modules.map(module => this.renderModule(module)).join('')}
|
|
</div>
|
|
|
|
${iterations && iterations.length > 0 ? this.renderIterations(iterations) : ''}
|
|
|
|
<div class="footer">
|
|
<p>测试执行时间: ${(result.duration / 1000).toFixed(2)} 秒</p>
|
|
<p>由 E2E 自动化测试框架生成</p>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
document.querySelectorAll('.module-header').forEach(header => {
|
|
header.addEventListener('click', () => {
|
|
const content = header.nextElementSibling;
|
|
content.classList.toggle('active');
|
|
});
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
|
|
fs.writeFileSync(filePath, html, 'utf-8');
|
|
return filePath;
|
|
}
|
|
|
|
private renderModule(module: TestModule): string {
|
|
return `
|
|
<div class="module">
|
|
<div class="module-header">
|
|
<span><strong>${module.name.toUpperCase()}</strong> 模块</span>
|
|
<span>${module.passedTests}/${module.totalTests} 通过 (${module.passRate.toFixed(1)}%)</span>
|
|
</div>
|
|
<div class="module-content">
|
|
${module.suites.map(suite => this.renderSuite(suite)).join('')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderSuite(suite: TestSuite): string {
|
|
return `
|
|
<div class="suite">
|
|
<div class="suite-header">
|
|
<span>${suite.name}</span>
|
|
<span class="status-badge ${suite.status}">${suite.status}</span>
|
|
</div>
|
|
${suite.tests.map(test => this.renderTestCase(test)).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderTestCase(test: TestCase): string {
|
|
return `
|
|
<div class="test-case">
|
|
<span>${test.name}</span>
|
|
<div>
|
|
<span class="status-badge ${test.status}">${test.status}</span>
|
|
<span style="margin-left: 10px; color: #666;">${(test.duration / 1000).toFixed(2)}s</span>
|
|
</div>
|
|
</div>
|
|
${test.error ? `<div class="error-details">${this.escapeHtml(test.error.message)}</div>` : ''}
|
|
`;
|
|
}
|
|
|
|
private renderIterations(iterations: TDDIteration[]): string {
|
|
return `
|
|
<div class="iterations">
|
|
<h3 style="margin-bottom: 15px;">TDD 迭代记录</h3>
|
|
${iterations.map((iter, index) => `
|
|
<div class="iteration">
|
|
<h4>迭代 ${index + 1}</h4>
|
|
<p>修复数量: ${iter.fixes.length}</p>
|
|
<p>通过率变化: ${iter.previousResult.passRate.toFixed(1)}% → ${iter.currentResult.passRate.toFixed(1)}%</p>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private escapeHtml(text: string): string {
|
|
return text
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
private generateJSONReport(result: TestResult, iterations?: TDDIteration[]): string {
|
|
const filePath = path.join(this.config.outputDir, 'test-report.json');
|
|
|
|
const report = {
|
|
summary: {
|
|
totalTests: result.totalTests,
|
|
passedTests: result.passedTests,
|
|
failedTests: result.failedTests,
|
|
skippedTests: result.skippedTests,
|
|
passRate: result.passRate,
|
|
duration: result.duration,
|
|
startTime: result.startTime,
|
|
endTime: result.endTime,
|
|
status: result.status
|
|
},
|
|
environment: result.environment,
|
|
modules: result.modules.map(module => ({
|
|
name: module.name,
|
|
totalTests: module.totalTests,
|
|
passedTests: module.passedTests,
|
|
failedTests: module.failedTests,
|
|
skippedTests: module.skippedTests,
|
|
passRate: module.passRate,
|
|
duration: module.duration,
|
|
suites: module.suites.map(suite => ({
|
|
name: suite.name,
|
|
status: suite.status,
|
|
duration: suite.duration,
|
|
passRate: suite.passRate,
|
|
tests: suite.tests.map(test => ({
|
|
id: test.id,
|
|
name: test.name,
|
|
status: test.status,
|
|
duration: test.duration,
|
|
error: test.error ? {
|
|
type: test.error.type,
|
|
message: test.error.message
|
|
} : null
|
|
}))
|
|
}))
|
|
})),
|
|
iterations: iterations?.map((iter, index) => ({
|
|
iteration: index + 1,
|
|
fixesCount: iter.fixes.length,
|
|
previousPassRate: iter.previousResult.passRate,
|
|
currentPassRate: iter.currentResult.passRate,
|
|
timestamp: iter.timestamp
|
|
}))
|
|
};
|
|
|
|
fs.writeFileSync(filePath, JSON.stringify(report, null, 2), 'utf-8');
|
|
return filePath;
|
|
}
|
|
|
|
private generateJUnitReport(result: TestResult): string {
|
|
const filePath = path.join(this.config.outputDir, 'junit-report.xml');
|
|
|
|
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
|
xml += `<testsuites tests="${result.totalTests}" failures="${result.failedTests}" skipped="${result.skippedTests}" time="${result.duration / 1000}">\n`;
|
|
|
|
for (const module of result.modules) {
|
|
for (const suite of module.suites) {
|
|
xml += ` <testsuite name="${this.escapeXml(suite.name)}" tests="${suite.tests.length}" failures="${suite.tests.filter(t => t.status === TestStatus.FAILED).length}" skipped="${suite.tests.filter(t => t.status === TestStatus.SKIPPED).length}" time="${suite.duration / 1000}">\n`;
|
|
|
|
for (const test of suite.tests) {
|
|
xml += ` <testcase name="${this.escapeXml(test.name)}" classname="${this.escapeXml(suite.name)}" time="${test.duration / 1000}"`;
|
|
|
|
if (test.status === TestStatus.SKIPPED) {
|
|
xml += '>\n <skipped/>\n </testcase>\n';
|
|
} else if (test.status === TestStatus.FAILED && test.error) {
|
|
xml += '>\n';
|
|
xml += ` <failure message="${this.escapeXml(test.error.message)}">\n`;
|
|
xml += ` ${this.escapeXml(test.error.stack || test.error.message)}\n`;
|
|
xml += ' </failure>\n';
|
|
xml += ' </testcase>\n';
|
|
} else {
|
|
xml += '/>\n';
|
|
}
|
|
}
|
|
|
|
xml += ' </testsuite>\n';
|
|
}
|
|
}
|
|
|
|
xml += '</testsuites>\n';
|
|
|
|
fs.writeFileSync(filePath, xml, 'utf-8');
|
|
return filePath;
|
|
}
|
|
|
|
private generateMarkdownReport(result: TestResult, iterations?: TDDIteration[]): string {
|
|
const filePath = path.join(this.config.outputDir, 'test-report.md');
|
|
|
|
let md = '# E2E 自动化测试报告\n\n';
|
|
md += `**生成时间**: ${result.startTime.toLocaleString('zh-CN')}\n\n`;
|
|
|
|
md += '## 测试概览\n\n';
|
|
md += '| 指标 | 数值 |\n';
|
|
md += '|------|------|\n';
|
|
md += `| 总测试数 | ${result.totalTests} |\n`;
|
|
md += `| 通过数 | ${result.passedTests} |\n`;
|
|
md += `| 失败数 | ${result.failedTests} |\n`;
|
|
md += `| 跳过数 | ${result.skippedTests} |\n`;
|
|
md += `| 通过率 | ${result.passRate.toFixed(2)}% |\n`;
|
|
md += `| 执行时间 | ${(result.duration / 1000).toFixed(2)}s |\n\n`;
|
|
|
|
md += '## 模块详情\n\n';
|
|
|
|
for (const module of result.modules) {
|
|
md += `### ${module.name.toUpperCase()} 模块\n\n`;
|
|
md += `- 通过率: ${module.passRate.toFixed(2)}%\n`;
|
|
md += `- 测试数: ${module.totalTests}\n`;
|
|
md += `- 通过: ${module.passedTests}\n`;
|
|
md += `- 失败: ${module.failedTests}\n\n`;
|
|
|
|
if (module.suites.length > 0) {
|
|
md += '| 测试用例 | 状态 | 耗时 |\n';
|
|
md += '|----------|------|------|\n';
|
|
|
|
for (const suite of module.suites) {
|
|
for (const test of suite.tests) {
|
|
const statusEmoji = test.status === TestStatus.PASSED ? '✅' :
|
|
test.status === TestStatus.FAILED ? '❌' : '⏭️';
|
|
md += `| ${test.name} | ${statusEmoji} ${test.status} | ${(test.duration / 1000).toFixed(2)}s |\n`;
|
|
}
|
|
}
|
|
md += '\n';
|
|
}
|
|
}
|
|
|
|
if (iterations && iterations.length > 0) {
|
|
md += '## TDD 迭代记录\n\n';
|
|
|
|
for (let i = 0; i < iterations.length; i++) {
|
|
const iter = iterations[i];
|
|
md += `### 迭代 ${i + 1}\n\n`;
|
|
md += `- 修复数量: ${iter.fixes.length}\n`;
|
|
md += `- 通过率变化: ${iter.previousResult.passRate.toFixed(2)}% → ${iter.currentResult.passRate.toFixed(2)}%\n\n`;
|
|
}
|
|
}
|
|
|
|
md += '## 环境信息\n\n';
|
|
md += `- Node.js: ${result.environment.nodeVersion}\n`;
|
|
md += `- 操作系统: ${result.environment.os}\n`;
|
|
md += `- API 地址: ${result.environment.apiBaseUrl}\n`;
|
|
md += `- Admin 地址: ${result.environment.adminBaseUrl}\n`;
|
|
md += `- Uniapp 地址: ${result.environment.uniappBaseUrl}\n`;
|
|
|
|
fs.writeFileSync(filePath, md, 'utf-8');
|
|
return filePath;
|
|
}
|
|
|
|
private escapeXml(text: string): string {
|
|
return text
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
}
|