feat(admin): 添加用户管理相关文件
添加用户管理视图、API和状态管理文件
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
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, ''');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user