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

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
}