feat(admin): 添加用户管理相关文件
添加用户管理视图、API和状态管理文件
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { ENVIRONMENTS } from '../constants';
|
||||
|
||||
export interface TestEnvironment {
|
||||
name: string;
|
||||
baseURL: string;
|
||||
mockEnabled: boolean;
|
||||
mockMode: 'full' | 'partial' | 'none';
|
||||
timeout: {
|
||||
default: number;
|
||||
navigation: number;
|
||||
element: number;
|
||||
network: number;
|
||||
};
|
||||
}
|
||||
|
||||
class TestConfig {
|
||||
private static instance: TestConfig;
|
||||
private currentEnvironment: string;
|
||||
private environments: Record<string, TestEnvironment>;
|
||||
|
||||
private constructor() {
|
||||
this.currentEnvironment = process.env.E2E_ENV || 'local';
|
||||
|
||||
this.environments = {
|
||||
local: {
|
||||
...ENVIRONMENTS.LOCAL,
|
||||
timeout: {
|
||||
default: 30000,
|
||||
navigation: 30000,
|
||||
element: 10000,
|
||||
network: 30000
|
||||
}
|
||||
},
|
||||
dev: {
|
||||
...ENVIRONMENTS.DEV,
|
||||
timeout: {
|
||||
default: 30000,
|
||||
navigation: 30000,
|
||||
element: 10000,
|
||||
network: 30000
|
||||
}
|
||||
},
|
||||
test: {
|
||||
...ENVIRONMENTS.TEST,
|
||||
timeout: {
|
||||
default: 30000,
|
||||
navigation: 30000,
|
||||
element: 10000,
|
||||
network: 30000
|
||||
}
|
||||
},
|
||||
prod: {
|
||||
...ENVIRONMENTS.PROD,
|
||||
timeout: {
|
||||
default: 30000,
|
||||
navigation: 30000,
|
||||
element: 10000,
|
||||
network: 30000
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static getInstance(): TestConfig {
|
||||
if (!TestConfig.instance) {
|
||||
TestConfig.instance = new TestConfig();
|
||||
}
|
||||
return TestConfig.instance;
|
||||
}
|
||||
|
||||
getEnvironment(): TestEnvironment {
|
||||
return this.environments[this.currentEnvironment];
|
||||
}
|
||||
|
||||
getBaseURL(): string {
|
||||
return this.environments[this.currentEnvironment].baseURL;
|
||||
}
|
||||
|
||||
isMockEnabled(): boolean {
|
||||
return this.environments[this.currentEnvironment].mockEnabled;
|
||||
}
|
||||
|
||||
getMockMode(): string {
|
||||
return this.environments[this.currentEnvironment].mockMode;
|
||||
}
|
||||
|
||||
getCurrentEnvironmentName(): string {
|
||||
return this.currentEnvironment;
|
||||
}
|
||||
|
||||
setEnvironment(envName: string): void {
|
||||
if (this.environments[envName]) {
|
||||
this.currentEnvironment = envName;
|
||||
} else {
|
||||
throw new Error(`Unknown environment: ${envName}`);
|
||||
}
|
||||
}
|
||||
|
||||
getTimeout(): TestEnvironment['timeout'] {
|
||||
return this.environments[this.currentEnvironment].timeout;
|
||||
}
|
||||
}
|
||||
|
||||
export const testConfig = TestConfig.getInstance();
|
||||
@@ -0,0 +1,210 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
export interface UserData {
|
||||
username: string;
|
||||
password: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
realName: string;
|
||||
status: 'active' | 'inactive' | 'locked';
|
||||
roleIds: number[];
|
||||
}
|
||||
|
||||
export interface RoleData {
|
||||
name: string;
|
||||
code: string;
|
||||
description: string;
|
||||
status: 'active' | 'inactive';
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface MenuData {
|
||||
name: string;
|
||||
code: string;
|
||||
path: string;
|
||||
icon: string;
|
||||
parentId: number;
|
||||
sortOrder: number;
|
||||
status: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
export interface PermissionData {
|
||||
name: string;
|
||||
code: string;
|
||||
description: string;
|
||||
type: 'menu' | 'button' | 'api';
|
||||
parentId: number;
|
||||
}
|
||||
|
||||
class TestDataGenerator {
|
||||
private static instance: TestDataGenerator;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): TestDataGenerator {
|
||||
if (!TestDataGenerator.instance) {
|
||||
TestDataGenerator.instance = new TestDataGenerator();
|
||||
}
|
||||
return TestDataGenerator.instance;
|
||||
}
|
||||
|
||||
randomString(length: number = 10): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
randomEmail(): string {
|
||||
const domains = ['example.com', 'test.com', 'demo.com'];
|
||||
const username = this.randomString(8).toLowerCase();
|
||||
const domain = domains[Math.floor(Math.random() * domains.length)];
|
||||
return `${username}@${domain}`;
|
||||
}
|
||||
|
||||
randomPhone(): string {
|
||||
const prefix = ['138', '139', '150', '186', '188'];
|
||||
const selectedPrefix = prefix[Math.floor(Math.random() * prefix.length)];
|
||||
const suffix = Math.floor(Math.random() * 100000000).toString().padStart(8, '0');
|
||||
return `${selectedPrefix}${suffix}`;
|
||||
}
|
||||
|
||||
randomPassword(length: number = 12): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
randomInt(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
randomBoolean(): boolean {
|
||||
return Math.random() < 0.5;
|
||||
}
|
||||
|
||||
randomDate(startYear: number = 2020, endYear: number = 2024): Date {
|
||||
const start = new Date(startYear, 0, 1);
|
||||
const end = new Date(endYear, 11, 31);
|
||||
return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
|
||||
}
|
||||
|
||||
randomItem<T>(items: T[]): T {
|
||||
return items[Math.floor(Math.random() * items.length)];
|
||||
}
|
||||
|
||||
randomItems<T>(items: T[], count: number): T[] {
|
||||
const shuffled = [...items].sort(() => Math.random() - 0.5);
|
||||
return shuffled.slice(0, Math.min(count, items.length));
|
||||
}
|
||||
|
||||
generateUserData(overrides: Partial<UserData> = {}): UserData {
|
||||
const username = overrides.username || `user_${this.randomString(6).toLowerCase()}`;
|
||||
|
||||
return {
|
||||
username,
|
||||
password: overrides.password || 'Admin@123',
|
||||
email: overrides.email || this.randomEmail(),
|
||||
phone: overrides.phone || this.randomPhone(),
|
||||
realName: overrides.realName || `测试用户${this.randomInt(1, 100)}`,
|
||||
status: overrides.status || this.randomItem(['active', 'inactive', 'locked']),
|
||||
roleIds: overrides.roleIds || [1]
|
||||
};
|
||||
}
|
||||
|
||||
generateRoleData(overrides: Partial<RoleData> = {}): RoleData {
|
||||
const code = overrides.code || `role_${this.randomString(6).toLowerCase()}`;
|
||||
|
||||
return {
|
||||
name: overrides.name || `测试角色${this.randomInt(1, 100)}`,
|
||||
code,
|
||||
description: overrides.description || `角色${code}的描述`,
|
||||
status: overrides.status || this.randomItem(['active', 'inactive']),
|
||||
permissions: overrides.permissions || ['dashboard:view']
|
||||
};
|
||||
}
|
||||
|
||||
generateMenuData(overrides: Partial<MenuData> = {}): MenuData {
|
||||
const code = overrides.code || `menu_${this.randomString(6).toLowerCase()}`;
|
||||
|
||||
return {
|
||||
name: overrides.name || `测试菜单${this.randomInt(1, 100)}`,
|
||||
code,
|
||||
path: overrides.path || `/${code}`,
|
||||
icon: overrides.icon || 'MenuOutlined',
|
||||
parentId: overrides.parentId || 0,
|
||||
sortOrder: overrides.sortOrder || this.randomInt(1, 100),
|
||||
status: overrides.status || this.randomItem(['active', 'inactive'])
|
||||
};
|
||||
}
|
||||
|
||||
generatePermissionData(overrides: Partial<PermissionData> = {}): PermissionData {
|
||||
const code = overrides.code || `perm_${this.randomString(6).toLowerCase()}`;
|
||||
|
||||
return {
|
||||
name: overrides.name || `测试权限${this.randomInt(1, 100)}`,
|
||||
code,
|
||||
description: overrides.description || `权限${code}的描述`,
|
||||
type: overrides.type || this.randomItem(['menu', 'button', 'api']),
|
||||
parentId: overrides.parentId || 0
|
||||
};
|
||||
}
|
||||
|
||||
generateUserList(count: number): UserData[] {
|
||||
return Array.from({ length: count }, () => this.generateUserData());
|
||||
}
|
||||
|
||||
generateRoleList(count: number): RoleData[] {
|
||||
return Array.from({ length: count }, () => this.generateRoleData());
|
||||
}
|
||||
|
||||
generateMenuList(count: number): MenuData[] {
|
||||
return Array.from({ length: count }, () => this.generateMenuData());
|
||||
}
|
||||
|
||||
generatePermissionList(count: number): PermissionData[] {
|
||||
return Array.from({ length: count }, () => this.generatePermissionData());
|
||||
}
|
||||
|
||||
generatePaginationData<T>(data: T[], page: number = 1, pageSize: number = 10) {
|
||||
const start = (page - 1) * pageSize;
|
||||
const end = start + pageSize;
|
||||
|
||||
return {
|
||||
records: data.slice(start, end),
|
||||
total: data.length,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(data.length / pageSize)
|
||||
};
|
||||
}
|
||||
|
||||
generateSearchQuery(keyword: string): Record<string, any> {
|
||||
return {
|
||||
keyword,
|
||||
page: 1,
|
||||
pageSize: 10
|
||||
};
|
||||
}
|
||||
|
||||
generateFormData(fields: Record<string, any>): Record<string, any> {
|
||||
const formData: Record<string, any> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (typeof value === 'function') {
|
||||
formData[key] = value();
|
||||
} else {
|
||||
formData[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return formData;
|
||||
}
|
||||
}
|
||||
|
||||
export const testDataGenerator = TestDataGenerator.getInstance();
|
||||
@@ -0,0 +1,86 @@
|
||||
export enum LogLevel {
|
||||
INFO = 'INFO',
|
||||
WARN = 'WARN',
|
||||
ERROR = 'ERROR',
|
||||
DEBUG = 'DEBUG',
|
||||
SUCCESS = 'SUCCESS',
|
||||
FAILURE = 'FAILURE'
|
||||
}
|
||||
|
||||
export interface TestLog {
|
||||
testName: string;
|
||||
status: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
duration: number;
|
||||
steps: Array<{
|
||||
name: string;
|
||||
status: string;
|
||||
duration: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class TestLogger {
|
||||
private prefix: string
|
||||
private logs: Array<{ level: LogLevel; message: string; timestamp: string; test?: string }> = []
|
||||
private testLogs: TestLog[] = []
|
||||
private currentTest: TestLog | null = null
|
||||
|
||||
constructor(prefix: string = 'Test') {
|
||||
this.prefix = prefix
|
||||
}
|
||||
|
||||
info(message: string, ...args: any[]) {
|
||||
console.log(`[${this.prefix}] INFO:`, message, ...args)
|
||||
this.logs.push({ level: LogLevel.INFO, message: JSON.stringify({ message, args }), timestamp: new Date().toISOString() })
|
||||
}
|
||||
|
||||
warn(message: string, ...args: any[]) {
|
||||
console.warn(`[${this.prefix}] WARN:`, message, ...args)
|
||||
this.logs.push({ level: LogLevel.WARN, message: JSON.stringify({ message, args }), timestamp: new Date().toISOString() })
|
||||
}
|
||||
|
||||
error(message: string, ...args: any[]) {
|
||||
console.error(`[${this.prefix}] ERROR:`, message, ...args)
|
||||
this.logs.push({ level: LogLevel.ERROR, message: JSON.stringify({ message, args }), timestamp: new Date().toISOString() })
|
||||
}
|
||||
|
||||
debug(message: string, ...args: any[]) {
|
||||
console.debug(`[${this.prefix}] DEBUG:`, message, ...args)
|
||||
this.logs.push({ level: LogLevel.DEBUG, message: JSON.stringify({ message, args }), timestamp: new Date().toISOString() })
|
||||
}
|
||||
|
||||
step(stepName: string) {
|
||||
console.log(`[${this.prefix}] STEP: ${stepName}`)
|
||||
}
|
||||
|
||||
success(message: string, ...args: any[]) {
|
||||
console.log(`[${this.prefix}] ✅ SUCCESS:`, message, ...args)
|
||||
this.logs.push({ level: LogLevel.SUCCESS, message: JSON.stringify({ message, args }), timestamp: new Date().toISOString() })
|
||||
}
|
||||
|
||||
failure(message: string, ...args: any[]) {
|
||||
console.error(`[${this.prefix}] ❌ FAILURE:`, message, ...args)
|
||||
this.logs.push({ level: LogLevel.FAILURE, message: JSON.stringify({ message, args }), timestamp: new Date().toISOString() })
|
||||
}
|
||||
|
||||
startStep(stepName: string) {
|
||||
console.log(`[${this.prefix}] START STEP: ${stepName}`)
|
||||
}
|
||||
|
||||
endStep(stepName: string, status: string, error?: Error) {
|
||||
console.log(`[${this.prefix}] END STEP: ${stepName} - ${status}`)
|
||||
}
|
||||
|
||||
getAllTestLogs(): TestLog[] {
|
||||
return this.testLogs
|
||||
}
|
||||
|
||||
getLogsByLevel(level: LogLevel): Array<{ level: LogLevel; message: string; timestamp: string; test?: string }> {
|
||||
return this.logs.filter(log => log.level === level)
|
||||
}
|
||||
}
|
||||
|
||||
export const testLogger = new TestLogger('E2E')
|
||||
|
||||
export default TestLogger
|
||||
@@ -0,0 +1,593 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user