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

594 lines
14 KiB
TypeScript

import { FullResult } from '@playwright/test';
import { testLogger, TestLog, LogLevel } from './test-logger';
import { testConfig } from './test-config';
import * as fs from 'fs/promises';
import * as path from 'path';
export interface TestSummary {
total: number;
passed: number;
failed: number;
skipped: number;
duration: number;
startTime: string;
endTime: string;
}
export interface TestReport {
summary: TestSummary;
testLogs: TestLog[];
environment: {
name: string;
baseURL: string;
mockEnabled: boolean;
mockMode: string;
};
errors: Array<{
testName: string;
error: Error;
timestamp: string;
}>;
screenshots: string[];
}
class TestReporter {
private static instance: TestReporter;
private report: TestReport;
private startTime: string = '';
private constructor() {
this.report = this.initializeReport();
}
static getInstance(): TestReporter {
if (!TestReporter.instance) {
TestReporter.instance = new TestReporter();
}
return TestReporter.instance;
}
private initializeReport(): TestReport {
return {
summary: {
total: 0,
passed: 0,
failed: 0,
skipped: 0,
duration: 0,
startTime: new Date().toISOString(),
endTime: ''
},
testLogs: [],
environment: {
name: testConfig.getEnvironment().name,
baseURL: testConfig.getBaseURL(),
mockEnabled: testConfig.isMockEnabled(),
mockMode: testConfig.getMockMode()
},
errors: [],
screenshots: []
};
}
startReport(): void {
this.startTime = new Date().toISOString();
this.report.summary.startTime = this.startTime;
testLogger.info('开始生成测试报告');
}
endReport(): void {
const endTime = new Date().toISOString();
this.report.summary.endTime = endTime;
this.report.summary.duration = new Date(endTime).getTime() - new Date(this.startTime).getTime();
this.report.testLogs = testLogger.getAllTestLogs();
const errorLogs = testLogger.getLogsByLevel(LogLevel.ERROR);
this.report.errors = errorLogs.map(log => ({
testName: log.test || 'unknown',
error: new Error(log.message),
timestamp: log.timestamp
}));
testLogger.info('测试报告生成完成', {
total: this.report.summary.total,
passed: this.report.summary.passed,
failed: this.report.summary.failed,
skipped: this.report.summary.skipped,
duration: this.report.summary.duration
});
}
updateSummary(results: FullResult): void {
this.report.summary.total = results.expected;
this.report.summary.passed = results.expected - results.failed - results.skipped;
this.report.summary.failed = results.failed;
this.report.summary.skipped = results.skipped;
}
addScreenshot(screenshotPath: string): void {
this.report.screenshots.push(screenshotPath);
}
getReport(): TestReport {
return this.report;
}
getSummary(): TestSummary {
return this.report.summary;
}
async generateJSONReport(outputPath: string): Promise<void> {
const dir = path.dirname(outputPath);
await fs.mkdir(dir, { recursive: true });
const jsonContent = JSON.stringify(this.report, null, 2);
await fs.writeFile(outputPath, jsonContent, 'utf-8');
testLogger.info(`JSON报告已生成: ${outputPath}`);
}
async generateHTMLReport(outputPath: string): Promise<void> {
const dir = path.dirname(outputPath);
await fs.mkdir(dir, { recursive: true });
const htmlContent = this.generateHTMLContent();
await fs.writeFile(outputPath, htmlContent, 'utf-8');
testLogger.info(`HTML报告已生成: ${outputPath}`);
}
private generateHTMLContent(): string {
const { summary, testLogs, environment, errors, screenshots } = this.report;
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', 'Helvetica', 'Arial', sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
}
.header h1 {
font-size: 28px;
margin-bottom: 10px;
}
.header .meta {
font-size: 14px;
opacity: 0.9;
}
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
padding: 30px;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
}
.summary-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.summary-card h3 {
font-size: 14px;
color: #6b7280;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.summary-card .value {
font-size: 32px;
font-weight: bold;
color: #111827;
}
.summary-card.passed .value {
color: #10b981;
}
.summary-card.failed .value {
color: #ef4444;
}
.summary-card.skipped .value {
color: #f59e0b;
}
.environment {
padding: 20px 30px;
border-bottom: 1px solid #e5e7eb;
}
.environment h2 {
font-size: 18px;
margin-bottom: 15px;
color: #111827;
}
.environment-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}
.environment-item {
display: flex;
flex-direction: column;
}
.environment-item label {
font-size: 12px;
color: #6b7280;
margin-bottom: 5px;
}
.environment-item span {
font-size: 14px;
color: #111827;
font-weight: 500;
}
.test-results {
padding: 30px;
}
.test-results h2 {
font-size: 18px;
margin-bottom: 20px;
color: #111827;
}
.test-item {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
margin-bottom: 15px;
overflow: hidden;
}
.test-header {
padding: 15px 20px;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
}
.test-header .name {
font-weight: 600;
color: #111827;
}
.test-header .status {
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.test-header .status.passed {
background: #d1fae5;
color: #065f46;
}
.test-header .status.failed {
background: #fee2e2;
color: #991b1b;
}
.test-header .status.skipped {
background: #fef3c7;
color: #92400e;
}
.test-body {
padding: 20px;
}
.test-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
margin-bottom: 15px;
font-size: 13px;
color: #6b7280;
}
.test-steps {
margin-top: 15px;
}
.test-steps h4 {
font-size: 14px;
margin-bottom: 10px;
color: #111827;
}
.step-item {
padding: 10px;
margin-bottom: 8px;
background: #f9fafb;
border-radius: 4px;
border-left: 3px solid #d1d5db;
}
.step-item.passed {
border-left-color: #10b981;
}
.step-item.failed {
border-left-color: #ef4444;
}
.step-item.skipped {
border-left-color: #f59e0b;
}
.step-item .name {
font-weight: 500;
margin-bottom: 5px;
}
.step-item .duration {
font-size: 12px;
color: #6b7280;
}
.errors {
padding: 30px;
background: #fef2f2;
}
.errors h2 {
font-size: 18px;
margin-bottom: 20px;
color: #991b1b;
}
.error-item {
background: white;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
}
.error-item .test-name {
font-weight: 600;
color: #991b1b;
margin-bottom: 10px;
}
.error-item .message {
font-family: 'Courier New', monospace;
font-size: 13px;
color: #7f1d1d;
background: #fef2f2;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
}
.error-item .timestamp {
font-size: 12px;
color: #6b7280;
margin-top: 10px;
}
.screenshots {
padding: 30px;
}
.screenshots h2 {
font-size: 18px;
margin-bottom: 20px;
color: #111827;
}
.screenshot-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
}
.screenshot-item {
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
}
.screenshot-item img {
width: 100%;
height: auto;
display: block;
}
.screenshot-item .path {
padding: 10px;
font-size: 12px;
color: #6b7280;
background: #f9fafb;
word-break: break-all;
}
.footer {
padding: 20px 30px;
background: #f9fafb;
border-top: 1px solid #e5e7eb;
text-align: center;
font-size: 13px;
color: #6b7280;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>E2E测试报告</h1>
<div class="meta">
生成时间: ${new Date().toLocaleString('zh-CN')} |
测试环境: ${environment.name} |
Mock模式: ${environment.mockMode}
</div>
</div>
<div class="summary">
<div class="summary-card">
<h3>总测试数</h3>
<div class="value">${summary.total}</div>
</div>
<div class="summary-card passed">
<h3>通过</h3>
<div class="value">${summary.passed}</div>
</div>
<div class="summary-card failed">
<h3>失败</h3>
<div class="value">${summary.failed}</div>
</div>
<div class="summary-card skipped">
<h3>跳过</h3>
<div class="value">${summary.skipped}</div>
</div>
<div class="summary-card">
<h3>总耗时</h3>
<div class="value">${(summary.duration / 1000).toFixed(2)}s</div>
</div>
</div>
<div class="environment">
<h2>测试环境</h2>
<div class="environment-info">
<div class="environment-item">
<label>环境名称</label>
<span>${environment.name}</span>
</div>
<div class="environment-item">
<label>基础URL</label>
<span>${environment.baseURL}</span>
</div>
<div class="environment-item">
<label>Mock启用</label>
<span>${environment.mockEnabled ? '是' : '否'}</span>
</div>
<div class="environment-item">
<label>Mock模式</label>
<span>${environment.mockMode}</span>
</div>
</div>
</div>
<div class="test-results">
<h2>测试结果</h2>
${testLogs.map(log => `
<div class="test-item">
<div class="test-header">
<span class="name">${log.testName}</span>
<span class="status ${log.status}">${log.status}</span>
</div>
<div class="test-body">
<div class="test-meta">
<div>开始时间: ${new Date(log.startTime).toLocaleString('zh-CN')}</div>
<div>结束时间: ${new Date(log.endTime).toLocaleString('zh-CN')}</div>
<div>耗时: ${(log.duration / 1000).toFixed(2)}s</div>
</div>
${log.steps.length > 0 ? `
<div class="test-steps">
<h4>测试步骤</h4>
${log.steps.map(step => `
<div class="step-item ${step.status}">
<div class="name">${step.name}</div>
<div class="duration">耗时: ${(step.duration / 1000).toFixed(2)}s</div>
</div>
`).join('')}
</div>
` : ''}
</div>
</div>
`).join('')}
</div>
${errors.length > 0 ? `
<div class="errors">
<h2>错误详情 (${errors.length})</h2>
${errors.map(error => `
<div class="error-item">
<div class="test-name">${error.testName}</div>
<div class="message">${error.error.message}</div>
<div class="timestamp">${new Date(error.timestamp).toLocaleString('zh-CN')}</div>
</div>
`).join('')}
</div>
` : ''}
${screenshots.length > 0 ? `
<div class="screenshots">
<h2>截图 (${screenshots.length})</h2>
<div class="screenshot-grid">
${screenshots.map(screenshot => `
<div class="screenshot-item">
<img src="${screenshot}" alt="Screenshot">
<div class="path">${screenshot}</div>
</div>
`).join('')}
</div>
</div>
` : ''}
<div class="footer">
E2E测试报告 - 由Playwright生成
</div>
</div>
</body>
</html>`;
}
async generateAllReports(outputDir: string): Promise<void> {
await fs.mkdir(outputDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
await this.generateJSONReport(path.join(outputDir, `e2e-report-${timestamp}.json`));
await this.generateHTMLReport(path.join(outputDir, `e2e-report-${timestamp}.html`));
testLogger.info(`所有报告已生成到目录: ${outputDir}`);
}
}
export const testReporter = TestReporter.getInstance();