feat(admin): 添加用户管理相关文件
添加用户管理视图、API和状态管理文件
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 全局测试设置
|
||||
* 在所有测试开始前执行
|
||||
*/
|
||||
|
||||
import { FullConfig } from '@playwright/test';
|
||||
import { testConfig } from './test-config';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
console.log('\n========== E2E测试开始 ==========');
|
||||
console.log(`环境: ${testConfig.getEnvironmentName()}`);
|
||||
console.log(`Admin URL: ${testConfig.getConfig().baseURL}`);
|
||||
console.log(`Uniapp URL: ${testConfig.getConfig().uniappBaseURL}`);
|
||||
console.log(`Mock模式: ${testConfig.isMockEnabled() ? '开启' : '关闭'}`);
|
||||
console.log('================================\n');
|
||||
|
||||
// 创建测试目录
|
||||
const dirs = [
|
||||
'test-results',
|
||||
'test-results/screenshots',
|
||||
'test-results/videos',
|
||||
'test-results/traces',
|
||||
'test-results/integration',
|
||||
];
|
||||
|
||||
dirs.forEach(dir => {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
// 清理旧的测试结果
|
||||
try {
|
||||
const files = fs.readdirSync('test-results/artifacts');
|
||||
files.forEach(file => {
|
||||
fs.unlinkSync(path.join('test-results/artifacts', file));
|
||||
});
|
||||
} catch (e) {
|
||||
// 忽略清理错误
|
||||
}
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 全局测试清理
|
||||
* 在所有测试结束后执行
|
||||
*/
|
||||
|
||||
import { FullConfig } from '@playwright/test';
|
||||
|
||||
async function globalTeardown(config: FullConfig) {
|
||||
console.log('\n========== E2E测试结束 ==========');
|
||||
|
||||
// 打印报告位置
|
||||
console.log('\n测试报告位置:');
|
||||
console.log(' - HTML报告: test-results/html-report/index.html');
|
||||
console.log(' - JSON报告: test-results/e2e-results.json');
|
||||
console.log(' - JUnit报告: test-results/junit-report.xml');
|
||||
console.log('\n查看HTML报告命令:');
|
||||
console.log(' npx playwright show-report test-results/html-report');
|
||||
console.log('================================\n');
|
||||
}
|
||||
|
||||
export default globalTeardown;
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 统一测试配置管理
|
||||
* 支持多环境配置:local、dev、test、prod
|
||||
*/
|
||||
|
||||
export interface EnvironmentConfig {
|
||||
name: string;
|
||||
baseURL: string;
|
||||
apiBaseURL: string;
|
||||
uniappBaseURL: string;
|
||||
timeout: {
|
||||
default: number;
|
||||
navigation: number;
|
||||
action: number;
|
||||
};
|
||||
mock: {
|
||||
enabled: boolean;
|
||||
mode: 'full' | 'partial' | 'none';
|
||||
delay: number;
|
||||
};
|
||||
retry: {
|
||||
count: number;
|
||||
delay: number;
|
||||
};
|
||||
}
|
||||
|
||||
const environments: Record<string, EnvironmentConfig> = {
|
||||
local: {
|
||||
name: 'local',
|
||||
baseURL: process.env.ADMIN_BASE_URL || 'http://localhost:5174',
|
||||
apiBaseURL: process.env.API_BASE_URL || 'http://localhost:8080',
|
||||
uniappBaseURL: process.env.UNIAPP_BASE_URL || 'http://localhost:8081',
|
||||
timeout: {
|
||||
default: 30000,
|
||||
navigation: 30000,
|
||||
action: 10000,
|
||||
},
|
||||
mock: {
|
||||
enabled: process.env.E2E_MOCK_ENABLED === 'true',
|
||||
mode: (process.env.E2E_MOCK_MODE as 'full' | 'partial' | 'none') || 'none',
|
||||
delay: 0,
|
||||
},
|
||||
retry: {
|
||||
count: process.env.CI ? 2 : 0,
|
||||
delay: 1000,
|
||||
},
|
||||
},
|
||||
dev: {
|
||||
name: 'dev',
|
||||
baseURL: process.env.ADMIN_BASE_URL || 'http://dev-admin.example.com',
|
||||
apiBaseURL: process.env.API_BASE_URL || 'http://dev-api.example.com',
|
||||
uniappBaseURL: process.env.UNIAPP_BASE_URL || 'http://dev-uniapp.example.com',
|
||||
timeout: {
|
||||
default: 30000,
|
||||
navigation: 30000,
|
||||
action: 10000,
|
||||
},
|
||||
mock: {
|
||||
enabled: false,
|
||||
mode: 'none',
|
||||
delay: 0,
|
||||
},
|
||||
retry: {
|
||||
count: 2,
|
||||
delay: 1000,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
name: 'test',
|
||||
baseURL: process.env.ADMIN_BASE_URL || 'http://test-admin.example.com',
|
||||
apiBaseURL: process.env.API_BASE_URL || 'http://test-api.example.com',
|
||||
uniappBaseURL: process.env.UNIAPP_BASE_URL || 'http://test-uniapp.example.com',
|
||||
timeout: {
|
||||
default: 30000,
|
||||
navigation: 30000,
|
||||
action: 10000,
|
||||
},
|
||||
mock: {
|
||||
enabled: false,
|
||||
mode: 'none',
|
||||
delay: 0,
|
||||
},
|
||||
retry: {
|
||||
count: 2,
|
||||
delay: 1000,
|
||||
},
|
||||
},
|
||||
ci: {
|
||||
name: 'ci',
|
||||
baseURL: process.env.ADMIN_BASE_URL || 'http://localhost:5174',
|
||||
apiBaseURL: process.env.API_BASE_URL || 'http://localhost:8080',
|
||||
uniappBaseURL: process.env.UNIAPP_BASE_URL || 'http://localhost:8081',
|
||||
timeout: {
|
||||
default: 60000,
|
||||
navigation: 60000,
|
||||
action: 15000,
|
||||
},
|
||||
mock: {
|
||||
enabled: true,
|
||||
mode: 'full',
|
||||
delay: 100,
|
||||
},
|
||||
retry: {
|
||||
count: 2,
|
||||
delay: 1000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
class TestConfigManager {
|
||||
private currentEnv: string = 'local';
|
||||
|
||||
setEnvironment(env: string): void {
|
||||
if (!environments[env]) {
|
||||
throw new Error(`Unknown environment: ${env}. Available: ${Object.keys(environments).join(', ')}`);
|
||||
}
|
||||
this.currentEnv = env;
|
||||
}
|
||||
|
||||
getConfig(): EnvironmentConfig {
|
||||
return environments[this.currentEnv];
|
||||
}
|
||||
|
||||
getEnvironmentName(): string {
|
||||
return this.currentEnv;
|
||||
}
|
||||
|
||||
isMockEnabled(): boolean {
|
||||
return this.getConfig().mock.enabled;
|
||||
}
|
||||
|
||||
getTimeout(type: keyof EnvironmentConfig['timeout'] = 'default'): number {
|
||||
return this.getConfig().timeout[type];
|
||||
}
|
||||
}
|
||||
|
||||
export const testConfig = new TestConfigManager();
|
||||
|
||||
// 自动根据环境变量设置环境
|
||||
if (process.env.CI) {
|
||||
testConfig.setEnvironment('ci');
|
||||
} else if (process.env.E2E_ENV && environments[process.env.E2E_ENV]) {
|
||||
testConfig.setEnvironment(process.env.E2E_ENV);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* 基础页面类
|
||||
* 所有页面对象的基类,提供通用的页面操作方法
|
||||
*/
|
||||
|
||||
import { Page, Locator, expect } from '@playwright/test';
|
||||
import { testConfig } from '../config/test-config';
|
||||
import { testLogger } from '../utils/test-logger';
|
||||
|
||||
export interface PageOptions {
|
||||
baseURL?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export class BasePage {
|
||||
protected page: Page;
|
||||
protected baseURL: string;
|
||||
protected defaultTimeout: number;
|
||||
|
||||
constructor(page: Page, options: PageOptions = {}) {
|
||||
this.page = page;
|
||||
this.baseURL = options.baseURL || testConfig.getConfig().baseURL;
|
||||
this.defaultTimeout = options.timeout || testConfig.getTimeout('default');
|
||||
}
|
||||
|
||||
/**
|
||||
* 导航到指定路径
|
||||
*/
|
||||
async navigate(path: string): Promise<void> {
|
||||
testLogger.debug(`导航到: ${this.baseURL}${path}`);
|
||||
await this.page.goto(`${this.baseURL}${path}`, {
|
||||
timeout: testConfig.getTimeout('navigation'),
|
||||
});
|
||||
await this.waitForLoad();
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待页面加载完成
|
||||
*/
|
||||
async waitForLoad(): Promise<void> {
|
||||
await this.page.waitForLoadState('networkidle', {
|
||||
timeout: this.defaultTimeout,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待元素可见
|
||||
*/
|
||||
async waitForVisible(selector: string, timeout?: number): Promise<Locator> {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.waitFor({
|
||||
state: 'visible',
|
||||
timeout: timeout || this.defaultTimeout,
|
||||
});
|
||||
return locator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待元素隐藏
|
||||
*/
|
||||
async waitForHidden(selector: string, timeout?: number): Promise<void> {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.waitFor({
|
||||
state: 'hidden',
|
||||
timeout: timeout || this.defaultTimeout,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击元素
|
||||
*/
|
||||
async clickElement(selector: string, options?: { force?: boolean }): Promise<void> {
|
||||
testLogger.debug(`点击元素: ${selector}`);
|
||||
const locator = await this.waitForVisible(selector);
|
||||
await locator.click({ force: options?.force });
|
||||
}
|
||||
|
||||
/**
|
||||
* 填写输入框
|
||||
*/
|
||||
async fillInput(selector: string, value: string, options?: { clear?: boolean }): Promise<void> {
|
||||
testLogger.debug(`填写输入框: ${selector} = ${value}`);
|
||||
const locator = await this.waitForVisible(selector);
|
||||
|
||||
if (options?.clear !== false) {
|
||||
await locator.clear();
|
||||
}
|
||||
|
||||
await locator.fill(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元素文本
|
||||
*/
|
||||
async getElementText(selector: string): Promise<string> {
|
||||
const locator = await this.waitForVisible(selector);
|
||||
return await locator.textContent() || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查元素是否存在
|
||||
*/
|
||||
async elementExists(selector: string): Promise<boolean> {
|
||||
const locator = this.page.locator(selector);
|
||||
return await locator.count() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查元素是否可见
|
||||
*/
|
||||
async isElementVisible(selector: string): Promise<boolean> {
|
||||
const locator = this.page.locator(selector);
|
||||
return await locator.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取页面标题
|
||||
*/
|
||||
async getPageTitle(): Promise<string> {
|
||||
return await this.page.title();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前URL
|
||||
*/
|
||||
async getCurrentURL(): Promise<string> {
|
||||
return this.page.url();
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待URL变化
|
||||
*/
|
||||
async waitForURL(pattern: string | RegExp, timeout?: number): Promise<void> {
|
||||
await this.page.waitForURL(pattern, {
|
||||
timeout: timeout || testConfig.getTimeout('navigation'),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 截图
|
||||
*/
|
||||
async takeScreenshot(name: string, fullPage: boolean = true): Promise<string> {
|
||||
const path = `test-results/screenshots/${name}.png`;
|
||||
await this.page.screenshot({ path, fullPage });
|
||||
testLogger.addScreenshot(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动到元素
|
||||
*/
|
||||
async scrollToElement(selector: string): Promise<void> {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.scrollIntoViewIfNeeded();
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动页面
|
||||
*/
|
||||
async scrollPage(x: number, y: number): Promise<void> {
|
||||
await this.page.evaluate(([scrollX, scrollY]) => {
|
||||
window.scrollTo(scrollX, scrollY);
|
||||
}, [x, y]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待指定时间
|
||||
*/
|
||||
async waitForTimeout(ms: number): Promise<void> {
|
||||
await this.page.waitForTimeout(ms);
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择下拉框选项
|
||||
*/
|
||||
async selectOption(selector: string, value: string): Promise<void> {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.selectOption(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查复选框
|
||||
*/
|
||||
async checkCheckbox(selector: string): Promise<void> {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.check();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消复选框
|
||||
*/
|
||||
async uncheckCheckbox(selector: string): Promise<void> {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.uncheck();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元素数量
|
||||
*/
|
||||
async getElementCount(selector: string): Promise<number> {
|
||||
return await this.page.locator(selector).count();
|
||||
}
|
||||
|
||||
/**
|
||||
* 悬停在元素上
|
||||
*/
|
||||
async hoverElement(selector: string): Promise<void> {
|
||||
const locator = await this.waitForVisible(selector);
|
||||
await locator.hover();
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽元素
|
||||
*/
|
||||
async dragElement(sourceSelector: string, targetSelector: string): Promise<void> {
|
||||
const source = this.page.locator(sourceSelector);
|
||||
const target = this.page.locator(targetSelector);
|
||||
await source.dragTo(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新页面
|
||||
*/
|
||||
async reload(): Promise<void> {
|
||||
await this.page.reload();
|
||||
await this.waitForLoad();
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回上一页
|
||||
*/
|
||||
async goBack(): Promise<void> {
|
||||
await this.page.goBack();
|
||||
await this.waitForLoad();
|
||||
}
|
||||
|
||||
/**
|
||||
* 前进到下一页
|
||||
*/
|
||||
async goForward(): Promise<void> {
|
||||
await this.page.goForward();
|
||||
await this.waitForLoad();
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行键盘操作
|
||||
*/
|
||||
async pressKey(key: string): Promise<void> {
|
||||
await this.page.keyboard.press(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*/
|
||||
async uploadFile(selector: string, filePath: string): Promise<void> {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.setInputFiles(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取页面性能指标
|
||||
*/
|
||||
async getPerformanceMetrics(): Promise<Record<string, number>> {
|
||||
return await this.page.evaluate(() => {
|
||||
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
||||
return {
|
||||
loadTime: navigation.loadEventEnd - navigation.startTime,
|
||||
domContentLoaded: navigation.domContentLoadedEventEnd - navigation.startTime,
|
||||
firstPaint: performance.getEntriesByName('first-paint')[0]?.startTime || 0,
|
||||
firstContentfulPaint: performance.getEntriesByName('first-contentful-paint')[0]?.startTime || 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证页面加载性能
|
||||
*/
|
||||
async verifyPerformance(maxLoadTime: number = 3000): Promise<void> {
|
||||
const metrics = await this.getPerformanceMetrics();
|
||||
testLogger.info('页面性能指标', metrics);
|
||||
|
||||
expect(metrics.loadTime).toBeLessThan(maxLoadTime);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* 测试数据工厂
|
||||
* 生成各种测试数据,支持边界条件和异常数据
|
||||
*/
|
||||
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { zh_CN } from '@faker-js/faker/locale/zh_CN';
|
||||
|
||||
faker.locale = zh_CN;
|
||||
|
||||
export interface UserData {
|
||||
id?: number;
|
||||
username: string;
|
||||
realName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
gender: number;
|
||||
status: number;
|
||||
avatar?: string;
|
||||
createBy?: string;
|
||||
updateBy?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface RoleData {
|
||||
id?: number;
|
||||
roleName: string;
|
||||
roleCode: string;
|
||||
description: string;
|
||||
status: number;
|
||||
permissions?: string[];
|
||||
createBy?: string;
|
||||
updateBy?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface MenuData {
|
||||
id?: number;
|
||||
menuName: string;
|
||||
menuCode: string;
|
||||
path: string;
|
||||
icon: string;
|
||||
sortOrder: number;
|
||||
status: number;
|
||||
parentId: number;
|
||||
menuType: number;
|
||||
component?: string;
|
||||
permission?: string;
|
||||
createBy?: string;
|
||||
updateBy?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AlmanacData {
|
||||
date: string;
|
||||
lunarDate: string;
|
||||
ganZhi: string;
|
||||
zodiac: string;
|
||||
yi: string[];
|
||||
ji: string[];
|
||||
jieQi?: string;
|
||||
sha?: string;
|
||||
jiShen?: string[];
|
||||
xiongShen?: string[];
|
||||
}
|
||||
|
||||
export interface CalendarData {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
lunarYear: number;
|
||||
lunarMonth: number;
|
||||
lunarDay: number;
|
||||
lunarMonthName: string;
|
||||
lunarDayName: string;
|
||||
ganZhiYear: string;
|
||||
ganZhiMonth: string;
|
||||
ganZhiDay: string;
|
||||
zodiac: string;
|
||||
isLeapMonth: boolean;
|
||||
isToday: boolean;
|
||||
}
|
||||
|
||||
class TestDataFactory {
|
||||
/**
|
||||
* 生成正常用户数据
|
||||
*/
|
||||
generateUserData(overrides: Partial<UserData> = {}): UserData {
|
||||
const timestamp = Date.now();
|
||||
const password = 'Test@123456';
|
||||
|
||||
return {
|
||||
username: `testuser_${timestamp}`,
|
||||
realName: faker.person.fullName(),
|
||||
email: `test_${timestamp}@example.com`,
|
||||
phone: `1${faker.string.numeric(10)}`,
|
||||
password,
|
||||
confirmPassword: password,
|
||||
gender: faker.number.int({ min: 0, max: 2 }),
|
||||
status: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成边界条件用户数据
|
||||
*/
|
||||
generateBoundaryUserData(type: 'min' | 'max' | 'empty' | 'special'): Partial<UserData> {
|
||||
const timestamp = Date.now();
|
||||
|
||||
switch (type) {
|
||||
case 'min':
|
||||
return {
|
||||
username: 'abc',
|
||||
nickname: '测',
|
||||
email: 'test@example.com',
|
||||
phone: '13800138000',
|
||||
password: 'A1@aaaa',
|
||||
confirmPassword: 'A1@aaaa',
|
||||
roleIds: [1], // 添加默认角色
|
||||
};
|
||||
case 'max':
|
||||
return {
|
||||
username: 'a'.repeat(20),
|
||||
nickname: '测'.repeat(50),
|
||||
email: `test${timestamp}@example.com`,
|
||||
phone: '13800138000',
|
||||
password: 'A1@' + 'a'.repeat(17),
|
||||
confirmPassword: 'A1@' + 'a'.repeat(17),
|
||||
roleIds: [1],
|
||||
};
|
||||
case 'empty':
|
||||
return {
|
||||
username: '',
|
||||
nickname: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
roleIds: [],
|
||||
};
|
||||
case 'special':
|
||||
return {
|
||||
username: `test_${timestamp}`,
|
||||
nickname: '测试<script>alert(1)</script>',
|
||||
email: `test+${timestamp}@example.com`,
|
||||
phone: '13800138000',
|
||||
password: 'Test@123!@#$%^&*()',
|
||||
confirmPassword: 'Test@123!@#$%^&*()',
|
||||
roleIds: [1],
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成异常用户数据
|
||||
*/
|
||||
generateInvalidUserData(type: 'duplicate' | 'invalid_email' | 'invalid_phone' | 'weak_password'): Partial<UserData> {
|
||||
const timestamp = Date.now();
|
||||
|
||||
switch (type) {
|
||||
case 'duplicate':
|
||||
return {
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
};
|
||||
case 'invalid_email':
|
||||
return {
|
||||
email: 'invalid-email',
|
||||
};
|
||||
case 'invalid_phone':
|
||||
return {
|
||||
phone: '12345678901',
|
||||
};
|
||||
case 'weak_password':
|
||||
return {
|
||||
password: '123456',
|
||||
confirmPassword: '123456',
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成角色数据
|
||||
*/
|
||||
generateRoleData(overrides: Partial<RoleData> = {}): RoleData {
|
||||
const timestamp = Date.now();
|
||||
|
||||
return {
|
||||
roleName: `测试角色_${timestamp}`,
|
||||
roleCode: `test_role_${timestamp}`,
|
||||
description: faker.lorem.sentence(),
|
||||
status: 1,
|
||||
permissions: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成菜单数据
|
||||
*/
|
||||
generateMenuData(overrides: Partial<MenuData> = {}): MenuData {
|
||||
const timestamp = Date.now();
|
||||
|
||||
return {
|
||||
menuName: `测试菜单_${timestamp}`,
|
||||
menuCode: `test_menu_${timestamp}`,
|
||||
path: `/test-menu-${timestamp}`,
|
||||
icon: 'SettingOutlined',
|
||||
sortOrder: faker.number.int({ min: 1, max: 100 }),
|
||||
status: 0,
|
||||
parentId: 0,
|
||||
menuType: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成黄历数据
|
||||
*/
|
||||
generateAlmanacData(date: string = new Date().toISOString().split('T')[0]): AlmanacData {
|
||||
return {
|
||||
date,
|
||||
lunarDate: '农历日期',
|
||||
ganZhi: '甲子年 丙寅月 戊辰日',
|
||||
zodiac: '鼠',
|
||||
yi: ['嫁娶', '祭祀', '祈福', '求嗣', '开光', '出行'],
|
||||
ji: ['开市', '立券', '交易', '纳财'],
|
||||
jieQi: '立春',
|
||||
sha: '南',
|
||||
jiShen: ['天德', '月德', '天恩'],
|
||||
xiongShen: ['月破', '大耗', '四击'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成日历数据
|
||||
*/
|
||||
generateCalendarData(year: number = new Date().getFullYear(), month: number = new Date().getMonth() + 1): CalendarData {
|
||||
return {
|
||||
year,
|
||||
month,
|
||||
day: 15,
|
||||
lunarYear: year,
|
||||
lunarMonth: month,
|
||||
lunarDay: 15,
|
||||
lunarMonthName: '正月',
|
||||
lunarDayName: '十五',
|
||||
ganZhiYear: '甲子',
|
||||
ganZhiMonth: '丙寅',
|
||||
ganZhiDay: '戊辰',
|
||||
zodiac: '鼠',
|
||||
isLeapMonth: false,
|
||||
isToday: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成批量测试数据
|
||||
*/
|
||||
generateBatchData<T>(
|
||||
generator: () => T,
|
||||
count: number,
|
||||
overrides: Partial<T> = {}
|
||||
): T[] {
|
||||
return Array.from({ length: count }, () => ({
|
||||
...generator(),
|
||||
...overrides,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成测试日期范围
|
||||
*/
|
||||
generateDateRange(days: number = 30): { start: string; end: string } {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setDate(start.getDate() - days);
|
||||
|
||||
return {
|
||||
start: start.toISOString().split('T')[0],
|
||||
end: end.toISOString().split('T')[0],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成特殊日期
|
||||
*/
|
||||
generateSpecialDates(): Record<string, string> {
|
||||
const year = new Date().getFullYear();
|
||||
return {
|
||||
newYear: `${year}-01-01`,
|
||||
springFestival: `${year}-02-10`, // 示例春节日期
|
||||
laborDay: `${year}-05-01`,
|
||||
nationalDay: `${year}-10-01`,
|
||||
leapYearFeb29: `${year % 4 === 0 ? year : year + 4}-02-29`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const testDataFactory = new TestDataFactory();
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* 测试日志记录器
|
||||
* 提供结构化的测试日志记录
|
||||
*/
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: string;
|
||||
level: 'debug' | 'info' | 'warn' | 'error';
|
||||
message: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TestStep {
|
||||
name: string;
|
||||
status: 'pending' | 'running' | 'passed' | 'failed' | 'skipped';
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
duration?: number;
|
||||
logs: LogEntry[];
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
testName: string;
|
||||
status: 'passed' | 'failed' | 'skipped';
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
duration: number;
|
||||
steps: TestStep[];
|
||||
logs: LogEntry[];
|
||||
screenshots: string[];
|
||||
error?: Error;
|
||||
retryCount?: number;
|
||||
}
|
||||
|
||||
class TestLogger {
|
||||
private logs: LogEntry[] = [];
|
||||
private steps: TestStep[] = [];
|
||||
private currentStep: TestStep | null = null;
|
||||
private currentTest: TestResult | null = null;
|
||||
|
||||
private getTimestamp(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
private addLog(level: LogEntry['level'], message: string, context?: Record<string, unknown>): void {
|
||||
const entry: LogEntry = {
|
||||
timestamp: this.getTimestamp(),
|
||||
level,
|
||||
message,
|
||||
context,
|
||||
};
|
||||
|
||||
this.logs.push(entry);
|
||||
|
||||
if (this.currentStep) {
|
||||
this.currentStep.logs.push(entry);
|
||||
}
|
||||
|
||||
// 控制台输出
|
||||
const consoleMessage = `[${entry.timestamp}] [${level.toUpperCase()}] ${message}`;
|
||||
switch (level) {
|
||||
case 'debug':
|
||||
console.debug(consoleMessage);
|
||||
break;
|
||||
case 'info':
|
||||
console.info(consoleMessage);
|
||||
break;
|
||||
case 'warn':
|
||||
console.warn(consoleMessage);
|
||||
break;
|
||||
case 'error':
|
||||
console.error(consoleMessage);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
debug(message: string, context?: Record<string, unknown>): void {
|
||||
this.addLog('debug', message, context);
|
||||
}
|
||||
|
||||
info(message: string, context?: Record<string, unknown>): void {
|
||||
this.addLog('info', message, context);
|
||||
}
|
||||
|
||||
warn(message: string, context?: Record<string, unknown>): void {
|
||||
this.addLog('warn', message, context);
|
||||
}
|
||||
|
||||
error(message: string, error?: Error, context?: Record<string, unknown>): void {
|
||||
this.addLog('error', message, {
|
||||
...context,
|
||||
error: error?.message,
|
||||
stack: error?.stack,
|
||||
});
|
||||
}
|
||||
|
||||
startTest(testName: string): void {
|
||||
this.currentTest = {
|
||||
testName,
|
||||
status: 'passed',
|
||||
startTime: this.getTimestamp(),
|
||||
endTime: '',
|
||||
duration: 0,
|
||||
steps: [],
|
||||
logs: [],
|
||||
screenshots: [],
|
||||
};
|
||||
this.logs = [];
|
||||
this.steps = [];
|
||||
this.info(`开始测试: ${testName}`);
|
||||
}
|
||||
|
||||
endTest(testName: string, status: 'passed' | 'failed' | 'skipped', error?: Error): void {
|
||||
if (this.currentTest) {
|
||||
this.currentTest.status = status;
|
||||
this.currentTest.endTime = this.getTimestamp();
|
||||
this.currentTest.duration = new Date(this.currentTest.endTime).getTime() -
|
||||
new Date(this.currentTest.startTime).getTime();
|
||||
this.currentTest.steps = this.steps;
|
||||
this.currentTest.logs = this.logs;
|
||||
|
||||
if (error) {
|
||||
this.currentTest.error = error;
|
||||
}
|
||||
|
||||
this.info(`测试结束: ${testName} - ${status}`, {
|
||||
duration: this.currentTest.duration,
|
||||
stepsCount: this.steps.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
startStep(stepName: string): void {
|
||||
if (this.currentStep) {
|
||||
this.endStep(this.currentStep.name, 'failed');
|
||||
}
|
||||
|
||||
this.currentStep = {
|
||||
name: stepName,
|
||||
status: 'running',
|
||||
startTime: this.getTimestamp(),
|
||||
logs: [],
|
||||
};
|
||||
|
||||
this.info(`开始步骤: ${stepName}`);
|
||||
}
|
||||
|
||||
endStep(stepName: string, status: TestStep['status'], error?: Error): void {
|
||||
if (this.currentStep && this.currentStep.name === stepName) {
|
||||
this.currentStep.status = status;
|
||||
this.currentStep.endTime = this.getTimestamp();
|
||||
|
||||
if (this.currentStep.startTime) {
|
||||
this.currentStep.duration = new Date(this.currentStep.endTime).getTime() -
|
||||
new Date(this.currentStep.startTime).getTime();
|
||||
}
|
||||
|
||||
if (error) {
|
||||
this.currentStep.error = error;
|
||||
}
|
||||
|
||||
this.steps.push(this.currentStep);
|
||||
this.info(`步骤结束: ${stepName} - ${status}`, {
|
||||
duration: this.currentStep.duration,
|
||||
});
|
||||
|
||||
this.currentStep = null;
|
||||
}
|
||||
}
|
||||
|
||||
addScreenshot(path: string): void {
|
||||
if (this.currentTest) {
|
||||
this.currentTest.screenshots.push(path);
|
||||
}
|
||||
this.info(`截图已保存: ${path}`);
|
||||
}
|
||||
|
||||
getCurrentTest(): TestResult | null {
|
||||
return this.currentTest;
|
||||
}
|
||||
|
||||
getLogs(): LogEntry[] {
|
||||
return this.logs;
|
||||
}
|
||||
|
||||
getSteps(): TestStep[] {
|
||||
return this.steps;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.logs = [];
|
||||
this.steps = [];
|
||||
this.currentStep = null;
|
||||
this.currentTest = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成测试执行摘要
|
||||
*/
|
||||
generateSummary(): Record<string, unknown> {
|
||||
const passed = this.steps.filter(s => s.status === 'passed').length;
|
||||
const failed = this.steps.filter(s => s.status === 'failed').length;
|
||||
const skipped = this.steps.filter(s => s.status === 'skipped').length;
|
||||
|
||||
return {
|
||||
totalSteps: this.steps.length,
|
||||
passed,
|
||||
failed,
|
||||
skipped,
|
||||
totalLogs: this.logs.length,
|
||||
errors: this.logs.filter(l => l.level === 'error').length,
|
||||
warnings: this.logs.filter(l => l.level === 'warn').length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const testLogger = new TestLogger();
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* 测试报告生成器
|
||||
* 生成多种格式的测试报告:HTML、JSON、JUnit XML、Markdown
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { TestResult, TestStep } from './test-logger';
|
||||
|
||||
export interface TestSuite {
|
||||
name: string;
|
||||
tests: TestResult[];
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
}
|
||||
|
||||
export interface ReportSummary {
|
||||
totalTests: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
totalDuration: number;
|
||||
passRate: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
|
||||
class TestReporter {
|
||||
private suites: TestSuite[] = [];
|
||||
private startTime: string = '';
|
||||
private endTime: string = '';
|
||||
|
||||
startReport(): void {
|
||||
this.startTime = new Date().toISOString();
|
||||
this.suites = [];
|
||||
}
|
||||
|
||||
endReport(): void {
|
||||
this.endTime = new Date().toISOString();
|
||||
}
|
||||
|
||||
addTestSuite(suite: TestSuite): void {
|
||||
this.suites.push(suite);
|
||||
}
|
||||
|
||||
recordTestResult(test: TestResult): void {
|
||||
// 查找或创建测试套件
|
||||
let suite = this.suites.find(s => s.name === 'Default Suite');
|
||||
if (!suite) {
|
||||
suite = { name: 'Default Suite', tests: [] };
|
||||
this.suites.push(suite);
|
||||
}
|
||||
suite.tests.push(test);
|
||||
}
|
||||
|
||||
generateSummary(): ReportSummary {
|
||||
const allTests = this.suites.flatMap(s => s.tests);
|
||||
const passed = allTests.filter(t => t.status === 'passed').length;
|
||||
const failed = allTests.filter(t => t.status === 'failed').length;
|
||||
const skipped = allTests.filter(t => t.status === 'skipped').length;
|
||||
const totalDuration = allTests.reduce((sum, t) => sum + t.duration, 0);
|
||||
|
||||
return {
|
||||
totalTests: allTests.length,
|
||||
passed,
|
||||
failed,
|
||||
skipped,
|
||||
totalDuration,
|
||||
passRate: allTests.length > 0 ? (passed / allTests.length) * 100 : 0,
|
||||
startTime: this.startTime,
|
||||
endTime: this.endTime,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成JSON格式报告
|
||||
*/
|
||||
generateJSONReport(outputPath: string): void {
|
||||
const report = {
|
||||
summary: this.generateSummary(),
|
||||
suites: this.suites,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.ensureDirectoryExists(path.dirname(outputPath));
|
||||
fs.writeFileSync(outputPath, JSON.stringify(report, null, 2), 'utf-8');
|
||||
console.log(`JSON报告已生成: ${outputPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成HTML格式报告
|
||||
*/
|
||||
generateHTMLReport(outputPath: string): void {
|
||||
const summary = this.generateSummary();
|
||||
const html = this.buildHTMLReport(summary);
|
||||
|
||||
this.ensureDirectoryExists(path.dirname(outputPath));
|
||||
fs.writeFileSync(outputPath, html, 'utf-8');
|
||||
console.log(`HTML报告已生成: ${outputPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成JUnit XML格式报告
|
||||
*/
|
||||
generateJUnitReport(outputPath: string): void {
|
||||
const summary = this.generateSummary();
|
||||
const xml = this.buildJUnitReport(summary);
|
||||
|
||||
this.ensureDirectoryExists(path.dirname(outputPath));
|
||||
fs.writeFileSync(outputPath, xml, 'utf-8');
|
||||
console.log(`JUnit报告已生成: ${outputPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成Markdown格式报告
|
||||
*/
|
||||
generateMarkdownReport(outputPath: string): void {
|
||||
const summary = this.generateSummary();
|
||||
const markdown = this.buildMarkdownReport(summary);
|
||||
|
||||
this.ensureDirectoryExists(path.dirname(outputPath));
|
||||
fs.writeFileSync(outputPath, markdown, 'utf-8');
|
||||
console.log(`Markdown报告已生成: ${outputPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成所有报告
|
||||
*/
|
||||
generateAllReports(outputDir: string): void {
|
||||
this.endReport();
|
||||
|
||||
this.generateJSONReport(path.join(outputDir, 'e2e-report.json'));
|
||||
this.generateHTMLReport(path.join(outputDir, 'e2e-report.html'));
|
||||
this.generateJUnitReport(path.join(outputDir, 'junit-report.xml'));
|
||||
this.generateMarkdownReport(path.join(outputDir, 'e2e-report.md'));
|
||||
|
||||
// 打印摘要
|
||||
this.printSummary();
|
||||
}
|
||||
|
||||
private printSummary(): void {
|
||||
const summary = this.generateSummary();
|
||||
console.log('\n========== 测试执行摘要 ==========');
|
||||
console.log(`总测试数: ${summary.totalTests}`);
|
||||
console.log(`通过: ${summary.passed} ✅`);
|
||||
console.log(`失败: ${summary.failed} ❌`);
|
||||
console.log(`跳过: ${summary.skipped} ⏭️`);
|
||||
console.log(`通过率: ${summary.passRate.toFixed(2)}%`);
|
||||
console.log(`总耗时: ${(summary.totalDuration / 1000).toFixed(2)}s`);
|
||||
console.log('===================================\n');
|
||||
}
|
||||
|
||||
private ensureDirectoryExists(dirPath: string): void {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
private buildHTMLReport(summary: ReportSummary): string {
|
||||
const allTests = this.suites.flatMap(s => s.tests);
|
||||
const statusColor = {
|
||||
passed: '#28a745',
|
||||
failed: '#dc3545',
|
||||
skipped: '#ffc107',
|
||||
};
|
||||
|
||||
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, sans-serif;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
.container { max-width: 1200px; margin: 0 auto; }
|
||||
.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; }
|
||||
.summary-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.card h3 { font-size: 14px; color: #666; margin-bottom: 8px; }
|
||||
.card .value { font-size: 32px; font-weight: bold; }
|
||||
.card.passed .value { color: #28a745; }
|
||||
.card.failed .value { color: #dc3545; }
|
||||
.card.skipped .value { color: #ffc107; }
|
||||
.progress-bar {
|
||||
background: #e9ecef;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.progress-fill {
|
||||
background: linear-gradient(90deg, #28a745 0%, #20c997 100%);
|
||||
height: 100%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.test-list {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.test-list-header {
|
||||
background: #f8f9fa;
|
||||
padding: 15px 20px;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
.test-item {
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.test-item:last-child { border-bottom: none; }
|
||||
.test-name { font-weight: 500; }
|
||||
.test-status {
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.test-status.passed { background: #d4edda; color: #155724; }
|
||||
.test-status.failed { background: #f8d7da; color: #721c24; }
|
||||
.test-status.skipped { background: #fff3cd; color: #856404; }
|
||||
.test-duration { color: #666; font-size: 14px; margin-left: 10px; }
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>E2E测试报告</h1>
|
||||
<p>生成时间: ${new Date().toLocaleString('zh-CN')}</p>
|
||||
</div>
|
||||
|
||||
<div class="summary-cards">
|
||||
<div class="card">
|
||||
<h3>总测试数</h3>
|
||||
<div class="value">${summary.totalTests}</div>
|
||||
</div>
|
||||
<div class="card passed">
|
||||
<h3>通过</h3>
|
||||
<div class="value">${summary.passed}</div>
|
||||
</div>
|
||||
<div class="card failed">
|
||||
<h3>失败</h3>
|
||||
<div class="value">${summary.failed}</div>
|
||||
</div>
|
||||
<div class="card skipped">
|
||||
<h3>跳过</h3>
|
||||
<div class="value">${summary.skipped}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom: 20px;">
|
||||
<h3>通过率: ${summary.passRate.toFixed(2)}%</h3>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: ${summary.passRate}%"></div>
|
||||
</div>
|
||||
<p style="margin-top: 10px; color: #666;">
|
||||
总耗时: ${(summary.totalDuration / 1000).toFixed(2)}秒
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="test-list">
|
||||
<div class="test-list-header">测试详情</div>
|
||||
${allTests.map(test => `
|
||||
<div class="test-item">
|
||||
<div>
|
||||
<span class="test-name">${test.testName}</span>
|
||||
<span class="test-duration">${(test.duration / 1000).toFixed(2)}s</span>
|
||||
</div>
|
||||
<span class="test-status ${test.status}">${test.status}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>由 Playwright E2E 测试框架生成</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildJUnitReport(summary: ReportSummary): string {
|
||||
const allTests = this.suites.flatMap(s => s.tests);
|
||||
const failures = allTests.filter(t => t.status === 'failed').length;
|
||||
|
||||
let xml = `<?xml version="1.0" encoding="UTF-8"?>\n`;
|
||||
xml += `<testsuites name="E2E Tests" tests="${summary.totalTests}" failures="${failures}" skipped="${summary.skipped}" time="${summary.totalDuration / 1000}">\n`;
|
||||
|
||||
this.suites.forEach(suite => {
|
||||
xml += ` <testsuite name="${this.escapeXml(suite.name)}" tests="${suite.tests.length}" failures="${suite.tests.filter(t => t.status === 'failed').length}">\n`;
|
||||
|
||||
suite.tests.forEach(test => {
|
||||
xml += ` <testcase name="${this.escapeXml(test.testName)}" time="${test.duration / 1000}">\n`;
|
||||
|
||||
if (test.status === 'failed' && test.error) {
|
||||
xml += ` <failure message="${this.escapeXml(test.error.message)}">\n`;
|
||||
xml += ` ${this.escapeXml(test.error.stack || '')}\n`;
|
||||
xml += ` </failure>\n`;
|
||||
} else if (test.status === 'skipped') {
|
||||
xml += ` <skipped/>\n`;
|
||||
}
|
||||
|
||||
xml += ` </testcase>\n`;
|
||||
});
|
||||
|
||||
xml += ` </testsuite>\n`;
|
||||
});
|
||||
|
||||
xml += `</testsuites>`;
|
||||
return xml;
|
||||
}
|
||||
|
||||
private buildMarkdownReport(summary: ReportSummary): string {
|
||||
const allTests = this.suites.flatMap(s => s.tests);
|
||||
|
||||
let md = `# E2E测试报告\n\n`;
|
||||
md += `**生成时间**: ${new Date().toLocaleString('zh-CN')}\n\n`;
|
||||
|
||||
md += `## 执行摘要\n\n`;
|
||||
md += `| 指标 | 数值 |\n`;
|
||||
md += `|------|------|\n`;
|
||||
md += `| 总测试数 | ${summary.totalTests} |\n`;
|
||||
md += `| 通过 | ${summary.passed} ✅ |\n`;
|
||||
md += `| 失败 | ${summary.failed} ❌ |\n`;
|
||||
md += `| 跳过 | ${summary.skipped} ⏭️ |\n`;
|
||||
md += `| 通过率 | ${summary.passRate.toFixed(2)}% |\n`;
|
||||
md += `| 总耗时 | ${(summary.totalDuration / 1000).toFixed(2)}秒 |\n\n`;
|
||||
|
||||
md += `## 测试详情\n\n`;
|
||||
md += `| 测试名称 | 状态 | 耗时 |\n`;
|
||||
md += `|----------|------|------|\n`;
|
||||
|
||||
allTests.forEach(test => {
|
||||
const statusIcon = test.status === 'passed' ? '✅' : test.status === 'failed' ? '❌' : '⏭️';
|
||||
md += `| ${test.testName} | ${statusIcon} ${test.status} | ${(test.duration / 1000).toFixed(2)}s |\n`;
|
||||
});
|
||||
|
||||
md += `\n---\n\n`;
|
||||
md += `*由 Playwright E2E 测试框架生成*\n`;
|
||||
|
||||
return md;
|
||||
}
|
||||
|
||||
private escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
}
|
||||
|
||||
export const testReporter = new TestReporter();
|
||||
Reference in New Issue
Block a user