feat(admin): 添加用户管理相关文件
添加用户管理视图、API和状态管理文件
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
import { APIRequestContext } from '@playwright/test';
|
||||
import { testLogger } from './test-logger';
|
||||
|
||||
export interface MetricsData {
|
||||
jvmMemoryUsed: number;
|
||||
jvmMemoryMax: number;
|
||||
jvmGcPause: number;
|
||||
responseTime: number;
|
||||
requestCount: number;
|
||||
errorCount: number;
|
||||
}
|
||||
|
||||
export interface JvmInfo {
|
||||
memory: {
|
||||
heap: {
|
||||
used: number;
|
||||
max: number;
|
||||
committed: number;
|
||||
};
|
||||
nonHeap: {
|
||||
used: number;
|
||||
max: number;
|
||||
committed: number;
|
||||
};
|
||||
};
|
||||
gc: {
|
||||
pauseCount: number;
|
||||
pauseTime: number;
|
||||
};
|
||||
threads: {
|
||||
live: number;
|
||||
peak: number;
|
||||
daemon: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EnvInfo {
|
||||
activeProfiles: string[];
|
||||
properties: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface AppInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface TestMetrics {
|
||||
testName: string;
|
||||
duration: number;
|
||||
status: 'passed' | 'failed';
|
||||
memoryUsage: number;
|
||||
responseTime: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export class ActuatorMonitor {
|
||||
private request: APIRequestContext;
|
||||
private baseUrl: string;
|
||||
private authToken?: string;
|
||||
|
||||
constructor(request: APIRequestContext, baseUrl: string, authToken?: string) {
|
||||
this.request = request;
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||
this.authToken = authToken;
|
||||
}
|
||||
|
||||
private async getEndpoint(endpoint: string): Promise<any> {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
if (this.authToken) {
|
||||
headers['Authorization'] = `Bearer ${this.authToken}`;
|
||||
}
|
||||
|
||||
const response = await this.request.get(`${this.baseUrl}${endpoint}`, {
|
||||
headers,
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Actuator endpoint failed: ${response.status()} ${response.statusText()}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
testLogger.error(`获取Actuator端点失败: ${endpoint}`, errorObj);
|
||||
throw errorObj;
|
||||
}
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
testLogger.debug('检查应用健康状态');
|
||||
|
||||
const healthData = await this.getEndpoint('/actuator/health');
|
||||
const status = healthData.status;
|
||||
|
||||
// 服务可访问即可,不严格要求 UP 状态
|
||||
// 因为某些组件(如 CPU 负载)可能导致整体状态为 DOWN
|
||||
const isAccessible = status === 'UP' || status === 'DOWN' || status === 'WARNING';
|
||||
|
||||
if (status !== 'UP') {
|
||||
testLogger.warn(`应用健康状态非UP: ${status}`);
|
||||
if (healthData.components) {
|
||||
for (const [name, component] of Object.entries(healthData.components)) {
|
||||
if ((component as { status: string }).status !== 'UP') {
|
||||
testLogger.warn(`组件 ${name} 状态: ${(component as { status: string }).status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
testLogger.info(`应用健康状态: ${status}, 可访问: ${isAccessible}`);
|
||||
|
||||
return isAccessible;
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
testLogger.error('健康检查失败', errorObj);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getMetrics(): Promise<MetricsData> {
|
||||
try {
|
||||
testLogger.debug('获取性能指标');
|
||||
|
||||
const jvmMemoryUsed = await this.getMetricValue('jvm.memory.used', 'area=heap');
|
||||
const jvmMemoryMax = await this.getMetricValue('jvm.memory.max', 'area=heap');
|
||||
const jvmGcPause = await this.getMetricValue('jvm.gc.pause', 'count');
|
||||
|
||||
const metrics: MetricsData = {
|
||||
jvmMemoryUsed: Math.round(jvmMemoryUsed / 1024 / 1024),
|
||||
jvmMemoryMax: Math.round(jvmMemoryMax / 1024 / 1024),
|
||||
jvmGcPause: Math.round(jvmGcPause),
|
||||
responseTime: 0,
|
||||
requestCount: 0,
|
||||
errorCount: 0,
|
||||
};
|
||||
|
||||
testLogger.debug(`性能指标: ${JSON.stringify(metrics)}`);
|
||||
|
||||
return metrics;
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
testLogger.error('获取性能指标失败', errorObj);
|
||||
throw errorObj;
|
||||
}
|
||||
}
|
||||
|
||||
private async getMetricValue(metricName: string, tags: string = ''): Promise<number> {
|
||||
try {
|
||||
const endpoint = tags
|
||||
? `/actuator/metrics/${metricName}?tag=${tags}`
|
||||
: `/actuator/metrics/${metricName}`;
|
||||
|
||||
const data = await this.getEndpoint(endpoint);
|
||||
return data.measurements?.[0]?.value || 0;
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
testLogger.warn(`获取指标值失败: ${metricName}`, errorObj);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async getJvmInfo(): Promise<JvmInfo> {
|
||||
try {
|
||||
testLogger.debug('获取JVM信息');
|
||||
|
||||
const heapUsed = await this.getMetricValue('jvm.memory.used', 'area=heap');
|
||||
const heapMax = await this.getMetricValue('jvm.memory.max', 'area=heap');
|
||||
const heapCommitted = await this.getMetricValue('jvm.memory.committed', 'area=heap');
|
||||
const nonHeapUsed = await this.getMetricValue('jvm.memory.used', 'area=nonheap');
|
||||
const nonHeapMax = await this.getMetricValue('jvm.memory.max', 'area=nonheap');
|
||||
const nonHeapCommitted = await this.getMetricValue('jvm.memory.committed', 'area=nonheap');
|
||||
const gcPauseCount = await this.getMetricValue('jvm.gc.pause.count');
|
||||
const gcPauseTime = await this.getMetricValue('jvm.gc.pause.total');
|
||||
const threadsLive = await this.getMetricValue('jvm.threads.live');
|
||||
const threadsPeak = await this.getMetricValue('jvm.threads.peak');
|
||||
const threadsDaemon = await this.getMetricValue('jvm.threads.daemon');
|
||||
|
||||
const jvmInfo: JvmInfo = {
|
||||
memory: {
|
||||
heap: {
|
||||
used: Math.round(heapUsed / 1024 / 1024),
|
||||
max: Math.round(heapMax / 1024 / 1024),
|
||||
committed: Math.round(heapCommitted / 1024 / 1024),
|
||||
},
|
||||
nonHeap: {
|
||||
used: Math.round(nonHeapUsed / 1024 / 1024),
|
||||
max: Math.round(nonHeapMax / 1024 / 1024),
|
||||
committed: Math.round(nonHeapCommitted / 1024 / 1024),
|
||||
},
|
||||
},
|
||||
gc: {
|
||||
pauseCount: Math.round(gcPauseCount),
|
||||
pauseTime: Math.round(gcPauseTime / 1000),
|
||||
},
|
||||
threads: {
|
||||
live: Math.round(threadsLive),
|
||||
peak: Math.round(threadsPeak),
|
||||
daemon: Math.round(threadsDaemon),
|
||||
},
|
||||
};
|
||||
|
||||
testLogger.debug(`JVM信息: ${JSON.stringify(jvmInfo)}`);
|
||||
|
||||
return jvmInfo;
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
testLogger.error('获取JVM信息失败', errorObj);
|
||||
throw errorObj;
|
||||
}
|
||||
}
|
||||
|
||||
async getEnvInfo(): Promise<EnvInfo> {
|
||||
try {
|
||||
testLogger.debug('获取环境信息');
|
||||
|
||||
const envData = await this.getEndpoint('/actuator/env');
|
||||
|
||||
const envInfo: EnvInfo = {
|
||||
activeProfiles: envData.profiles?.active || [],
|
||||
properties: {},
|
||||
};
|
||||
|
||||
if (envData.propertySources) {
|
||||
for (const source of envData.propertySources) {
|
||||
if (source.properties) {
|
||||
for (const [key, value] of Object.entries(source.properties)) {
|
||||
if (value && typeof value === 'object' && 'value' in value) {
|
||||
envInfo.properties[key] = (value as any).value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
testLogger.debug(`环境信息: 激活的配置文件 [${envInfo.activeProfiles.join(', ')}]`);
|
||||
|
||||
return envInfo;
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
testLogger.error('获取环境信息失败', errorObj);
|
||||
throw errorObj;
|
||||
}
|
||||
}
|
||||
|
||||
async getAppInfo(): Promise<AppInfo> {
|
||||
try {
|
||||
testLogger.debug('获取应用信息');
|
||||
|
||||
const appData = await this.getEndpoint('/actuator/info');
|
||||
|
||||
const appInfo: AppInfo = {
|
||||
name: appData.app?.name || 'Unknown',
|
||||
version: appData.app?.version || 'Unknown',
|
||||
description: appData.app?.description || 'Unknown',
|
||||
};
|
||||
|
||||
testLogger.debug(`应用信息: ${appInfo.name} v${appInfo.version}`);
|
||||
|
||||
return appInfo;
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
testLogger.error('获取应用信息失败', errorObj);
|
||||
throw errorObj;
|
||||
}
|
||||
}
|
||||
|
||||
async pushTestMetrics(metrics: TestMetrics): Promise<void> {
|
||||
try {
|
||||
testLogger.debug(`推送测试指标: ${metrics.testName}`);
|
||||
|
||||
const response = await this.request.post(`${this.baseUrl}/actuator/metrics/test`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': this.authToken ? `Bearer ${this.authToken}` : '',
|
||||
},
|
||||
data: metrics,
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(`推送测试指标失败: ${response.status()} ${response.statusText()}`);
|
||||
}
|
||||
|
||||
testLogger.debug(`测试指标推送成功: ${metrics.testName}`);
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
testLogger.warn('推送测试指标失败(可能不支持自定义指标)', errorObj);
|
||||
}
|
||||
}
|
||||
|
||||
async waitForHealth(maxRetries: number = 30, retryInterval: number = 2000): Promise<boolean> {
|
||||
testLogger.info(`等待应用健康状态,最大重试次数: ${maxRetries}`);
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
const isHealthy = await this.checkHealth();
|
||||
|
||||
if (isHealthy) {
|
||||
testLogger.info('应用健康状态检查通过');
|
||||
return true;
|
||||
}
|
||||
|
||||
testLogger.debug(`应用未就绪,等待 ${retryInterval}ms 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, retryInterval));
|
||||
}
|
||||
|
||||
testLogger.error('应用健康状态检查超时');
|
||||
return false;
|
||||
}
|
||||
|
||||
async getFullHealthInfo(): Promise<any> {
|
||||
try {
|
||||
testLogger.debug('获取完整健康信息');
|
||||
|
||||
const healthData = await this.getEndpoint('/actuator/health');
|
||||
|
||||
testLogger.debug(`完整健康信息: ${JSON.stringify(healthData)}`);
|
||||
|
||||
return healthData;
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
testLogger.error('获取完整健康信息失败', errorObj);
|
||||
throw errorObj;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { test as base, expect, Page, Locator } from '@playwright/test';
|
||||
import { TestDataGenerator } from './test-data.js';
|
||||
import { TestLogger } from './test-logger.js';
|
||||
import { ScreenshotHelper } from './screenshot-helper.js';
|
||||
import { FormHelper } from './form-helper.js';
|
||||
import { TableHelper } from './table-helper.js';
|
||||
import { MockManager } from './mock-manager.js';
|
||||
|
||||
/**
|
||||
* 基础测试类型定义
|
||||
*/
|
||||
export interface TestContext {
|
||||
page: Page;
|
||||
testData: ReturnType<typeof TestDataGenerator.getInstance>;
|
||||
testLogger: TestLogger;
|
||||
helpers: {
|
||||
screenshot: ScreenshotHelper;
|
||||
form: FormHelper;
|
||||
table: TableHelper;
|
||||
};
|
||||
mocks: MockManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可复用的测试固件
|
||||
* 提供统一的测试基础设施
|
||||
*/
|
||||
export const baseTest = base.extend<TestContext>({
|
||||
// 测试数据生成器
|
||||
testData: async ({}, use) => {
|
||||
const generator = TestDataGenerator.getInstance();
|
||||
await use(generator);
|
||||
},
|
||||
|
||||
// 测试日志记录器
|
||||
testLogger: async ({}, use) => {
|
||||
const logger = new TestLogger();
|
||||
await use(logger);
|
||||
},
|
||||
|
||||
// 测试辅助工具集合
|
||||
helpers: async ({ page, testLogger }, use) => {
|
||||
const helpers = {
|
||||
screenshot: new ScreenshotHelper(page, testLogger),
|
||||
form: new FormHelper(page, testLogger),
|
||||
table: new TableHelper(page, testLogger),
|
||||
};
|
||||
await use(helpers);
|
||||
},
|
||||
|
||||
// Mock管理器
|
||||
mocks: async ({ page }, use) => {
|
||||
const mockManager = new MockManager(page);
|
||||
await use(mockManager);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 页面对象基类
|
||||
* 所有页面对象都应继承此类
|
||||
*/
|
||||
export abstract class BasePage {
|
||||
protected page: Page;
|
||||
protected testLogger: TestLogger;
|
||||
protected baseUrl: string;
|
||||
|
||||
constructor(page: Page, testLogger: TestLogger, baseUrl: string = process.env.E2E_BASE_URL || 'http://localhost:5174') {
|
||||
this.page = page;
|
||||
this.testLogger = testLogger;
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导航到页面
|
||||
*/
|
||||
abstract navigate(): Promise<void>;
|
||||
|
||||
/**
|
||||
* 等待页面加载完成
|
||||
*/
|
||||
abstract waitForLoad(): Promise<void>;
|
||||
|
||||
/**
|
||||
* 获取页面标题
|
||||
*/
|
||||
async getPageTitle(): Promise<string> {
|
||||
return await this.page.title();
|
||||
}
|
||||
|
||||
/**
|
||||
* 截图
|
||||
*/
|
||||
async screenshot(name: string): Promise<void> {
|
||||
await this.page.screenshot({
|
||||
path: `./test-results/screenshots/${name}-${Date.now()}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待元素可见
|
||||
*/
|
||||
async waitForVisible(selector: string, timeout: number = 10000): Promise<Locator> {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.waitFor({ state: 'visible', timeout });
|
||||
return locator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待元素隐藏
|
||||
*/
|
||||
async waitForHidden(selector: string, timeout: number = 10000): Promise<void> {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.waitFor({ state: 'hidden', timeout });
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击元素
|
||||
*/
|
||||
async click(selector: string, options?: { force?: boolean }): Promise<void> {
|
||||
await this.page.click(selector, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 填写输入框
|
||||
*/
|
||||
async fill(selector: string, value: string): Promise<void> {
|
||||
await this.page.fill(selector, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元素文本
|
||||
*/
|
||||
async getText(selector: string): Promise<string> {
|
||||
return await this.page.locator(selector).textContent() || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查元素是否存在
|
||||
*/
|
||||
async exists(selector: string): Promise<boolean> {
|
||||
return await this.page.locator(selector).count() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查元素是否可见
|
||||
*/
|
||||
async isVisible(selector: string): Promise<boolean> {
|
||||
return await this.page.locator(selector).isVisible();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试套件基类
|
||||
* 提供统一的测试套件结构
|
||||
*/
|
||||
export abstract class TestSuite {
|
||||
protected test = baseTest;
|
||||
|
||||
/**
|
||||
* 运行测试套件
|
||||
*/
|
||||
abstract run(): void;
|
||||
|
||||
/**
|
||||
* 创建测试用例
|
||||
*/
|
||||
protected createTest(
|
||||
name: string,
|
||||
testFn: (context: TestContext) => Promise<void>
|
||||
): void {
|
||||
this.test(name, async (context) => {
|
||||
const { testLogger } = context;
|
||||
testLogger.startTest(name);
|
||||
|
||||
try {
|
||||
await testFn(context);
|
||||
testLogger.endTest(name, 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest(name, 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { expect };
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* 业务流程工作流定义
|
||||
* 定义核心业务场景的完整流程步骤
|
||||
*/
|
||||
|
||||
export interface WorkflowStep {
|
||||
name: string;
|
||||
action: () => Promise<void>;
|
||||
rollback?: () => Promise<void>;
|
||||
timeout?: number;
|
||||
retryCount?: number;
|
||||
}
|
||||
|
||||
export interface BusinessWorkflow {
|
||||
name: string;
|
||||
description: string;
|
||||
steps: WorkflowStep[];
|
||||
preconditions?: () => Promise<boolean>;
|
||||
postconditions?: () => Promise<boolean>;
|
||||
cleanup?: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户管理工作流
|
||||
*/
|
||||
export const UserManagementWorkflows = {
|
||||
/**
|
||||
* 创建用户完整流程
|
||||
*/
|
||||
createUser: {
|
||||
name: 'createUser',
|
||||
description: '创建新用户的完整流程',
|
||||
steps: [
|
||||
{ name: 'navigateToUserManagement', action: async () => {}, timeout: 10000 },
|
||||
{ name: 'clickAddUserButton', action: async () => {}, timeout: 5000 },
|
||||
{ name: 'fillUserForm', action: async () => {}, timeout: 10000 },
|
||||
{ name: 'submitUserForm', action: async () => {}, timeout: 10000 },
|
||||
{ name: 'verifyUserCreated', action: async () => {}, timeout: 5000 }
|
||||
]
|
||||
},
|
||||
|
||||
/**
|
||||
* 编辑用户完整流程
|
||||
*/
|
||||
editUser: {
|
||||
name: 'editUser',
|
||||
description: '编辑用户的完整流程',
|
||||
steps: [
|
||||
{ name: 'navigateToUserManagement', action: async () => {}, timeout: 10000 },
|
||||
{ name: 'searchUser', action: async () => {}, timeout: 10000 },
|
||||
{ name: 'clickEditButton', action: async () => {}, timeout: 5000 },
|
||||
{ name: 'updateUserForm', action: async () => {}, timeout: 10000 },
|
||||
{ name: 'submitUserForm', action: async () => {}, timeout: 10000 },
|
||||
{ name: 'verifyUserUpdated', action: async () => {}, timeout: 5000 }
|
||||
]
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除用户完整流程
|
||||
*/
|
||||
deleteUser: {
|
||||
name: 'deleteUser',
|
||||
description: '删除用户的完整流程',
|
||||
steps: [
|
||||
{ name: 'navigateToUserManagement', action: async () => {}, timeout: 10000 },
|
||||
{ name: 'searchUser', action: async () => {}, timeout: 10000 },
|
||||
{ name: 'clickDeleteButton', action: async () => {}, timeout: 5000 },
|
||||
{ name: 'confirmDelete', action: async () => {}, timeout: 5000 },
|
||||
{ name: 'verifyUserDeleted', action: async () => {}, timeout: 5000 }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 角色管理工作流
|
||||
*/
|
||||
export const RoleManagementWorkflows = {
|
||||
/**
|
||||
* 创建角色完整流程
|
||||
*/
|
||||
createRole: {
|
||||
name: 'createRole',
|
||||
description: '创建新角色的完整流程',
|
||||
steps: [
|
||||
{ name: 'navigateToRoleManagement', action: async () => {}, timeout: 10000 },
|
||||
{ name: 'clickAddRoleButton', action: async () => {}, timeout: 5000 },
|
||||
{ name: 'fillRoleForm', action: async () => {}, timeout: 10000 },
|
||||
{ name: 'assignPermissions', action: async () => {}, timeout: 10000 },
|
||||
{ name: 'submitRoleForm', action: async () => {}, timeout: 10000 },
|
||||
{ name: 'verifyRoleCreated', action: async () => {}, timeout: 5000 }
|
||||
]
|
||||
},
|
||||
|
||||
/**
|
||||
* 编辑角色完整流程
|
||||
*/
|
||||
editRole: {
|
||||
name: 'editRole',
|
||||
description: '编辑角色的完整流程',
|
||||
steps: [
|
||||
{ name: 'navigateToRoleManagement', action: async () => {}, timeout: 10000 },
|
||||
{ name: 'searchRole', action: async () => {}, timeout: 10000 },
|
||||
{ name: 'clickEditButton', action: async () => {}, timeout: 5000 },
|
||||
{ name: 'updateRoleForm', action: async () => {}, timeout: 10000 },
|
||||
{ name: 'updatePermissions', action: async () => {}, timeout: 10000 },
|
||||
{ name: 'submitRoleForm', action: async () => {}, timeout: 10000 },
|
||||
{ name: 'verifyRoleUpdated', action: async () => {}, timeout: 5000 }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 菜单管理工作流
|
||||
*/
|
||||
export const MenuManagementWorkflows = {
|
||||
/**
|
||||
* 创建菜单完整流程
|
||||
*/
|
||||
createMenu: {
|
||||
name: 'createMenu',
|
||||
description: '创建新菜单的完整流程',
|
||||
steps: [
|
||||
{ name: 'navigateToMenuManagement', action: async () => {}, timeout: 10000 },
|
||||
{ name: 'clickAddMenuButton', action: async () => {}, timeout: 5000 },
|
||||
{ name: 'fillMenuForm', action: async () => {}, timeout: 10000 },
|
||||
{ name: 'selectParentMenu', action: async () => {}, timeout: 5000 },
|
||||
{ name: 'submitMenuForm', action: async () => {}, timeout: 10000 },
|
||||
{ name: 'verifyMenuCreated', action: async () => {}, timeout: 5000 }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 认证工作流
|
||||
*/
|
||||
export const AuthenticationWorkflows = {
|
||||
/**
|
||||
* 用户登录流程
|
||||
*/
|
||||
login: {
|
||||
name: 'login',
|
||||
description: '用户登录的完整流程',
|
||||
steps: [
|
||||
{ name: 'navigateToLogin', action: async () => {}, timeout: 10000 },
|
||||
{ name: 'fillUsername', action: async () => {}, timeout: 5000 },
|
||||
{ name: 'fillPassword', action: async () => {}, timeout: 5000 },
|
||||
{ name: 'clickLoginButton', action: async () => {}, timeout: 5000 },
|
||||
{ name: 'verifyLoginSuccess', action: async () => {}, timeout: 10000 }
|
||||
]
|
||||
},
|
||||
|
||||
/**
|
||||
* 用户登出流程
|
||||
*/
|
||||
logout: {
|
||||
name: 'logout',
|
||||
description: '用户登出的完整流程',
|
||||
steps: [
|
||||
{ name: 'clickUserDropdown', action: async () => {}, timeout: 5000 },
|
||||
{ name: 'clickLogoutButton', action: async () => {}, timeout: 5000 },
|
||||
{ name: 'verifyLogoutSuccess', action: async () => {}, timeout: 10000 }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 端到端业务流程
|
||||
*/
|
||||
export const EndToEndWorkflows = {
|
||||
/**
|
||||
* 完整用户生命周期流程
|
||||
*/
|
||||
userLifecycle: {
|
||||
name: 'userLifecycle',
|
||||
description: '用户从创建到删除的完整生命周期',
|
||||
steps: [
|
||||
{ name: 'login', action: async () => {}, timeout: 15000 },
|
||||
{ name: 'createUser', action: async () => {}, timeout: 30000 },
|
||||
{ name: 'verifyUserInList', action: async () => {}, timeout: 10000 },
|
||||
{ name: 'editUser', action: async () => {}, timeout: 30000 },
|
||||
{ name: 'verifyUserUpdated', action: async () => {}, timeout: 10000 },
|
||||
{ name: 'deleteUser', action: async () => {}, timeout: 20000 },
|
||||
{ name: 'verifyUserDeleted', action: async () => {}, timeout: 10000 },
|
||||
{ name: 'logout', action: async () => {}, timeout: 15000 }
|
||||
]
|
||||
},
|
||||
|
||||
/**
|
||||
* 权限管理完整流程
|
||||
*/
|
||||
permissionManagement: {
|
||||
name: 'permissionManagement',
|
||||
description: '创建角色并分配权限的完整流程',
|
||||
steps: [
|
||||
{ name: 'login', action: async () => {}, timeout: 15000 },
|
||||
{ name: 'createRole', action: async () => {}, timeout: 30000 },
|
||||
{ name: 'assignPermissions', action: async () => {}, timeout: 20000 },
|
||||
{ name: 'createUserWithRole', action: async () => {}, timeout: 30000 },
|
||||
{ name: 'verifyPermissions', action: async () => {}, timeout: 20000 },
|
||||
{ name: 'cleanup', action: async () => {}, timeout: 30000 }
|
||||
]
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,254 @@
|
||||
import { test } from '@playwright/test';
|
||||
import { testLogger } from '../shared/utils/test-logger';
|
||||
import { testDataGenerator, UserData, RoleData, MenuData } from './test-data';
|
||||
|
||||
export type DataStrategy = 'real' | 'mock' | 'hybrid';
|
||||
|
||||
export interface DataStrategyConfig {
|
||||
strategy: DataStrategy;
|
||||
mockEnabled: boolean;
|
||||
realDataEnabled: boolean;
|
||||
autoCleanup: boolean;
|
||||
}
|
||||
|
||||
export interface DataSnapshot {
|
||||
name: string;
|
||||
timestamp: number;
|
||||
data: Map<string, any[]>;
|
||||
}
|
||||
|
||||
export class DataStrategyManager {
|
||||
private strategy: DataStrategy;
|
||||
private config: DataStrategyConfig;
|
||||
private snapshots: Map<string, DataSnapshot> = new Map();
|
||||
private testData: Map<string, any[]> = new Map();
|
||||
|
||||
constructor(config?: Partial<DataStrategyConfig>) {
|
||||
this.config = {
|
||||
strategy: 'hybrid',
|
||||
mockEnabled: true,
|
||||
realDataEnabled: true,
|
||||
autoCleanup: true,
|
||||
...config
|
||||
};
|
||||
this.strategy = this.config.strategy;
|
||||
|
||||
testLogger.info(`DataStrategyManager initialized with strategy: ${this.strategy}`);
|
||||
}
|
||||
|
||||
setStrategy(strategy: DataStrategy): void {
|
||||
this.strategy = strategy;
|
||||
this.config.strategy = strategy;
|
||||
testLogger.info(`Data strategy changed to: ${strategy}`);
|
||||
}
|
||||
|
||||
getStrategy(): DataStrategy {
|
||||
return this.strategy;
|
||||
}
|
||||
|
||||
getConfig(): DataStrategyConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
selectDataSource(testTags: string[]): DataStrategy {
|
||||
testLogger.debug(`Selecting data source for tags: ${testTags.join(', ')}`);
|
||||
|
||||
if (this.config.strategy === 'real') {
|
||||
testLogger.debug('Using real data strategy (forced)');
|
||||
return 'real';
|
||||
}
|
||||
|
||||
if (this.config.strategy === 'mock') {
|
||||
testLogger.debug('Using mock data strategy (forced)');
|
||||
return 'mock';
|
||||
}
|
||||
|
||||
if (this.config.strategy === 'hybrid') {
|
||||
return this.selectHybridDataSource(testTags);
|
||||
}
|
||||
|
||||
return 'mock';
|
||||
}
|
||||
|
||||
private selectHybridDataSource(testTags: string[]): DataStrategy {
|
||||
const hasSmokeTag = testTags.includes('@smoke');
|
||||
const hasRegressionTag = testTags.includes('@regression');
|
||||
const hasFullTag = testTags.includes('@full');
|
||||
const hasCriticalTag = testTags.includes('@critical');
|
||||
|
||||
if (hasCriticalTag) {
|
||||
testLogger.debug('Hybrid strategy: Using real data for @critical tests');
|
||||
return 'real';
|
||||
}
|
||||
|
||||
if (hasFullTag) {
|
||||
testLogger.debug('Hybrid strategy: Using real data for @full tests');
|
||||
return 'real';
|
||||
}
|
||||
|
||||
if (hasRegressionTag) {
|
||||
testLogger.debug('Hybrid strategy: Using hybrid data for @regression tests');
|
||||
return 'hybrid';
|
||||
}
|
||||
|
||||
if (hasSmokeTag) {
|
||||
testLogger.debug('Hybrid strategy: Using mock data for @smoke tests');
|
||||
return 'mock';
|
||||
}
|
||||
|
||||
testLogger.debug('Hybrid strategy: Defaulting to mock data');
|
||||
return 'mock';
|
||||
}
|
||||
|
||||
async createData(dataType: string, data: any, testTags: string[] = []): Promise<any> {
|
||||
const dataSource = this.selectDataSource(testTags);
|
||||
testLogger.info(`Creating ${dataType} using ${dataSource} data source`);
|
||||
|
||||
if (dataSource === 'mock') {
|
||||
return this.createMockData(dataType, data);
|
||||
}
|
||||
|
||||
if (dataSource === 'real') {
|
||||
return this.createRealData(dataType, data);
|
||||
}
|
||||
|
||||
return this.createHybridData(dataType, data);
|
||||
}
|
||||
|
||||
private createMockData(dataType: string, data: any): any {
|
||||
testLogger.debug(`Creating mock data for ${dataType}`);
|
||||
|
||||
switch (dataType) {
|
||||
case 'user':
|
||||
return testDataGenerator.generateUserData(data);
|
||||
case 'role':
|
||||
return testDataGenerator.generateRoleData(data);
|
||||
case 'menu':
|
||||
return testDataGenerator.generateMenuData(data);
|
||||
case 'permission':
|
||||
return testDataGenerator.generatePermissionData(data);
|
||||
default:
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
private createRealData(dataType: string, data: any): any {
|
||||
testLogger.debug(`Creating real data for ${dataType} (requires API connection)`);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private createHybridData(dataType: string, data: any): any {
|
||||
testLogger.debug(`Creating hybrid data for ${dataType}`);
|
||||
|
||||
const mockData = this.createMockData(dataType, data);
|
||||
const realData = this.createRealData(dataType, data);
|
||||
|
||||
return {
|
||||
...mockData,
|
||||
_dataSource: 'hybrid',
|
||||
_mockData: mockData,
|
||||
_realData: realData
|
||||
};
|
||||
}
|
||||
|
||||
async cleanupData(dataType: string, dataId: string | number): Promise<void> {
|
||||
testLogger.info(`Cleaning up ${dataType} with id: ${dataId}`);
|
||||
|
||||
if (this.config.autoCleanup) {
|
||||
this.removeTestData(dataType, dataId);
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupAll(): Promise<void> {
|
||||
testLogger.info('Cleaning up all test data');
|
||||
|
||||
this.testData.clear();
|
||||
this.snapshots.clear();
|
||||
|
||||
testLogger.info('All test data cleaned up');
|
||||
}
|
||||
|
||||
async createSnapshot(snapshotName: string): Promise<DataSnapshot> {
|
||||
testLogger.info(`Creating snapshot: ${snapshotName}`);
|
||||
|
||||
const snapshot: DataSnapshot = {
|
||||
name: snapshotName,
|
||||
timestamp: Date.now(),
|
||||
data: new Map(this.testData)
|
||||
};
|
||||
|
||||
this.snapshots.set(snapshotName, snapshot);
|
||||
testLogger.info(`Snapshot created: ${snapshotName} at ${new Date(snapshot.timestamp).toISOString()}`);
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async rollbackToSnapshot(snapshotName: string): Promise<void> {
|
||||
testLogger.info(`Rolling back to snapshot: ${snapshotName}`);
|
||||
|
||||
const snapshot = this.snapshots.get(snapshotName);
|
||||
if (!snapshot) {
|
||||
throw new Error(`Snapshot not found: ${snapshotName}`);
|
||||
}
|
||||
|
||||
this.testData = new Map(snapshot.data);
|
||||
testLogger.info(`Rolled back to snapshot: ${snapshotName}`);
|
||||
}
|
||||
|
||||
private addTestData(dataType: string, data: any): void {
|
||||
if (!this.testData.has(dataType)) {
|
||||
this.testData.set(dataType, []);
|
||||
}
|
||||
this.testData.get(dataType)!.push(data);
|
||||
}
|
||||
|
||||
private removeTestData(dataType: string, dataId: string | number): void {
|
||||
const items = this.testData.get(dataType);
|
||||
if (items) {
|
||||
const index = items.findIndex(item => item.id === dataId);
|
||||
if (index !== -1) {
|
||||
items.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getTestData(dataType: string): any[] {
|
||||
return this.testData.get(dataType) || [];
|
||||
}
|
||||
|
||||
getSnapshot(snapshotName: string): DataSnapshot | undefined {
|
||||
return this.snapshots.get(snapshotName);
|
||||
}
|
||||
|
||||
getAllSnapshots(): DataSnapshot[] {
|
||||
return Array.from(this.snapshots.values());
|
||||
}
|
||||
|
||||
deleteSnapshot(snapshotName: string): void {
|
||||
this.snapshots.delete(snapshotName);
|
||||
testLogger.info(`Snapshot deleted: ${snapshotName}`);
|
||||
}
|
||||
|
||||
getStatistics(): {
|
||||
totalTestData: number;
|
||||
totalSnapshots: number;
|
||||
strategy: DataStrategy;
|
||||
config: DataStrategyConfig;
|
||||
} {
|
||||
let totalTestData = 0;
|
||||
const testDataValues = Array.from(this.testData.values());
|
||||
for (const items of testDataValues) {
|
||||
totalTestData += items.length;
|
||||
}
|
||||
|
||||
return {
|
||||
totalTestData,
|
||||
totalSnapshots: this.snapshots.size,
|
||||
strategy: this.strategy,
|
||||
config: this.getConfig()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const dataStrategyManager = new DataStrategyManager();
|
||||
@@ -0,0 +1,303 @@
|
||||
import { Page, Route } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Mock管理器
|
||||
* 提供统一的API Mock功能
|
||||
*/
|
||||
|
||||
export interface MockConfig {
|
||||
url: string | RegExp;
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
status?: number;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export class MockManager {
|
||||
private page: Page;
|
||||
private mocks: Map<string, MockConfig> = new Map();
|
||||
private isEnabled: boolean = false;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用Mock
|
||||
*/
|
||||
enable(): void {
|
||||
this.isEnabled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用Mock
|
||||
*/
|
||||
disable(): void {
|
||||
this.isEnabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查Mock是否启用
|
||||
*/
|
||||
isMockEnabled(): boolean {
|
||||
return this.isEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加Mock配置
|
||||
*/
|
||||
addMock(config: MockConfig): void {
|
||||
const key = this.getMockKey(config.url, config.method || 'GET');
|
||||
this.mocks.set(key, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除Mock配置
|
||||
*/
|
||||
removeMock(url: string | RegExp, method: string = 'GET'): void {
|
||||
const key = this.getMockKey(url, method);
|
||||
this.mocks.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有Mock
|
||||
*/
|
||||
clearMocks(): void {
|
||||
this.mocks.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用所有Mock
|
||||
*/
|
||||
async applyMocks(): Promise<void> {
|
||||
if (!this.isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.page.route('**/*', async (route: Route) => {
|
||||
const request = route.request();
|
||||
const url = request.url();
|
||||
const method = request.method();
|
||||
|
||||
// 查找匹配的Mock配置
|
||||
for (const config of this.mocks.values()) {
|
||||
if (this.matchesUrl(url, config.url) && method === (config.method || 'GET')) {
|
||||
// 模拟延迟
|
||||
if (config.delay) {
|
||||
await new Promise(resolve => setTimeout(resolve, config.delay));
|
||||
}
|
||||
|
||||
// 返回Mock响应
|
||||
await route.fulfill({
|
||||
status: config.status || 200,
|
||||
contentType: 'application/json',
|
||||
headers: config.headers || {},
|
||||
body: JSON.stringify(config.body || {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 没有匹配的Mock,继续正常请求
|
||||
await route.continue();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock登录API
|
||||
*/
|
||||
mockLogin(success: boolean = true, delay: number = 500): void {
|
||||
this.addMock({
|
||||
url: /.*\/auth\/login/,
|
||||
method: 'POST',
|
||||
status: success ? 200 : 401,
|
||||
delay,
|
||||
body: success
|
||||
? {
|
||||
token: 'mock-token-' + Date.now(),
|
||||
refreshToken: 'mock-refresh-token',
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
realName: '管理员',
|
||||
email: 'admin@example.com',
|
||||
avatar: '',
|
||||
status: 'active',
|
||||
},
|
||||
permissions: ['*'],
|
||||
}
|
||||
: {
|
||||
message: '用户名或密码错误',
|
||||
code: 401,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock用户列表API
|
||||
*/
|
||||
mockUserList(count: number = 10, delay: number = 300): void {
|
||||
const users = Array.from({ length: count }, (_, i) => ({
|
||||
id: i + 1,
|
||||
username: `user${i + 1}`,
|
||||
realName: `用户${i + 1}`,
|
||||
email: `user${i + 1}@example.com`,
|
||||
phone: `138001380${String(i).padStart(2, '0')}`,
|
||||
status: i % 3 === 0 ? 'inactive' : 'active',
|
||||
createTime: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
this.addMock({
|
||||
url: /.*\/user\/list/,
|
||||
method: 'GET',
|
||||
status: 200,
|
||||
delay,
|
||||
body: {
|
||||
data: users,
|
||||
total: count,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock角色列表API
|
||||
*/
|
||||
mockRoleList(delay: number = 300): void {
|
||||
const roles = [
|
||||
{ id: 1, roleName: '超级管理员', roleKey: 'admin', status: 'active' },
|
||||
{ id: 2, roleName: '普通用户', roleKey: 'user', status: 'active' },
|
||||
{ id: 3, roleName: '访客', roleKey: 'guest', status: 'inactive' },
|
||||
];
|
||||
|
||||
this.addMock({
|
||||
url: /.*\/role\/list/,
|
||||
method: 'GET',
|
||||
status: 200,
|
||||
delay,
|
||||
body: {
|
||||
data: roles,
|
||||
total: roles.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock菜单列表API
|
||||
*/
|
||||
mockMenuList(delay: number = 300): void {
|
||||
const menus = [
|
||||
{ id: 1, menuName: '仪表盘', path: '/dashboard', icon: 'DashboardOutlined', status: 'active' },
|
||||
{ id: 2, menuName: '系统管理', path: '/sys', icon: 'SettingOutlined', status: 'active' },
|
||||
{ id: 3, menuName: '用户管理', path: '/sys/user', parentId: 2, status: 'active' },
|
||||
{ id: 4, menuName: '角色管理', path: '/sys/role', parentId: 2, status: 'active' },
|
||||
];
|
||||
|
||||
this.addMock({
|
||||
url: /.*\/menu\/list/,
|
||||
method: 'GET',
|
||||
status: 200,
|
||||
delay,
|
||||
body: {
|
||||
data: menus,
|
||||
total: menus.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock创建操作
|
||||
*/
|
||||
mockCreate(resource: string, delay: number = 500): void {
|
||||
this.addMock({
|
||||
url: new RegExp(`.*\\/${resource}$`),
|
||||
method: 'POST',
|
||||
status: 200,
|
||||
delay,
|
||||
body: {
|
||||
message: '创建成功',
|
||||
code: 200,
|
||||
data: { id: Date.now() },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock更新操作
|
||||
*/
|
||||
mockUpdate(resource: string, delay: number = 500): void {
|
||||
this.addMock({
|
||||
url: new RegExp(`.*\\/${resource}\\/.*`),
|
||||
method: 'PUT',
|
||||
status: 200,
|
||||
delay,
|
||||
body: {
|
||||
message: '更新成功',
|
||||
code: 200,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock删除操作
|
||||
*/
|
||||
mockDelete(resource: string, delay: number = 500): void {
|
||||
this.addMock({
|
||||
url: new RegExp(`.*\\/${resource}\\/.*`),
|
||||
method: 'DELETE',
|
||||
status: 200,
|
||||
delay,
|
||||
body: {
|
||||
message: '删除成功',
|
||||
code: 200,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock错误响应
|
||||
*/
|
||||
mockError(url: string | RegExp, status: number = 500, message: string = '服务器错误'): void {
|
||||
this.addMock({
|
||||
url,
|
||||
method: 'GET',
|
||||
status,
|
||||
body: {
|
||||
message,
|
||||
code: status,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock网络延迟
|
||||
*/
|
||||
mockDelay(url: string | RegExp, delay: number = 2000): void {
|
||||
this.addMock({
|
||||
url,
|
||||
method: 'GET',
|
||||
delay,
|
||||
body: {},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Mock键
|
||||
*/
|
||||
private getMockKey(url: string | RegExp, method: string): string {
|
||||
const urlStr = url instanceof RegExp ? url.source : url;
|
||||
return `${method}:${urlStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查URL是否匹配
|
||||
*/
|
||||
private matchesUrl(actualUrl: string, configUrl: string | RegExp): boolean {
|
||||
if (configUrl instanceof RegExp) {
|
||||
return configUrl.test(actualUrl);
|
||||
}
|
||||
return actualUrl.includes(configUrl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
export interface TestEnvironment {
|
||||
name: string;
|
||||
baseURL: string;
|
||||
apiBaseURL: string;
|
||||
uniappBaseURL: string;
|
||||
mockEnabled: boolean;
|
||||
timeout: number;
|
||||
credentials: {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class TestConfig {
|
||||
private static instance: TestConfig;
|
||||
private currentEnv: TestEnvironment;
|
||||
|
||||
private constructor() {
|
||||
this.currentEnv = this.loadEnvironment();
|
||||
}
|
||||
|
||||
static getInstance(): TestConfig {
|
||||
if (!TestConfig.instance) {
|
||||
TestConfig.instance = new TestConfig();
|
||||
}
|
||||
return TestConfig.instance;
|
||||
}
|
||||
|
||||
getEnvironment(): TestEnvironment {
|
||||
return this.currentEnv;
|
||||
}
|
||||
|
||||
getBaseURL(): string {
|
||||
return this.currentEnv.baseURL;
|
||||
}
|
||||
|
||||
setEnvironment(envName: string): void {
|
||||
this.currentEnv = this.loadEnvironment(envName);
|
||||
}
|
||||
|
||||
private loadEnvironment(envName?: string): TestEnvironment {
|
||||
const name = envName || process.env.TEST_ENV || 'local';
|
||||
|
||||
return {
|
||||
name,
|
||||
baseURL: process.env.ADMIN_BASE_URL || 'http://localhost:5174',
|
||||
apiBaseURL: process.env.API_BASE_URL || 'http://127.0.0.1:8080',
|
||||
uniappBaseURL: process.env.UNIAPP_BASE_URL || 'http://localhost:8081',
|
||||
mockEnabled: process.env.MOCK_ENABLED === 'true',
|
||||
timeout: parseInt(process.env.TEST_TIMEOUT || '30000'),
|
||||
credentials: {
|
||||
username: process.env.TEST_USERNAME || 'admin',
|
||||
password: process.env.TEST_PASSWORD || 'admin123'
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const testConfig = TestConfig.getInstance();
|
||||
@@ -0,0 +1,529 @@
|
||||
import { test } from '@playwright/test';
|
||||
import { testLogger } from '../shared/utils/test-logger';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface TestCoverage {
|
||||
totalTests: number;
|
||||
passedTests: number;
|
||||
failedTests: number;
|
||||
skippedTests: number;
|
||||
passRate: number;
|
||||
testSuites: TestSuiteCoverage[];
|
||||
executionTime: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface TestSuiteCoverage {
|
||||
name: string;
|
||||
totalTests: number;
|
||||
passedTests: number;
|
||||
failedTests: number;
|
||||
skippedTests: number;
|
||||
passRate: number;
|
||||
tests: TestCaseCoverage[];
|
||||
}
|
||||
|
||||
export interface TestCaseCoverage {
|
||||
name: string;
|
||||
status: 'passed' | 'failed' | 'skipped';
|
||||
duration: number;
|
||||
tags: string[];
|
||||
file: string;
|
||||
}
|
||||
|
||||
export class TestCoverageReporter {
|
||||
private coverageData: TestCoverage;
|
||||
private testResults: Map<string, TestCaseCoverage[]> = new Map();
|
||||
private suiteResults: Map<string, TestSuiteCoverage> = new Map();
|
||||
private startTime: number = 0;
|
||||
private endTime: number = 0;
|
||||
|
||||
constructor() {
|
||||
this.coverageData = {
|
||||
totalTests: 0,
|
||||
passedTests: 0,
|
||||
failedTests: 0,
|
||||
skippedTests: 0,
|
||||
passRate: 0,
|
||||
testSuites: [],
|
||||
executionTime: 0,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
startCoverage(): void {
|
||||
this.startTime = Date.now();
|
||||
testLogger.info('开始收集测试覆盖率数据');
|
||||
}
|
||||
|
||||
endCoverage(): void {
|
||||
this.endTime = Date.now();
|
||||
this.coverageData.executionTime = this.endTime - this.startTime;
|
||||
|
||||
this.calculateCoverage();
|
||||
this.generateReport();
|
||||
|
||||
testLogger.info('测试覆盖率收集完成');
|
||||
testLogger.info(`总测试数: ${this.coverageData.totalTests}`);
|
||||
testLogger.info(`通过测试数: ${this.coverageData.passedTests}`);
|
||||
testLogger.info(`失败测试数: ${this.coverageData.failedTests}`);
|
||||
testLogger.info(`跳过测试数: ${this.coverageData.skippedTests}`);
|
||||
testLogger.info(`通过率: ${this.coverageData.passRate.toFixed(2)}%`);
|
||||
}
|
||||
|
||||
recordTestResult(suiteName: string, testName: string, status: 'passed' | 'failed' | 'skipped', duration: number, tags: string[], file: string): void {
|
||||
const testCase: TestCaseCoverage = {
|
||||
name: testName,
|
||||
status,
|
||||
duration,
|
||||
tags,
|
||||
file
|
||||
};
|
||||
|
||||
if (!this.testResults.has(suiteName)) {
|
||||
this.testResults.set(suiteName, []);
|
||||
}
|
||||
|
||||
this.testResults.get(suiteName)!.push(testCase);
|
||||
}
|
||||
|
||||
private calculateCoverage(): void {
|
||||
let totalTests = 0;
|
||||
let passedTests = 0;
|
||||
let failedTests = 0;
|
||||
let skippedTests = 0;
|
||||
|
||||
for (const [suiteName, testCases] of this.testResults.entries()) {
|
||||
const suiteCoverage = this.calculateSuiteCoverage(suiteName, testCases);
|
||||
this.suiteResults.set(suiteName, suiteCoverage);
|
||||
|
||||
totalTests += suiteCoverage.totalTests;
|
||||
passedTests += suiteCoverage.passedTests;
|
||||
failedTests += suiteCoverage.failedTests;
|
||||
skippedTests += suiteCoverage.skippedTests;
|
||||
}
|
||||
|
||||
this.coverageData.totalTests = totalTests;
|
||||
this.coverageData.passedTests = passedTests;
|
||||
this.coverageData.failedTests = failedTests;
|
||||
this.coverageData.skippedTests = skippedTests;
|
||||
this.coverageData.passRate = totalTests > 0 ? (passedTests / totalTests) * 100 : 0;
|
||||
this.coverageData.testSuites = Array.from(this.suiteResults.values());
|
||||
}
|
||||
|
||||
private calculateSuiteCoverage(suiteName: string, testCases: TestCaseCoverage[]): TestSuiteCoverage {
|
||||
const totalTests = testCases.length;
|
||||
const passedTests = testCases.filter(tc => tc.status === 'passed').length;
|
||||
const failedTests = testCases.filter(tc => tc.status === 'failed').length;
|
||||
const skippedTests = testCases.filter(tc => tc.status === 'skipped').length;
|
||||
const passRate = totalTests > 0 ? (passedTests / totalTests) * 100 : 0;
|
||||
|
||||
return {
|
||||
name: suiteName,
|
||||
totalTests,
|
||||
passedTests,
|
||||
failedTests,
|
||||
skippedTests,
|
||||
passRate,
|
||||
tests: testCases
|
||||
};
|
||||
}
|
||||
|
||||
private generateReport(): void {
|
||||
const reportDir = path.join(process.cwd(), 'test-results', 'coverage');
|
||||
|
||||
if (!fs.existsSync(reportDir)) {
|
||||
fs.mkdirSync(reportDir, { recursive: true });
|
||||
}
|
||||
|
||||
this.generateJSONReport(reportDir);
|
||||
this.generateHTMLReport(reportDir);
|
||||
this.generateMarkdownReport(reportDir);
|
||||
this.generateConsoleReport();
|
||||
}
|
||||
|
||||
private generateJSONReport(reportDir: string): void {
|
||||
const jsonPath = path.join(reportDir, 'coverage.json');
|
||||
fs.writeFileSync(jsonPath, JSON.stringify(this.coverageData, null, 2), 'utf-8');
|
||||
testLogger.info(`JSON覆盖率报告已生成: ${jsonPath}`);
|
||||
}
|
||||
|
||||
private generateHTMLReport(reportDir: string): void {
|
||||
const htmlPath = path.join(reportDir, 'coverage.html');
|
||||
const html = this.generateHTMLContent();
|
||||
fs.writeFileSync(htmlPath, html, 'utf-8');
|
||||
testLogger.info(`HTML覆盖率报告已生成: ${htmlPath}`);
|
||||
}
|
||||
|
||||
private generateMarkdownReport(reportDir: string): void {
|
||||
const mdPath = path.join(reportDir, 'coverage.md');
|
||||
const markdown = this.generateMarkdownContent();
|
||||
fs.writeFileSync(mdPath, markdown, 'utf-8');
|
||||
testLogger.info(`Markdown覆盖率报告已生成: ${mdPath}`);
|
||||
}
|
||||
|
||||
private generateHTMLContent(): string {
|
||||
const { totalTests, passedTests, failedTests, skippedTests, passRate, executionTime, testSuites, timestamp } = this.coverageData;
|
||||
|
||||
const passRateColor = passRate >= 80 ? '#52c41a' : passRate >= 60 ? '#faad14' : '#f5222d';
|
||||
const passRateClass = passRate >= 80 ? 'success' : passRate >= 60 ? 'warning' : 'danger';
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>测试覆盖率报告</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background-color: 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;
|
||||
text-align: center;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.header p {
|
||||
margin: 10px 0 0 0;
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
padding: 30px;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
.summary-card {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
text-align: center;
|
||||
}
|
||||
.summary-card .label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.summary-card .value {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
.summary-card .value.${passRateClass} {
|
||||
color: ${passRateColor};
|
||||
}
|
||||
.suites {
|
||||
padding: 30px;
|
||||
}
|
||||
.suites h2 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
}
|
||||
.suite {
|
||||
margin-bottom: 30px;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.suite-header {
|
||||
background-color: #f8f9fa;
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.suite-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
}
|
||||
.suite-stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.suite-stats span {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.suite-stats .passed {
|
||||
background-color: #e6f7ff;
|
||||
color: #4a5568;
|
||||
}
|
||||
.suite-stats .failed {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
.suite-stats .skipped {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
.suite-stats .rate {
|
||||
background-color: ${passRateColor};
|
||||
color: white;
|
||||
}
|
||||
.test-cases {
|
||||
padding: 20px;
|
||||
}
|
||||
.test-case {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.test-case:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.test-case .name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
.test-case .status {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.test-case .status.passed {
|
||||
background-color: #d4edda;
|
||||
color: #0f5132;
|
||||
}
|
||||
.test-case .status.failed {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
.test-case .status.skipped {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
.test-case .duration {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
min-width: 80px;
|
||||
text-align: right;
|
||||
}
|
||||
.test-case .tags {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
.test-case .tag {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
background-color: #e9ecef;
|
||||
color: #495057;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🧪 测试覆盖率报告</h1>
|
||||
<p>生成时间: ${timestamp}</p>
|
||||
<p>执行时间: ${(executionTime / 1000).toFixed(2)}秒</p>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="summary-card">
|
||||
<div class="label">总测试数</div>
|
||||
<div class="value">${totalTests}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="label">通过测试</div>
|
||||
<div class="value" style="color: #52c41a">${passedTests}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="label">失败测试</div>
|
||||
<div class="value" style="color: #f5222d">${failedTests}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="label">跳过测试</div>
|
||||
<div class="value" style="color: #faad14">${skippedTests}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="label">通过率</div>
|
||||
<div class="value ${passRateClass}">${passRate.toFixed(2)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="suites">
|
||||
<h2>📊 测试套件详情</h2>
|
||||
${testSuites.map(suite => `
|
||||
<div class="suite">
|
||||
<div class="suite-header">
|
||||
<h3>${suite.name}</h3>
|
||||
<div class="suite-stats">
|
||||
<span class="passed">✓ ${suite.passedTests}</span>
|
||||
<span class="failed">✗ ${suite.failedTests}</span>
|
||||
<span class="skipped">⊘ ${suite.skippedTests}</span>
|
||||
<span class="rate">${suite.passRate.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="test-cases">
|
||||
${suite.tests.map(testCase => `
|
||||
<div class="test-case">
|
||||
<div class="name">${testCase.name}</div>
|
||||
<div class="status ${testCase.status}">${testCase.status}</div>
|
||||
<div class="duration">${testCase.duration}ms</div>
|
||||
<div class="tags">
|
||||
${testCase.tags.map(tag => `<span class="tag">${tag}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Generated by TestCoverageReporter</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
private generateMarkdownContent(): string {
|
||||
const { totalTests, passedTests, failedTests, skippedTests, passRate, executionTime, testSuites, timestamp } = this.coverageData;
|
||||
|
||||
return `# 测试覆盖率报告
|
||||
|
||||
## 概要
|
||||
|
||||
- **生成时间**: ${timestamp}
|
||||
- **执行时间**: ${(executionTime / 1000).toFixed(2)}秒
|
||||
- **总测试数**: ${totalTests}
|
||||
- **通过测试**: ${passedTests}
|
||||
- **失败测试**: ${failedTests}
|
||||
- **跳过测试**: ${skippedTests}
|
||||
- **通过率**: ${passRate.toFixed(2)}%
|
||||
|
||||
## 测试套件详情
|
||||
|
||||
${testSuites.map(suite => `
|
||||
### ${suite.name}
|
||||
|
||||
- **总测试数**: ${suite.totalTests}
|
||||
- **通过测试**: ${suite.passedTests}
|
||||
- **失败测试**: ${suite.failedTests}
|
||||
- **跳过测试**: ${suite.skippedTests}
|
||||
- **通过率**: ${suite.passRate.toFixed(2)}%
|
||||
|
||||
#### 测试用例
|
||||
|
||||
| 测试用例 | 状态 | 耗时 | 标签 |
|
||||
|---------|------|------|------|
|
||||
${suite.tests.map(testCase => `
|
||||
| ${testCase.name} | ${testCase.status} | ${testCase.duration}ms | ${testCase.tags.join(', ')} |
|
||||
`).join('')}
|
||||
`).join('')}
|
||||
|
||||
## 总结
|
||||
|
||||
${passRate >= 80 ? '✅ 测试覆盖率优秀' : passRate >= 60 ? '⚠️ 测试覆盖率良好' : '❌ 测试覆盖率需要改进'}
|
||||
|
||||
---
|
||||
*Generated by TestCoverageReporter*
|
||||
`;
|
||||
}
|
||||
|
||||
private generateConsoleReport(): void {
|
||||
const { totalTests, passedTests, failedTests, skippedTests, passRate, executionTime } = this.coverageData;
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('📊 测试覆盖率报告');
|
||||
console.log('='.repeat(60));
|
||||
console.log(`生成时间: ${this.coverageData.timestamp}`);
|
||||
console.log(`执行时间: ${(executionTime / 1000).toFixed(2)}秒`);
|
||||
console.log('');
|
||||
console.log('📈 总体统计:');
|
||||
console.log(` 总测试数: ${totalTests}`);
|
||||
console.log(` 通过测试: ${passedTests} (${(passedTests / totalTests * 100).toFixed(2)}%)`);
|
||||
console.log(` 失败测试: ${failedTests} (${(failedTests / totalTests * 100).toFixed(2)}%)`);
|
||||
console.log(` 跳过测试: ${skippedTests} (${(skippedTests / totalTests * 100).toFixed(2)}%)`);
|
||||
console.log(` 通过率: ${passRate.toFixed(2)}%`);
|
||||
console.log('');
|
||||
console.log('📋 测试套件详情:');
|
||||
|
||||
for (const suite of this.coverageData.testSuites) {
|
||||
console.log(`\n ${suite.name}:`);
|
||||
console.log(` 总测试数: ${suite.totalTests}`);
|
||||
console.log(` 通过测试: ${suite.passedTests} (${suite.passRate.toFixed(2)}%)`);
|
||||
console.log(` 失败测试: ${suite.failedTests}`);
|
||||
console.log(` 跳过测试: ${suite.skippedTests}`);
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
|
||||
if (passRate >= 80) {
|
||||
console.log('✅ 测试覆盖率优秀');
|
||||
} else if (passRate >= 60) {
|
||||
console.log('⚠️ 测试覆盖率良好');
|
||||
} else {
|
||||
console.log('❌ 测试覆盖率需要改进');
|
||||
}
|
||||
|
||||
console.log('='.repeat(60) + '\n');
|
||||
}
|
||||
|
||||
getCoverage(): TestCoverage {
|
||||
return this.coverageData;
|
||||
}
|
||||
|
||||
getSuiteCoverage(suiteName: string): TestSuiteCoverage | undefined {
|
||||
return this.suiteResults.get(suiteName);
|
||||
}
|
||||
|
||||
exportCoverage(format: 'json' | 'html' | 'markdown' = 'json'): string {
|
||||
switch (format) {
|
||||
case 'json':
|
||||
return JSON.stringify(this.coverageData, null, 2);
|
||||
case 'html':
|
||||
return this.generateHTMLContent();
|
||||
case 'markdown':
|
||||
return this.generateMarkdownContent();
|
||||
default:
|
||||
return JSON.stringify(this.coverageData, null, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const testCoverageReporter = new TestCoverageReporter();
|
||||
@@ -0,0 +1,254 @@
|
||||
import { testDataGenerator, UserData } from './test-data';
|
||||
|
||||
export class TestDataManager {
|
||||
private request: APIRequestContext;
|
||||
private authToken: string;
|
||||
private testData: Map<string, any[]> = new Map();
|
||||
|
||||
constructor(request: APIRequestContext, authToken: string) {
|
||||
this.request = request;
|
||||
this.authToken = authToken;
|
||||
}
|
||||
|
||||
async createTestUser(overrides: Partial<User> = {}): Promise<User> {
|
||||
const userData = TestDataFactory.generateUser(overrides);
|
||||
|
||||
const response = await this.request.post('/api/sys/user', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.authToken}`
|
||||
},
|
||||
data: userData
|
||||
});
|
||||
|
||||
const body = await response.json();
|
||||
const user = body.data || body;
|
||||
|
||||
this.addTestData('user', user);
|
||||
return user;
|
||||
}
|
||||
|
||||
async createTestUserFromGenerator(overrides: Partial<UserData> = {}): Promise<UserData> {
|
||||
const userData = testDataGenerator.generateUserData(overrides);
|
||||
|
||||
const response = await this.request.post('/api/sys/user', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.authToken}`
|
||||
},
|
||||
data: userData
|
||||
});
|
||||
|
||||
const body = await response.json();
|
||||
const user = body.data || body;
|
||||
|
||||
this.addTestData('user', user);
|
||||
return user;
|
||||
}
|
||||
|
||||
async createTestRole(overrides: Partial<Role> = {}): Promise<Role> {
|
||||
const roleData = TestDataFactory.generateRole(overrides);
|
||||
|
||||
const response = await this.request.post('/api/sys/role', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.authToken}`
|
||||
},
|
||||
data: roleData
|
||||
});
|
||||
|
||||
const body = await response.json();
|
||||
const role = body.data || body;
|
||||
|
||||
this.addTestData('role', role);
|
||||
return role;
|
||||
}
|
||||
|
||||
async createTestMenu(overrides: Partial<Menu> = {}): Promise<Menu> {
|
||||
const menuData = TestDataFactory.generateMenu(overrides);
|
||||
|
||||
const response = await this.request.post('/api/sys/menu', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.authToken}`
|
||||
},
|
||||
data: menuData
|
||||
});
|
||||
|
||||
const body = await response.json();
|
||||
const menu = body.data || body;
|
||||
|
||||
this.addTestData('menu', menu);
|
||||
return menu;
|
||||
}
|
||||
|
||||
async getTestUser(username: string): Promise<User | null> {
|
||||
const response = await this.request.get(`/api/sys/user/username/${username}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.authToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status() === 200) {
|
||||
const body = await response.json();
|
||||
return body.data || body;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async updateTestUser(userId: number | string, updates: Partial<User>): Promise<User> {
|
||||
const response = await this.request.put('/api/sys/user', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.authToken}`
|
||||
},
|
||||
data: { id: userId, ...updates }
|
||||
});
|
||||
|
||||
const body = await response.json();
|
||||
return body.data || body;
|
||||
}
|
||||
|
||||
async deleteTestUser(userId: number | string): Promise<void> {
|
||||
await this.request.delete(`/api/sys/user/${userId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.authToken}`
|
||||
}
|
||||
});
|
||||
|
||||
this.removeTestData('user', userId);
|
||||
}
|
||||
|
||||
async deleteTestRole(roleId: number | string): Promise<void> {
|
||||
await this.request.delete(`/api/sys/role/${roleId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.authToken}`
|
||||
}
|
||||
});
|
||||
|
||||
this.removeTestData('role', roleId);
|
||||
}
|
||||
|
||||
async deleteTestMenu(menuId: number | string): Promise<void> {
|
||||
await this.request.delete(`/api/sys/menu/${menuId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.authToken}`
|
||||
}
|
||||
});
|
||||
|
||||
this.removeTestData('menu', menuId);
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
const cleanupPromises: Promise<void>[] = [];
|
||||
|
||||
for (const [type, items] of this.testData) {
|
||||
for (const item of items) {
|
||||
if (item.id) {
|
||||
switch (type) {
|
||||
case 'user':
|
||||
cleanupPromises.push(this.deleteTestUser(item.id));
|
||||
break;
|
||||
case 'role':
|
||||
cleanupPromises.push(this.deleteTestRole(item.id));
|
||||
break;
|
||||
case 'menu':
|
||||
cleanupPromises.push(this.deleteTestMenu(item.id));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(cleanupPromises);
|
||||
this.testData.clear();
|
||||
}
|
||||
|
||||
private addTestData(type: string, data: any): void {
|
||||
if (!this.testData.has(type)) {
|
||||
this.testData.set(type, []);
|
||||
}
|
||||
this.testData.get(type)!.push(data);
|
||||
}
|
||||
|
||||
private removeTestData(type: string, id: number | string): void {
|
||||
const items = this.testData.get(type);
|
||||
if (items) {
|
||||
const index = items.findIndex(item => item.id === id);
|
||||
if (index !== -1) {
|
||||
items.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class TestDataFactory {
|
||||
static generateUser(overrides: Partial<User> = {}): User {
|
||||
const timestamp = Date.now();
|
||||
return {
|
||||
username: `e2e_test_user_${timestamp}`,
|
||||
password: 'Test@123456',
|
||||
realName: 'E2E测试用户',
|
||||
email: `e2e_${timestamp}@example.com`,
|
||||
phone: '13800138000',
|
||||
status: 1,
|
||||
gender: 1,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
static generateRole(overrides: Partial<Role> = {}): Role {
|
||||
const timestamp = Date.now();
|
||||
return {
|
||||
roleName: `E2E测试角色_${timestamp}`,
|
||||
roleCode: `e2e_test_role_${timestamp}`,
|
||||
description: 'E2E测试角色描述',
|
||||
status: 1,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
static generateMenu(overrides: Partial<Menu> = {}): Menu {
|
||||
const timestamp = Date.now();
|
||||
return {
|
||||
name: `E2E测试菜单_${timestamp}`,
|
||||
code: `e2e_test_menu_${timestamp}`,
|
||||
path: `/e2e-test-menu-${timestamp}`,
|
||||
icon: 'SettingOutlined',
|
||||
sortOrder: 10,
|
||||
status: 1,
|
||||
parentId: 0,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id?: number | string;
|
||||
username: string;
|
||||
password?: string;
|
||||
realName?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
status?: number;
|
||||
gender?: number;
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id?: number | string;
|
||||
roleName: string;
|
||||
roleCode: string;
|
||||
description?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface Menu {
|
||||
id?: number | string;
|
||||
name: string;
|
||||
code: string;
|
||||
path?: string;
|
||||
icon?: string;
|
||||
sortOrder?: number;
|
||||
status?: number;
|
||||
parentId?: number | string;
|
||||
}
|
||||
@@ -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,109 @@
|
||||
export class TestLogger {
|
||||
private logs: LogEntry[] = [];
|
||||
private currentTest: string | null = null;
|
||||
private currentStep: string | null = null;
|
||||
|
||||
startTest(testName: string): void {
|
||||
this.currentTest = testName;
|
||||
this.currentStep = null;
|
||||
this.log('info', `开始测试: ${testName}`);
|
||||
}
|
||||
|
||||
endTest(testName: string, status: 'passed' | 'failed', error?: Error): void {
|
||||
this.log('info', `结束测试: ${testName} - ${status}`);
|
||||
if (error) {
|
||||
this.log('error', `测试失败: ${error.message}`, error);
|
||||
}
|
||||
this.currentTest = null;
|
||||
this.currentStep = null;
|
||||
}
|
||||
|
||||
startStep(stepName: string): void {
|
||||
this.currentStep = stepName;
|
||||
this.log('info', ` 开始步骤: ${stepName}`);
|
||||
}
|
||||
|
||||
endStep(stepName: string, status: 'passed' | 'failed', error?: Error): void {
|
||||
this.log('info', ` 结束步骤: ${stepName} - ${status}`);
|
||||
if (error) {
|
||||
this.log('error', ` 步骤失败: ${error.message}`, error);
|
||||
}
|
||||
this.currentStep = null;
|
||||
}
|
||||
|
||||
debug(message: string): void {
|
||||
this.log('debug', message);
|
||||
}
|
||||
|
||||
info(message: string): void {
|
||||
this.log('info', message);
|
||||
}
|
||||
|
||||
warn(message: string): void {
|
||||
this.log('warn', message);
|
||||
}
|
||||
|
||||
error(message: string, error?: Error): void {
|
||||
this.log('error', message, error);
|
||||
}
|
||||
|
||||
success(message: string): void {
|
||||
this.log('info', `✅ ${message}`);
|
||||
}
|
||||
|
||||
private log(level: LogLevel, message: string, error?: Error): void {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message,
|
||||
test: this.currentTest,
|
||||
step: this.currentStep,
|
||||
error
|
||||
};
|
||||
this.logs.push(entry);
|
||||
this.printLog(entry);
|
||||
}
|
||||
|
||||
private printLog(entry: LogEntry): void {
|
||||
const timestamp = entry.timestamp.split('T')[1].split('.')[0];
|
||||
const prefix = entry.step ? ` ${entry.step}` : entry.test || 'SYSTEM';
|
||||
const levelIcon = {
|
||||
debug: '🔍',
|
||||
info: 'ℹ️',
|
||||
warn: '⚠️',
|
||||
error: '❌'
|
||||
}[entry.level];
|
||||
|
||||
console.log(`${timestamp} ${levelIcon} [${prefix}] ${entry.message}`);
|
||||
|
||||
if (entry.error) {
|
||||
const errorMessage = entry.error.stack || entry.error.message || String(entry.error);
|
||||
console.error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
getLogs(): LogEntry[] {
|
||||
return this.logs;
|
||||
}
|
||||
|
||||
clearLogs(): void {
|
||||
this.logs = [];
|
||||
}
|
||||
|
||||
exportLogs(): string {
|
||||
return JSON.stringify(this.logs, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: string;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
test?: string | null;
|
||||
step?: string | null;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
export const testLogger = new TestLogger();
|
||||
@@ -0,0 +1,208 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
export class TestReporter {
|
||||
private results: TestResult[] = [];
|
||||
private startTime: number = 0;
|
||||
|
||||
startReport(): void {
|
||||
this.startTime = Date.now();
|
||||
console.log('📊 开始生成测试报告');
|
||||
}
|
||||
|
||||
recordResult(result: TestResult): void {
|
||||
this.results.push(result);
|
||||
}
|
||||
|
||||
async generateAllReports(outputDir: string): Promise<void> {
|
||||
await this.generateJSONReport(join(outputDir, 'test-results.json'));
|
||||
await this.generateHTMLReport(join(outputDir, 'test-results.html'));
|
||||
await this.generateJUnitReport(join(outputDir, 'junit-report.xml'));
|
||||
await this.generateSummaryReport(outputDir);
|
||||
}
|
||||
|
||||
async generateJSONReport(outputPath: string): Promise<void> {
|
||||
const report = {
|
||||
summary: this.getSummary(),
|
||||
results: this.results,
|
||||
metadata: {
|
||||
generatedAt: new Date().toISOString(),
|
||||
duration: Date.now() - this.startTime
|
||||
}
|
||||
};
|
||||
|
||||
await fs.writeFile(outputPath, JSON.stringify(report, null, 2));
|
||||
console.log(`✅ JSON报告已生成: ${outputPath}`);
|
||||
}
|
||||
|
||||
async generateHTMLReport(outputPath: string): Promise<void> {
|
||||
const summary = this.getSummary();
|
||||
const html = this.generateHTML(summary, this.results);
|
||||
|
||||
await fs.writeFile(outputPath, html);
|
||||
console.log(`✅ HTML报告已生成: ${outputPath}`);
|
||||
}
|
||||
|
||||
async generateJUnitReport(outputPath: string): Promise<void> {
|
||||
const summary = this.getSummary();
|
||||
const xml = this.generateJUnitXML(summary, this.results);
|
||||
|
||||
await fs.writeFile(outputPath, xml);
|
||||
console.log(`✅ JUnit报告已生成: ${outputPath}`);
|
||||
}
|
||||
|
||||
async generateSummaryReport(outputDir: string): Promise<void> {
|
||||
const summary = this.getSummary();
|
||||
const summaryPath = join(outputDir, 'summary.txt');
|
||||
const summaryText = this.generateSummaryText(summary);
|
||||
|
||||
await fs.writeFile(summaryPath, summaryText);
|
||||
console.log(`✅ 摘要报告已生成: ${summaryPath}`);
|
||||
}
|
||||
|
||||
private getSummary(): TestSummary {
|
||||
const passed = this.results.filter(r => r.status === 'passed').length;
|
||||
const failed = this.results.filter(r => r.status === 'failed').length;
|
||||
const skipped = this.results.filter(r => r.status === 'skipped').length;
|
||||
const total = this.results.length;
|
||||
const duration = this.results.reduce((sum, r) => sum + r.duration, 0);
|
||||
|
||||
return {
|
||||
total,
|
||||
passed,
|
||||
failed,
|
||||
skipped,
|
||||
passRate: total > 0 ? (passed / total * 100).toFixed(2) : '0',
|
||||
duration,
|
||||
startTime: new Date(this.startTime).toISOString(),
|
||||
endTime: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
private generateHTML(summary: TestSummary, results: TestResult[]): string {
|
||||
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>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
.summary { background: #f5f5f5; padding: 20px; border-radius: 5px; margin-bottom: 20px; }
|
||||
.passed { color: green; }
|
||||
.failed { color: red; }
|
||||
.skipped { color: orange; }
|
||||
.test-result { border: 1px solid #ddd; padding: 10px; margin: 10px 0; border-radius: 5px; }
|
||||
.test-result.failed { background: #ffe6e6; }
|
||||
.test-result.passed { background: #e6ffe6; }
|
||||
.test-result.skipped { background: #fff3cd; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background: #f2f2f2; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>E2E测试报告</h1>
|
||||
<div class="summary">
|
||||
<h2>测试摘要</h2>
|
||||
<p>总测试数: ${summary.total}</p>
|
||||
<p>通过: <span class="passed">${summary.passed}</span></p>
|
||||
<p>失败: <span class="failed">${summary.failed}</span></p>
|
||||
<p>跳过: <span class="skipped">${summary.skipped}</span></p>
|
||||
<p>通过率: ${summary.passRate}%</p>
|
||||
<p>总耗时: ${summary.duration}ms</p>
|
||||
</div>
|
||||
<h2>测试结果</h2>
|
||||
${results.map(result => `
|
||||
<div class="test-result ${result.status}">
|
||||
<h3>${result.testName}</h3>
|
||||
<p>状态: <span class="${result.status}">${result.status}</span></p>
|
||||
<p>耗时: ${result.duration}ms</p>
|
||||
${result.error ? `<p>错误: ${result.error.message}</p>` : ''}
|
||||
${result.steps.length > 0 ? `
|
||||
<h4>测试步骤</h4>
|
||||
<ul>
|
||||
${result.steps.map(step => `
|
||||
<li>${step.name} - <span class="${step.status}">${step.status}</span></li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
private generateJUnitXML(summary: TestSummary, results: TestResult[]): string {
|
||||
const testCases = results.map(result => {
|
||||
const testCase = `
|
||||
<testcase name="${result.testName}" time="${result.duration / 1000}">
|
||||
${result.status === 'failed' ? `
|
||||
<failure message="${result.error?.message || 'Test failed'}">
|
||||
${result.error?.stack || ''}
|
||||
</failure>
|
||||
` : ''}
|
||||
${result.status === 'skipped' ? '<skipped/>' : ''}
|
||||
</testcase>`;
|
||||
return testCase;
|
||||
}).join('\n');
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuites>
|
||||
<testsuite name="E2E Tests" tests="${summary.total}" failures="${summary.failed}" skipped="${summary.skipped}" time="${summary.duration / 1000}">
|
||||
${testCases}
|
||||
</testsuite>
|
||||
</testsuites>`;
|
||||
}
|
||||
|
||||
private generateSummaryText(summary: TestSummary): string {
|
||||
return `
|
||||
========================================
|
||||
E2E测试摘要报告
|
||||
========================================
|
||||
|
||||
测试时间: ${summary.startTime} - ${summary.endTime}
|
||||
总耗时: ${summary.duration}ms
|
||||
|
||||
测试统计:
|
||||
----------------------------------------
|
||||
总测试数: ${summary.total}
|
||||
通过: ${summary.passed}
|
||||
失败: ${summary.failed}
|
||||
跳过: ${summary.skipped}
|
||||
通过率: ${summary.passRate}%
|
||||
========================================
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
testName: string;
|
||||
status: 'passed' | 'failed' | 'skipped';
|
||||
duration: number;
|
||||
steps: TestStep[];
|
||||
logs: string[];
|
||||
screenshots: string[];
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export interface TestStep {
|
||||
name: string;
|
||||
status: 'passed' | 'failed';
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface TestSummary {
|
||||
total: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
passRate: string;
|
||||
duration: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
|
||||
export const testReporter = new TestReporter();
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* 工作流执行器
|
||||
* 负责执行业务流程工作流,支持重试、回滚和错误处理
|
||||
*/
|
||||
|
||||
import { BusinessWorkflow, WorkflowStep } from './business-workflows.js';
|
||||
import { TestLogger } from './test-logger.js';
|
||||
|
||||
export interface WorkflowExecutionResult {
|
||||
success: boolean;
|
||||
workflowName: string;
|
||||
completedSteps: string[];
|
||||
failedStep?: string;
|
||||
error?: Error;
|
||||
executionTime: number;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
}
|
||||
|
||||
export interface WorkflowExecutionOptions {
|
||||
maxRetries?: number;
|
||||
retryDelay?: number;
|
||||
continueOnError?: boolean;
|
||||
enableRollback?: boolean;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export class WorkflowExecutor {
|
||||
private testLogger: TestLogger;
|
||||
private defaultOptions: WorkflowExecutionOptions = {
|
||||
maxRetries: 3,
|
||||
retryDelay: 1000,
|
||||
continueOnError: false,
|
||||
enableRollback: true,
|
||||
timeout: 300000 // 5分钟默认超时
|
||||
};
|
||||
|
||||
constructor(testLogger: TestLogger) {
|
||||
this.testLogger = testLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行工作流
|
||||
*/
|
||||
async execute(
|
||||
workflow: BusinessWorkflow,
|
||||
options?: WorkflowExecutionOptions
|
||||
): Promise<WorkflowExecutionResult> {
|
||||
const mergedOptions = { ...this.defaultOptions, ...options };
|
||||
const startTime = new Date();
|
||||
const completedSteps: string[] = [];
|
||||
const executedSteps: WorkflowStep[] = [];
|
||||
|
||||
this.testLogger.info(`🚀 开始执行工作流: ${workflow.name}`);
|
||||
this.testLogger.info(`📝 工作流描述: ${workflow.description}`);
|
||||
|
||||
try {
|
||||
// 执行前置条件检查
|
||||
if (workflow.preconditions) {
|
||||
this.testLogger.info('🔍 检查前置条件');
|
||||
const preconditionsMet = await workflow.preconditions();
|
||||
if (!preconditionsMet) {
|
||||
throw new Error('前置条件未满足');
|
||||
}
|
||||
this.testLogger.success('前置条件检查通过');
|
||||
}
|
||||
|
||||
// 执行工作流步骤
|
||||
for (const step of workflow.steps) {
|
||||
const stepStartTime = Date.now();
|
||||
|
||||
try {
|
||||
this.testLogger.startStep(step.name);
|
||||
|
||||
// 执行步骤(带重试)
|
||||
await this.executeStepWithRetry(step, mergedOptions);
|
||||
|
||||
executedSteps.push(step);
|
||||
completedSteps.push(step.name);
|
||||
|
||||
const stepDuration = Date.now() - stepStartTime;
|
||||
this.testLogger.endStep(step.name, 'passed', stepDuration);
|
||||
|
||||
} catch (error) {
|
||||
const stepDuration = Date.now() - stepStartTime;
|
||||
this.testLogger.endStep(step.name, 'failed', stepDuration);
|
||||
this.testLogger.error(`步骤执行失败: ${step.name}`, error as Error);
|
||||
|
||||
// 如果需要回滚
|
||||
if (mergedOptions.enableRollback) {
|
||||
await this.rollback(executedSteps);
|
||||
}
|
||||
|
||||
const endTime = new Date();
|
||||
return {
|
||||
success: false,
|
||||
workflowName: workflow.name,
|
||||
completedSteps,
|
||||
failedStep: step.name,
|
||||
error: error as Error,
|
||||
executionTime: endTime.getTime() - startTime.getTime(),
|
||||
startTime,
|
||||
endTime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 执行后置条件检查
|
||||
if (workflow.postconditions) {
|
||||
this.testLogger.info('🔍 检查后置条件');
|
||||
const postconditionsMet = await workflow.postconditions();
|
||||
if (!postconditionsMet) {
|
||||
throw new Error('后置条件未满足');
|
||||
}
|
||||
this.testLogger.success('后置条件检查通过');
|
||||
}
|
||||
|
||||
const endTime = new Date();
|
||||
const executionTime = endTime.getTime() - startTime.getTime();
|
||||
|
||||
this.testLogger.success(`✅ 工作流执行成功: ${workflow.name} (${executionTime}ms)`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
workflowName: workflow.name,
|
||||
completedSteps,
|
||||
executionTime,
|
||||
startTime,
|
||||
endTime
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const endTime = new Date();
|
||||
|
||||
// 执行清理
|
||||
if (workflow.cleanup) {
|
||||
this.testLogger.info('🧹 执行清理操作');
|
||||
try {
|
||||
await workflow.cleanup();
|
||||
} catch (cleanupError) {
|
||||
this.testLogger.error('清理操作失败', cleanupError as Error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
workflowName: workflow.name,
|
||||
completedSteps,
|
||||
error: error as Error,
|
||||
executionTime: endTime.getTime() - startTime.getTime(),
|
||||
startTime,
|
||||
endTime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 带重试的步骤执行
|
||||
*/
|
||||
private async executeStepWithRetry(
|
||||
step: WorkflowStep,
|
||||
options: WorkflowExecutionOptions
|
||||
): Promise<void> {
|
||||
const maxRetries = step.retryCount ?? options.maxRetries ?? 3;
|
||||
const retryDelay = options.retryDelay ?? 1000;
|
||||
const timeout = step.timeout ?? options.timeout ?? 30000;
|
||||
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
this.testLogger.info(`🔄 执行步骤: ${step.name} (尝试 ${attempt}/${maxRetries})`);
|
||||
|
||||
// 使用 Promise.race 实现超时控制
|
||||
await Promise.race([
|
||||
step.action(),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`步骤超时: ${step.name}`)), timeout)
|
||||
)
|
||||
]);
|
||||
|
||||
// 执行成功
|
||||
return;
|
||||
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
this.testLogger.warn(`步骤执行失败 (尝试 ${attempt}/${maxRetries}): ${step.name}`);
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
this.testLogger.info(`⏳ 等待 ${retryDelay}ms 后重试...`);
|
||||
await this.delay(retryDelay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 所有重试都失败
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚操作
|
||||
*/
|
||||
private async rollback(executedSteps: WorkflowStep[]): Promise<void> {
|
||||
this.testLogger.info('⏪ 开始回滚操作');
|
||||
|
||||
// 逆序执行回滚
|
||||
for (let i = executedSteps.length - 1; i >= 0; i--) {
|
||||
const step = executedSteps[i];
|
||||
|
||||
if (step.rollback) {
|
||||
try {
|
||||
this.testLogger.info(`⏪ 回滚步骤: ${step.name}`);
|
||||
await step.rollback();
|
||||
this.testLogger.success(`步骤回滚成功: ${step.name}`);
|
||||
} catch (error) {
|
||||
this.testLogger.error(`步骤回滚失败: ${step.name}`, error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.testLogger.info('⏪ 回滚操作完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟函数
|
||||
*/
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量执行多个工作流
|
||||
*/
|
||||
async executeBatch(
|
||||
workflows: BusinessWorkflow[],
|
||||
options?: WorkflowExecutionOptions
|
||||
): Promise<WorkflowExecutionResult[]> {
|
||||
this.testLogger.info(`📦 开始批量执行 ${workflows.length} 个工作流`);
|
||||
|
||||
const results: WorkflowExecutionResult[] = [];
|
||||
|
||||
for (const workflow of workflows) {
|
||||
const result = await this.execute(workflow, options);
|
||||
results.push(result);
|
||||
|
||||
// 如果失败且不继续执行,则中断
|
||||
if (!result.success && !options?.continueOnError) {
|
||||
this.testLogger.error(`工作流执行失败,中断批量执行: ${workflow.name}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
this.testLogger.info(`📦 批量执行完成: ${successCount}/${workflows.length} 成功`);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 并行执行多个工作流
|
||||
*/
|
||||
async executeParallel(
|
||||
workflows: BusinessWorkflow[],
|
||||
options?: WorkflowExecutionOptions
|
||||
): Promise<WorkflowExecutionResult[]> {
|
||||
this.testLogger.info(`⚡ 开始并行执行 ${workflows.length} 个工作流`);
|
||||
|
||||
const promises = workflows.map(workflow => this.execute(workflow, options));
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
this.testLogger.info(`⚡ 并行执行完成: ${successCount}/${workflows.length} 成功`);
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user