feat(admin): 添加用户管理相关文件
添加用户管理视图、API和状态管理文件
This commit is contained in:
+28
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "========================================="
|
||||
echo " 服务状态检查"
|
||||
echo "========================================="
|
||||
|
||||
API_STATUS="❌ 未运行"
|
||||
ADMIN_STATUS="❌ 未运行"
|
||||
|
||||
if curl -s http://localhost:8080/api/health > /dev/null 2>&1; then
|
||||
API_STATUS="✅ 运行中 (http://localhost:8080/api/health)"
|
||||
fi
|
||||
|
||||
if curl -s http://localhost:5173 > /dev/null 2>&1; then
|
||||
ADMIN_STATUS="✅ 运行中 (http://localhost:5173)"
|
||||
fi
|
||||
|
||||
echo "API 服务: $API_STATUS"
|
||||
echo "Admin 服务: $ADMIN_STATUS"
|
||||
|
||||
echo "========================================="
|
||||
|
||||
if [[ "$API_STATUS" == *"✅"* ]] && [[ "$ADMIN_STATUS" == *"✅"* ]]; then
|
||||
exit 0
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "🧹 清理测试环境..."
|
||||
|
||||
# 清理测试报告
|
||||
rm -rf test-results/
|
||||
rm -rf playwright-report/
|
||||
|
||||
# 清理测试截图
|
||||
rm -rf screenshots/
|
||||
|
||||
# 清理测试视频
|
||||
rm -rf test-results/videos/
|
||||
|
||||
echo "✅ 测试环境清理完成!"
|
||||
@@ -0,0 +1,285 @@
|
||||
import { exec, spawn, ChildProcess } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { logger } from '../utils/logger';
|
||||
import { TestEnvironment } from '../models/test-result';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
interface ServiceConfig {
|
||||
name: string;
|
||||
command: string;
|
||||
cwd?: string;
|
||||
healthCheckUrl?: string;
|
||||
healthCheckCommand?: string;
|
||||
port?: number;
|
||||
startupTimeout: number;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export class TestEnvironmentManager {
|
||||
private processes: Map<string, ChildProcess> = new Map();
|
||||
private projectRoot: string;
|
||||
private testRoot: string;
|
||||
private environment: TestEnvironment | null = null;
|
||||
|
||||
constructor() {
|
||||
this.testRoot = process.cwd();
|
||||
this.projectRoot = path.dirname(this.testRoot);
|
||||
}
|
||||
|
||||
async prepare(): Promise<TestEnvironment> {
|
||||
logger.section('环境准备');
|
||||
|
||||
logger.info('检查系统环境...');
|
||||
await this.checkSystemRequirements();
|
||||
|
||||
logger.info('检查并安装依赖...');
|
||||
await this.installDependencies();
|
||||
|
||||
logger.info('收集环境信息...');
|
||||
this.environment = await this.collectEnvironmentInfo();
|
||||
|
||||
logger.info('环境准备完成', { environment: this.environment });
|
||||
return this.environment;
|
||||
}
|
||||
|
||||
private async checkSystemRequirements(): Promise<void> {
|
||||
const requirements = [
|
||||
{ name: 'Node.js', command: 'node --version', minVersion: '18.0.0' },
|
||||
{ name: 'npm', command: 'npm --version', minVersion: '9.0.0' }
|
||||
];
|
||||
|
||||
for (const req of requirements) {
|
||||
try {
|
||||
const { stdout } = await execAsync(req.command);
|
||||
const version = stdout.trim();
|
||||
logger.debug(`${req.name} 版本: ${version}`);
|
||||
} catch (error) {
|
||||
throw new Error(`${req.name} 未安装或版本过低,最低要求: ${req.minVersion}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async installDependencies(): Promise<void> {
|
||||
const nodeModulesPath = path.join(this.testRoot, 'node_modules');
|
||||
if (!fs.existsSync(nodeModulesPath)) {
|
||||
logger.info('安装 npm 依赖...');
|
||||
await execAsync('npm install', { cwd: this.testRoot });
|
||||
}
|
||||
|
||||
const playwrightPath = path.join(nodeModulesPath, '@playwright');
|
||||
if (!fs.existsSync(playwrightPath)) {
|
||||
logger.info('安装 Playwright 浏览器...');
|
||||
await execAsync('npx playwright install', { cwd: this.testRoot });
|
||||
}
|
||||
}
|
||||
|
||||
private async collectEnvironmentInfo(): Promise<TestEnvironment> {
|
||||
const { stdout: nodeVersion } = await execAsync('node --version');
|
||||
|
||||
let browserVersions: Record<string, string> = {};
|
||||
try {
|
||||
const { stdout } = await execAsync('npx playwright --version', { cwd: this.testRoot });
|
||||
browserVersions['playwright'] = stdout.trim();
|
||||
} catch {
|
||||
browserVersions['playwright'] = 'unknown';
|
||||
}
|
||||
|
||||
return {
|
||||
nodeVersion: nodeVersion.trim(),
|
||||
os: process.platform,
|
||||
browserVersions,
|
||||
apiBaseUrl: process.env.API_BASE_URL || 'http://localhost:8080',
|
||||
adminBaseUrl: process.env.ADMIN_BASE_URL || 'http://localhost:5174',
|
||||
uniappBaseUrl: process.env.UNIAPP_BASE_URL || 'http://localhost:8081',
|
||||
databaseType: process.env.DB_TYPE || 'h2',
|
||||
timestamp: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
async startAPIService(): Promise<void> {
|
||||
logger.section('启动 API 服务');
|
||||
|
||||
const apiProjectPath = path.join(this.projectRoot, 'everything-is-suitable-api');
|
||||
|
||||
if (!fs.existsSync(apiProjectPath)) {
|
||||
logger.warn('API 项目目录不存在,跳过 API 服务启动');
|
||||
return;
|
||||
}
|
||||
|
||||
const serviceConfig: ServiceConfig = {
|
||||
name: 'api',
|
||||
command: 'mvn',
|
||||
cwd: apiProjectPath,
|
||||
port: 8080,
|
||||
startupTimeout: 120000,
|
||||
env: {
|
||||
SPRING_PROFILES_ACTIVE: 'test',
|
||||
SPRING_DATASOURCE_URL: 'jdbc:h2:mem:testdb',
|
||||
SPRING_DATASOURCE_DRIVER_CLASS_NAME: 'org.h2.Driver',
|
||||
SPRING_JPA_DATABASE_PLATFORM: 'org.hibernate.dialect.H2Dialect'
|
||||
}
|
||||
};
|
||||
|
||||
await this.startService(serviceConfig, ['spring-boot:run', '-Dspring-boot.run.profiles=test']);
|
||||
}
|
||||
|
||||
async startAdminService(): Promise<void> {
|
||||
logger.section('启动 Admin 服务');
|
||||
|
||||
const adminProjectPath = path.join(this.projectRoot, 'everything-is-suitable-admin');
|
||||
|
||||
if (!fs.existsSync(adminProjectPath)) {
|
||||
logger.warn('Admin 项目目录不存在,跳过 Admin 服务启动');
|
||||
return;
|
||||
}
|
||||
|
||||
const serviceConfig: ServiceConfig = {
|
||||
name: 'admin',
|
||||
command: 'npm',
|
||||
cwd: adminProjectPath,
|
||||
port: 5174,
|
||||
startupTimeout: 60000,
|
||||
env: {}
|
||||
};
|
||||
|
||||
if (!fs.existsSync(path.join(adminProjectPath, 'node_modules'))) {
|
||||
logger.info('安装 Admin 项目依赖...');
|
||||
await execAsync('npm install', { cwd: adminProjectPath });
|
||||
}
|
||||
|
||||
await this.startService(serviceConfig, ['run', 'dev']);
|
||||
}
|
||||
|
||||
async startUniappService(): Promise<void> {
|
||||
logger.section('启动 Uniapp 服务');
|
||||
|
||||
const uniappProjectPath = path.join(this.projectRoot, 'everything-is-suitable-uniapp');
|
||||
|
||||
if (!fs.existsSync(uniappProjectPath)) {
|
||||
logger.warn('Uniapp 项目目录不存在,跳过 Uniapp 服务启动');
|
||||
return;
|
||||
}
|
||||
|
||||
const serviceConfig: ServiceConfig = {
|
||||
name: 'uniapp',
|
||||
command: 'npm',
|
||||
cwd: uniappProjectPath,
|
||||
port: 8081,
|
||||
startupTimeout: 60000,
|
||||
env: {}
|
||||
};
|
||||
|
||||
if (!fs.existsSync(path.join(uniappProjectPath, 'node_modules'))) {
|
||||
logger.info('安装 Uniapp 项目依赖...');
|
||||
await execAsync('npm install', { cwd: uniappProjectPath });
|
||||
}
|
||||
|
||||
await this.startService(serviceConfig, ['run', 'dev:h5']);
|
||||
}
|
||||
|
||||
private async startService(config: ServiceConfig, args: string[]): Promise<void> {
|
||||
logger.info(`启动 ${config.name} 服务...`);
|
||||
|
||||
const env = { ...process.env, ...config.env };
|
||||
|
||||
const childProcess = spawn(config.command, args, {
|
||||
cwd: config.cwd,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false
|
||||
});
|
||||
|
||||
this.processes.set(config.name, childProcess);
|
||||
|
||||
childProcess.stdout?.on('data', (data) => {
|
||||
const output = data.toString();
|
||||
logger.debug(`[${config.name}] ${output.trim()}`);
|
||||
});
|
||||
|
||||
childProcess.stderr?.on('data', (data) => {
|
||||
const output = data.toString();
|
||||
logger.debug(`[${config.name}] ERROR: ${output.trim()}`);
|
||||
});
|
||||
|
||||
childProcess.on('error', (error) => {
|
||||
logger.error(`${config.name} 服务启动失败`, error);
|
||||
});
|
||||
|
||||
await this.waitForServiceReady(config);
|
||||
logger.info(`${config.name} 服务已启动`);
|
||||
}
|
||||
|
||||
private async waitForServiceReady(config: ServiceConfig): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
const timeout = config.startupTimeout;
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
try {
|
||||
if (config.port) {
|
||||
const isReady = await this.checkPort(config.port);
|
||||
if (isReady) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.sleep(1000);
|
||||
} catch (error) {
|
||||
logger.debug(`等待 ${config.name} 服务就绪...`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`${config.name} 服务启动超时 (${timeout}ms)`);
|
||||
}
|
||||
|
||||
private async checkPort(port: number): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execAsync(`lsof -i :${port} -t`);
|
||||
return stdout.trim().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async checkServiceHealth(name: string, url: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execAsync(`curl -s -o /dev/null -w "%{http_code}" ${url}`);
|
||||
const statusCode = parseInt(stdout.trim(), 10);
|
||||
return statusCode >= 200 && statusCode < 300;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async stopAllServices(): Promise<void> {
|
||||
logger.section('停止所有服务');
|
||||
|
||||
const entries = Array.from(this.processes.entries());
|
||||
for (const [name, process] of entries) {
|
||||
logger.info(`停止 ${name} 服务...`);
|
||||
try {
|
||||
process.kill('SIGTERM');
|
||||
logger.info(`${name} 服务已停止`);
|
||||
} catch (error) {
|
||||
logger.warn(`停止 ${name} 服务时出错`, { error });
|
||||
}
|
||||
}
|
||||
|
||||
this.processes.clear();
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
logger.section('清理环境');
|
||||
await this.stopAllServices();
|
||||
logger.info('环境清理完成');
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
getEnvironment(): TestEnvironment | null {
|
||||
return this.environment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import { TestCase, TestError, ErrorType } from '../models/test-result';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface ErrorAnalysis {
|
||||
type: ErrorType;
|
||||
category: ErrorCategory;
|
||||
severity: ErrorSeverity;
|
||||
rootCause: string;
|
||||
suggestedFix: string;
|
||||
autoFixable: boolean;
|
||||
fixStrategy?: FixStrategy;
|
||||
}
|
||||
|
||||
export enum ErrorCategory {
|
||||
INFRASTRUCTURE = 'infrastructure',
|
||||
APPLICATION = 'application',
|
||||
TEST_CODE = 'test_code',
|
||||
DATA = 'data',
|
||||
CONFIGURATION = 'configuration',
|
||||
TIMING = 'timing'
|
||||
}
|
||||
|
||||
export enum ErrorSeverity {
|
||||
CRITICAL = 'critical',
|
||||
HIGH = 'high',
|
||||
MEDIUM = 'medium',
|
||||
LOW = 'low'
|
||||
}
|
||||
|
||||
export interface FixStrategy {
|
||||
type: 'code' | 'config' | 'data' | 'wait' | 'retry';
|
||||
description: string;
|
||||
actions: FixAction[];
|
||||
}
|
||||
|
||||
export interface FixAction {
|
||||
type: string;
|
||||
target: string;
|
||||
value?: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class ErrorAnalyzer {
|
||||
private patterns: Map<ErrorType, RegExp[]>;
|
||||
|
||||
constructor() {
|
||||
this.patterns = this.initializePatterns();
|
||||
}
|
||||
|
||||
private initializePatterns(): Map<ErrorType, RegExp[]> {
|
||||
return new Map([
|
||||
[ErrorType.TIMEOUT, [
|
||||
/timeout/i,
|
||||
/timed out/i,
|
||||
/exceeded.*timeout/i,
|
||||
/waiting.*failed/i
|
||||
]],
|
||||
[ErrorType.ELEMENT_NOT_FOUND, [
|
||||
/element.*not found/i,
|
||||
/no element.*matching/i,
|
||||
/selector.*not found/i,
|
||||
/unable to find/i,
|
||||
/waiting for selector/i
|
||||
]],
|
||||
[ErrorType.API_ERROR, [
|
||||
/api.*error/i,
|
||||
/request failed/i,
|
||||
/status code.*\d{3}/i,
|
||||
/http.*error/i,
|
||||
/response.*error/i
|
||||
]],
|
||||
[ErrorType.ASSERTION_ERROR, [
|
||||
/assertion.*failed/i,
|
||||
/expect.*received/i,
|
||||
/expected.*but received/i,
|
||||
/assertionerror/i
|
||||
]],
|
||||
[ErrorType.NETWORK_ERROR, [
|
||||
/network error/i,
|
||||
/connection refused/i,
|
||||
/econnrefused/i,
|
||||
/enotfound/i,
|
||||
/socket hang up/i
|
||||
]],
|
||||
[ErrorType.AUTH_ERROR, [
|
||||
/unauthorized/i,
|
||||
/forbidden/i,
|
||||
/authentication failed/i,
|
||||
/invalid token/i,
|
||||
/session expired/i
|
||||
]],
|
||||
[ErrorType.DATA_ERROR, [
|
||||
/json parse error/i,
|
||||
/invalid json/i,
|
||||
/data.*invalid/i,
|
||||
/schema.*validation/i
|
||||
]],
|
||||
[ErrorType.ENVIRONMENT_ERROR, [
|
||||
/environment.*error/i,
|
||||
/config.*error/i,
|
||||
/setup.*failed/i,
|
||||
/initialization.*failed/i
|
||||
]]
|
||||
]);
|
||||
}
|
||||
|
||||
analyze(testCase: TestCase): ErrorAnalysis {
|
||||
if (!testCase.error) {
|
||||
return this.createDefaultAnalysis();
|
||||
}
|
||||
|
||||
const error = testCase.error;
|
||||
const type = this.detectErrorType(error);
|
||||
const category = this.categorizeError(type, error);
|
||||
const severity = this.assessSeverity(type, testCase);
|
||||
const rootCause = this.identifyRootCause(error, type);
|
||||
const suggestedFix = this.suggestFix(type, error, testCase);
|
||||
const autoFixable = this.isAutoFixable(type, error);
|
||||
const fixStrategy = autoFixable ? this.createFixStrategy(type, error, testCase) : undefined;
|
||||
|
||||
const analysis: ErrorAnalysis = {
|
||||
type,
|
||||
category,
|
||||
severity,
|
||||
rootCause,
|
||||
suggestedFix,
|
||||
autoFixable,
|
||||
fixStrategy
|
||||
};
|
||||
|
||||
logger.debug('错误分析完成', {
|
||||
testId: testCase.id,
|
||||
type,
|
||||
category,
|
||||
severity,
|
||||
autoFixable
|
||||
});
|
||||
|
||||
return analysis;
|
||||
}
|
||||
|
||||
private detectErrorType(error: TestError): ErrorType {
|
||||
const message = error.message.toLowerCase();
|
||||
|
||||
const patternEntries = Array.from(this.patterns.entries());
|
||||
for (const [type, regexps] of patternEntries) {
|
||||
for (const regex of regexps) {
|
||||
if (regex.test(message)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ErrorType.UNKNOWN;
|
||||
}
|
||||
|
||||
private categorizeError(type: ErrorType, error: TestError): ErrorCategory {
|
||||
switch (type) {
|
||||
case ErrorType.TIMEOUT:
|
||||
case ErrorType.NETWORK_ERROR:
|
||||
return ErrorCategory.INFRASTRUCTURE;
|
||||
|
||||
case ErrorType.API_ERROR:
|
||||
case ErrorType.AUTH_ERROR:
|
||||
return ErrorCategory.APPLICATION;
|
||||
|
||||
case ErrorType.ASSERTION_ERROR:
|
||||
return ErrorCategory.TEST_CODE;
|
||||
|
||||
case ErrorType.DATA_ERROR:
|
||||
return ErrorCategory.DATA;
|
||||
|
||||
case ErrorType.ENVIRONMENT_ERROR:
|
||||
return ErrorCategory.CONFIGURATION;
|
||||
|
||||
case ErrorType.ELEMENT_NOT_FOUND:
|
||||
const message = error.message.toLowerCase();
|
||||
if (message.includes('loading') || message.includes('spinner')) {
|
||||
return ErrorCategory.TIMING;
|
||||
}
|
||||
return ErrorCategory.TEST_CODE;
|
||||
|
||||
default:
|
||||
return ErrorCategory.TEST_CODE;
|
||||
}
|
||||
}
|
||||
|
||||
private assessSeverity(type: ErrorType, testCase: TestCase): ErrorSeverity {
|
||||
if (testCase.priority === 'high') {
|
||||
if (type === ErrorType.API_ERROR || type === ErrorType.AUTH_ERROR) {
|
||||
return ErrorSeverity.CRITICAL;
|
||||
}
|
||||
return ErrorSeverity.HIGH;
|
||||
}
|
||||
|
||||
if (testCase.priority === 'medium') {
|
||||
if (type === ErrorType.TIMEOUT || type === ErrorType.NETWORK_ERROR) {
|
||||
return ErrorSeverity.HIGH;
|
||||
}
|
||||
return ErrorSeverity.MEDIUM;
|
||||
}
|
||||
|
||||
return ErrorSeverity.LOW;
|
||||
}
|
||||
|
||||
private identifyRootCause(error: TestError, type: ErrorType): string {
|
||||
const message = error.message;
|
||||
|
||||
switch (type) {
|
||||
case ErrorType.TIMEOUT:
|
||||
if (message.includes('navigation')) {
|
||||
return '页面导航超时,可能是网络延迟或页面加载过慢';
|
||||
}
|
||||
if (message.includes('element')) {
|
||||
return '元素等待超时,元素可能未及时渲染';
|
||||
}
|
||||
return '操作超时,系统响应过慢';
|
||||
|
||||
case ErrorType.ELEMENT_NOT_FOUND:
|
||||
return '目标元素不存在,可能是选择器错误或页面结构变化';
|
||||
|
||||
case ErrorType.API_ERROR:
|
||||
const statusMatch = message.match(/status code[:\s]*(\d+)/i);
|
||||
if (statusMatch) {
|
||||
return `API 返回错误状态码: ${statusMatch[1]}`;
|
||||
}
|
||||
return 'API 请求失败';
|
||||
|
||||
case ErrorType.ASSERTION_ERROR:
|
||||
return '断言失败,实际结果与预期不符';
|
||||
|
||||
case ErrorType.NETWORK_ERROR:
|
||||
return '网络连接失败,服务可能未启动或不可达';
|
||||
|
||||
case ErrorType.AUTH_ERROR:
|
||||
return '认证失败,令牌可能已过期或无效';
|
||||
|
||||
case ErrorType.DATA_ERROR:
|
||||
return '数据格式错误,JSON 解析失败';
|
||||
|
||||
case ErrorType.ENVIRONMENT_ERROR:
|
||||
return '环境配置错误';
|
||||
|
||||
default:
|
||||
return '未知错误';
|
||||
}
|
||||
}
|
||||
|
||||
private suggestFix(type: ErrorType, error: TestError, testCase: TestCase): string {
|
||||
switch (type) {
|
||||
case ErrorType.TIMEOUT:
|
||||
return '增加超时时间或优化等待策略,确保元素加载完成后再操作';
|
||||
|
||||
case ErrorType.ELEMENT_NOT_FOUND:
|
||||
return '检查选择器是否正确,确认页面结构是否变化,添加更健壮的等待逻辑';
|
||||
|
||||
case ErrorType.API_ERROR:
|
||||
return '检查 API 服务状态,验证请求参数和认证信息';
|
||||
|
||||
case ErrorType.ASSERTION_ERROR:
|
||||
return '检查断言条件,确认预期值是否正确,可能需要更新测试预期';
|
||||
|
||||
case ErrorType.NETWORK_ERROR:
|
||||
return '确认服务已启动,检查网络连接和防火墙设置';
|
||||
|
||||
case ErrorType.AUTH_ERROR:
|
||||
return '刷新认证令牌,检查用户凭证和权限配置';
|
||||
|
||||
case ErrorType.DATA_ERROR:
|
||||
return '验证数据格式,检查 JSON 结构是否符合预期';
|
||||
|
||||
case ErrorType.ENVIRONMENT_ERROR:
|
||||
return '检查环境变量和配置文件,确保所有依赖已正确安装';
|
||||
|
||||
default:
|
||||
return '需要人工分析错误日志,确定具体问题';
|
||||
}
|
||||
}
|
||||
|
||||
private isAutoFixable(type: ErrorType, error: TestError): boolean {
|
||||
const autoFixableTypes = [
|
||||
ErrorType.TIMEOUT,
|
||||
ErrorType.AUTH_ERROR
|
||||
];
|
||||
|
||||
return autoFixableTypes.includes(type);
|
||||
}
|
||||
|
||||
private createFixStrategy(type: ErrorType, error: TestError, testCase: TestCase): FixStrategy | undefined {
|
||||
switch (type) {
|
||||
case ErrorType.TIMEOUT:
|
||||
return {
|
||||
type: 'wait',
|
||||
description: '增加等待时间和重试策略',
|
||||
actions: [
|
||||
{
|
||||
type: 'increase_timeout',
|
||||
target: testCase.id,
|
||||
value: '60000',
|
||||
description: '将超时时间增加到 60 秒'
|
||||
},
|
||||
{
|
||||
type: 'add_retry',
|
||||
target: testCase.id,
|
||||
value: '3',
|
||||
description: '添加 3 次重试'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
case ErrorType.AUTH_ERROR:
|
||||
return {
|
||||
type: 'config',
|
||||
description: '刷新认证令牌',
|
||||
actions: [
|
||||
{
|
||||
type: 'refresh_token',
|
||||
target: 'auth',
|
||||
description: '重新获取认证令牌'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private createDefaultAnalysis(): ErrorAnalysis {
|
||||
return {
|
||||
type: ErrorType.UNKNOWN,
|
||||
category: ErrorCategory.TEST_CODE,
|
||||
severity: ErrorSeverity.LOW,
|
||||
rootCause: '未知错误',
|
||||
suggestedFix: '需要人工分析',
|
||||
autoFixable: false
|
||||
};
|
||||
}
|
||||
|
||||
analyzeBatch(testCases: TestCase[]): Map<string, ErrorAnalysis> {
|
||||
const results = new Map<string, ErrorAnalysis>();
|
||||
|
||||
for (const testCase of testCases) {
|
||||
if (testCase.status === 'failed' && testCase.error) {
|
||||
results.set(testCase.id, this.analyze(testCase));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
getErrorStatistics(analyses: Map<string, ErrorAnalysis>): {
|
||||
byType: Record<ErrorType, number>;
|
||||
byCategory: Record<ErrorCategory, number>;
|
||||
bySeverity: Record<ErrorSeverity, number>;
|
||||
autoFixableCount: number;
|
||||
} {
|
||||
const byType: Record<ErrorType, number> = {} as Record<ErrorType, number>;
|
||||
const byCategory: Record<ErrorCategory, number> = {} as Record<ErrorCategory, number>;
|
||||
const bySeverity: Record<ErrorSeverity, number> = {} as Record<ErrorSeverity, number>;
|
||||
let autoFixableCount = 0;
|
||||
|
||||
const analysisValues = Array.from(analyses.values());
|
||||
for (const analysis of analysisValues) {
|
||||
byType[analysis.type] = (byType[analysis.type] || 0) + 1;
|
||||
byCategory[analysis.category] = (byCategory[analysis.category] || 0) + 1;
|
||||
bySeverity[analysis.severity] = (bySeverity[analysis.severity] || 0) + 1;
|
||||
if (analysis.autoFixable) {
|
||||
autoFixableCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return { byType, byCategory, bySeverity, autoFixableCount };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './environment';
|
||||
export * from './test-executor';
|
||||
export * from './error-analyzer';
|
||||
export * from './tdd-iterator';
|
||||
export * from './report-generator';
|
||||
@@ -0,0 +1,406 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { TestResult, TestModule, TestSuite, TestCase, TestStatus, TDDIteration } from '../models/test-result';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface ReportConfig {
|
||||
outputDir: string;
|
||||
formats: ('html' | 'json' | 'junit' | 'markdown')[];
|
||||
includeScreenshots: boolean;
|
||||
includeVideos: boolean;
|
||||
}
|
||||
|
||||
export class ReportGenerator {
|
||||
private config: ReportConfig;
|
||||
|
||||
constructor(config?: Partial<ReportConfig>) {
|
||||
this.config = {
|
||||
outputDir: config?.outputDir ?? 'test-results/reports',
|
||||
formats: config?.formats ?? ['html', 'json', 'markdown'],
|
||||
includeScreenshots: config?.includeScreenshots ?? true,
|
||||
includeVideos: config?.includeVideos ?? true
|
||||
};
|
||||
this.ensureOutputDir();
|
||||
}
|
||||
|
||||
private ensureOutputDir(): void {
|
||||
try {
|
||||
if (!fs.existsSync(this.config.outputDir)) {
|
||||
fs.mkdirSync(this.config.outputDir, { recursive: true });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('创建报告目录失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
async generate(result: TestResult, iterations?: TDDIteration[]): Promise<string[]> {
|
||||
logger.section('生成测试报告');
|
||||
|
||||
this.ensureOutputDir();
|
||||
|
||||
const generatedFiles: string[] = [];
|
||||
|
||||
for (const format of this.config.formats) {
|
||||
try {
|
||||
const filePath = await this.generateFormat(result, format, iterations);
|
||||
generatedFiles.push(filePath);
|
||||
logger.info(`生成 ${format.toUpperCase()} 报告: ${filePath}`);
|
||||
} catch (error) {
|
||||
logger.error(`生成 ${format.toUpperCase()} 报告失败`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return generatedFiles;
|
||||
}
|
||||
|
||||
private async generateFormat(
|
||||
result: TestResult,
|
||||
format: 'html' | 'json' | 'junit' | 'markdown',
|
||||
iterations?: TDDIteration[]
|
||||
): Promise<string> {
|
||||
switch (format) {
|
||||
case 'html':
|
||||
return this.generateHTMLReport(result, iterations);
|
||||
case 'json':
|
||||
return this.generateJSONReport(result, iterations);
|
||||
case 'junit':
|
||||
return this.generateJUnitReport(result);
|
||||
case 'markdown':
|
||||
return this.generateMarkdownReport(result, iterations);
|
||||
default:
|
||||
throw new Error(`不支持的报告格式: ${format}`);
|
||||
}
|
||||
}
|
||||
|
||||
private generateHTMLReport(result: TestResult, iterations?: TDDIteration[]): string {
|
||||
const filePath = path.join(this.config.outputDir, 'test-report.html');
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>E2E 测试报告</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; border-radius: 10px; margin-bottom: 20px; }
|
||||
.header h1 { font-size: 28px; margin-bottom: 10px; }
|
||||
.header .timestamp { opacity: 0.8; font-size: 14px; }
|
||||
.summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-bottom: 20px; }
|
||||
.summary-card { background: white; padding: 20px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
||||
.summary-card h3 { font-size: 14px; color: #666; margin-bottom: 10px; }
|
||||
.summary-card .value { font-size: 32px; font-weight: bold; }
|
||||
.summary-card.passed .value { color: #10b981; }
|
||||
.summary-card.failed .value { color: #ef4444; }
|
||||
.summary-card.skipped .value { color: #f59e0b; }
|
||||
.summary-card.rate .value { color: #3b82f6; }
|
||||
.progress-bar { height: 10px; background: #e5e7eb; border-radius: 5px; overflow: hidden; margin-top: 10px; }
|
||||
.progress-bar .fill { height: 100%; transition: width 0.3s; }
|
||||
.progress-bar .fill.passed { background: #10b981; }
|
||||
.progress-bar .fill.failed { background: #ef4444; }
|
||||
.modules { background: white; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); overflow: hidden; }
|
||||
.module-header { padding: 15px 20px; background: #f9fafb; border-bottom: 1px solid #e5e7eb; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
|
||||
.module-header:hover { background: #f3f4f6; }
|
||||
.module-content { padding: 20px; display: none; }
|
||||
.module-content.active { display: block; }
|
||||
.suite { margin-bottom: 15px; border: 1px solid #e5e7eb; border-radius: 8px; }
|
||||
.suite-header { padding: 10px 15px; background: #f9fafb; display: flex; justify-content: space-between; }
|
||||
.test-case { padding: 10px 15px; border-top: 1px solid #e5e7eb; display: flex; justify-content: space-between; align-items: center; }
|
||||
.test-case:hover { background: #f9fafb; }
|
||||
.status-badge { padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: 500; }
|
||||
.status-badge.passed { background: #d1fae5; color: #065f46; }
|
||||
.status-badge.failed { background: #fee2e2; color: #991b1b; }
|
||||
.status-badge.skipped { background: #fef3c7; color: #92400e; }
|
||||
.error-details { margin-top: 10px; padding: 10px; background: #fef2f2; border-radius: 5px; font-family: monospace; font-size: 12px; white-space: pre-wrap; }
|
||||
.iterations { margin-top: 20px; }
|
||||
.iteration { background: white; border-radius: 10px; padding: 20px; margin-bottom: 15px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
||||
.iteration h4 { margin-bottom: 10px; }
|
||||
.footer { text-align: center; padding: 20px; color: #666; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>E2E 自动化测试报告</h1>
|
||||
<div class="timestamp">生成时间: ${result.startTime.toLocaleString('zh-CN')}</div>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="summary-card">
|
||||
<h3>总测试数</h3>
|
||||
<div class="value">${result.totalTests}</div>
|
||||
</div>
|
||||
<div class="summary-card passed">
|
||||
<h3>通过</h3>
|
||||
<div class="value">${result.passedTests}</div>
|
||||
</div>
|
||||
<div class="summary-card failed">
|
||||
<h3>失败</h3>
|
||||
<div class="value">${result.failedTests}</div>
|
||||
</div>
|
||||
<div class="summary-card skipped">
|
||||
<h3>跳过</h3>
|
||||
<div class="value">${result.skippedTests}</div>
|
||||
</div>
|
||||
<div class="summary-card rate">
|
||||
<h3>通过率</h3>
|
||||
<div class="value">${result.passRate.toFixed(1)}%</div>
|
||||
<div class="progress-bar">
|
||||
<div class="fill passed" style="width: ${result.passRate}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modules">
|
||||
${result.modules.map(module => this.renderModule(module)).join('')}
|
||||
</div>
|
||||
|
||||
${iterations && iterations.length > 0 ? this.renderIterations(iterations) : ''}
|
||||
|
||||
<div class="footer">
|
||||
<p>测试执行时间: ${(result.duration / 1000).toFixed(2)} 秒</p>
|
||||
<p>由 E2E 自动化测试框架生成</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.module-header').forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
const content = header.nextElementSibling;
|
||||
content.classList.toggle('active');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
fs.writeFileSync(filePath, html, 'utf-8');
|
||||
return filePath;
|
||||
}
|
||||
|
||||
private renderModule(module: TestModule): string {
|
||||
return `
|
||||
<div class="module">
|
||||
<div class="module-header">
|
||||
<span><strong>${module.name.toUpperCase()}</strong> 模块</span>
|
||||
<span>${module.passedTests}/${module.totalTests} 通过 (${module.passRate.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<div class="module-content">
|
||||
${module.suites.map(suite => this.renderSuite(suite)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSuite(suite: TestSuite): string {
|
||||
return `
|
||||
<div class="suite">
|
||||
<div class="suite-header">
|
||||
<span>${suite.name}</span>
|
||||
<span class="status-badge ${suite.status}">${suite.status}</span>
|
||||
</div>
|
||||
${suite.tests.map(test => this.renderTestCase(test)).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTestCase(test: TestCase): string {
|
||||
return `
|
||||
<div class="test-case">
|
||||
<span>${test.name}</span>
|
||||
<div>
|
||||
<span class="status-badge ${test.status}">${test.status}</span>
|
||||
<span style="margin-left: 10px; color: #666;">${(test.duration / 1000).toFixed(2)}s</span>
|
||||
</div>
|
||||
</div>
|
||||
${test.error ? `<div class="error-details">${this.escapeHtml(test.error.message)}</div>` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderIterations(iterations: TDDIteration[]): string {
|
||||
return `
|
||||
<div class="iterations">
|
||||
<h3 style="margin-bottom: 15px;">TDD 迭代记录</h3>
|
||||
${iterations.map((iter, index) => `
|
||||
<div class="iteration">
|
||||
<h4>迭代 ${index + 1}</h4>
|
||||
<p>修复数量: ${iter.fixes.length}</p>
|
||||
<p>通过率变化: ${iter.previousResult.passRate.toFixed(1)}% → ${iter.currentResult.passRate.toFixed(1)}%</p>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
private generateJSONReport(result: TestResult, iterations?: TDDIteration[]): string {
|
||||
const filePath = path.join(this.config.outputDir, 'test-report.json');
|
||||
|
||||
const report = {
|
||||
summary: {
|
||||
totalTests: result.totalTests,
|
||||
passedTests: result.passedTests,
|
||||
failedTests: result.failedTests,
|
||||
skippedTests: result.skippedTests,
|
||||
passRate: result.passRate,
|
||||
duration: result.duration,
|
||||
startTime: result.startTime,
|
||||
endTime: result.endTime,
|
||||
status: result.status
|
||||
},
|
||||
environment: result.environment,
|
||||
modules: result.modules.map(module => ({
|
||||
name: module.name,
|
||||
totalTests: module.totalTests,
|
||||
passedTests: module.passedTests,
|
||||
failedTests: module.failedTests,
|
||||
skippedTests: module.skippedTests,
|
||||
passRate: module.passRate,
|
||||
duration: module.duration,
|
||||
suites: module.suites.map(suite => ({
|
||||
name: suite.name,
|
||||
status: suite.status,
|
||||
duration: suite.duration,
|
||||
passRate: suite.passRate,
|
||||
tests: suite.tests.map(test => ({
|
||||
id: test.id,
|
||||
name: test.name,
|
||||
status: test.status,
|
||||
duration: test.duration,
|
||||
error: test.error ? {
|
||||
type: test.error.type,
|
||||
message: test.error.message
|
||||
} : null
|
||||
}))
|
||||
}))
|
||||
})),
|
||||
iterations: iterations?.map((iter, index) => ({
|
||||
iteration: index + 1,
|
||||
fixesCount: iter.fixes.length,
|
||||
previousPassRate: iter.previousResult.passRate,
|
||||
currentPassRate: iter.currentResult.passRate,
|
||||
timestamp: iter.timestamp
|
||||
}))
|
||||
};
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify(report, null, 2), 'utf-8');
|
||||
return filePath;
|
||||
}
|
||||
|
||||
private generateJUnitReport(result: TestResult): string {
|
||||
const filePath = path.join(this.config.outputDir, 'junit-report.xml');
|
||||
|
||||
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
||||
xml += `<testsuites tests="${result.totalTests}" failures="${result.failedTests}" skipped="${result.skippedTests}" time="${result.duration / 1000}">\n`;
|
||||
|
||||
for (const module of result.modules) {
|
||||
for (const suite of module.suites) {
|
||||
xml += ` <testsuite name="${this.escapeXml(suite.name)}" tests="${suite.tests.length}" failures="${suite.tests.filter(t => t.status === TestStatus.FAILED).length}" skipped="${suite.tests.filter(t => t.status === TestStatus.SKIPPED).length}" time="${suite.duration / 1000}">\n`;
|
||||
|
||||
for (const test of suite.tests) {
|
||||
xml += ` <testcase name="${this.escapeXml(test.name)}" classname="${this.escapeXml(suite.name)}" time="${test.duration / 1000}"`;
|
||||
|
||||
if (test.status === TestStatus.SKIPPED) {
|
||||
xml += '>\n <skipped/>\n </testcase>\n';
|
||||
} else if (test.status === TestStatus.FAILED && test.error) {
|
||||
xml += '>\n';
|
||||
xml += ` <failure message="${this.escapeXml(test.error.message)}">\n`;
|
||||
xml += ` ${this.escapeXml(test.error.stack || test.error.message)}\n`;
|
||||
xml += ' </failure>\n';
|
||||
xml += ' </testcase>\n';
|
||||
} else {
|
||||
xml += '/>\n';
|
||||
}
|
||||
}
|
||||
|
||||
xml += ' </testsuite>\n';
|
||||
}
|
||||
}
|
||||
|
||||
xml += '</testsuites>\n';
|
||||
|
||||
fs.writeFileSync(filePath, xml, 'utf-8');
|
||||
return filePath;
|
||||
}
|
||||
|
||||
private generateMarkdownReport(result: TestResult, iterations?: TDDIteration[]): string {
|
||||
const filePath = path.join(this.config.outputDir, 'test-report.md');
|
||||
|
||||
let md = '# E2E 自动化测试报告\n\n';
|
||||
md += `**生成时间**: ${result.startTime.toLocaleString('zh-CN')}\n\n`;
|
||||
|
||||
md += '## 测试概览\n\n';
|
||||
md += '| 指标 | 数值 |\n';
|
||||
md += '|------|------|\n';
|
||||
md += `| 总测试数 | ${result.totalTests} |\n`;
|
||||
md += `| 通过数 | ${result.passedTests} |\n`;
|
||||
md += `| 失败数 | ${result.failedTests} |\n`;
|
||||
md += `| 跳过数 | ${result.skippedTests} |\n`;
|
||||
md += `| 通过率 | ${result.passRate.toFixed(2)}% |\n`;
|
||||
md += `| 执行时间 | ${(result.duration / 1000).toFixed(2)}s |\n\n`;
|
||||
|
||||
md += '## 模块详情\n\n';
|
||||
|
||||
for (const module of result.modules) {
|
||||
md += `### ${module.name.toUpperCase()} 模块\n\n`;
|
||||
md += `- 通过率: ${module.passRate.toFixed(2)}%\n`;
|
||||
md += `- 测试数: ${module.totalTests}\n`;
|
||||
md += `- 通过: ${module.passedTests}\n`;
|
||||
md += `- 失败: ${module.failedTests}\n\n`;
|
||||
|
||||
if (module.suites.length > 0) {
|
||||
md += '| 测试用例 | 状态 | 耗时 |\n';
|
||||
md += '|----------|------|------|\n';
|
||||
|
||||
for (const suite of module.suites) {
|
||||
for (const test of suite.tests) {
|
||||
const statusEmoji = test.status === TestStatus.PASSED ? '✅' :
|
||||
test.status === TestStatus.FAILED ? '❌' : '⏭️';
|
||||
md += `| ${test.name} | ${statusEmoji} ${test.status} | ${(test.duration / 1000).toFixed(2)}s |\n`;
|
||||
}
|
||||
}
|
||||
md += '\n';
|
||||
}
|
||||
}
|
||||
|
||||
if (iterations && iterations.length > 0) {
|
||||
md += '## TDD 迭代记录\n\n';
|
||||
|
||||
for (let i = 0; i < iterations.length; i++) {
|
||||
const iter = iterations[i];
|
||||
md += `### 迭代 ${i + 1}\n\n`;
|
||||
md += `- 修复数量: ${iter.fixes.length}\n`;
|
||||
md += `- 通过率变化: ${iter.previousResult.passRate.toFixed(2)}% → ${iter.currentResult.passRate.toFixed(2)}%\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
md += '## 环境信息\n\n';
|
||||
md += `- Node.js: ${result.environment.nodeVersion}\n`;
|
||||
md += `- 操作系统: ${result.environment.os}\n`;
|
||||
md += `- API 地址: ${result.environment.apiBaseUrl}\n`;
|
||||
md += `- Admin 地址: ${result.environment.adminBaseUrl}\n`;
|
||||
md += `- Uniapp 地址: ${result.environment.uniappBaseUrl}\n`;
|
||||
|
||||
fs.writeFileSync(filePath, md, 'utf-8');
|
||||
return filePath;
|
||||
}
|
||||
|
||||
private escapeXml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { TestResult, TestModule, TestCase, TDDIteration, TDDFix, TestStatus } from '../models/test-result';
|
||||
import { ErrorAnalyzer, ErrorAnalysis, ErrorCategory, ErrorSeverity } from './error-analyzer';
|
||||
import { TestExecutor } from './test-executor';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface TDDConfig {
|
||||
maxIterations: number;
|
||||
autoFixEnabled: boolean;
|
||||
stopOnCriticalFailure: boolean;
|
||||
stopThreshold: number;
|
||||
}
|
||||
|
||||
export class TDDIterator {
|
||||
private config: TDDConfig;
|
||||
private errorAnalyzer: ErrorAnalyzer;
|
||||
private testExecutor: TestExecutor;
|
||||
private iterations: TDDIteration[] = [];
|
||||
private appliedFixes: Map<string, TDDFix> = new Map();
|
||||
|
||||
constructor(config?: Partial<TDDConfig>) {
|
||||
this.config = {
|
||||
maxIterations: config?.maxIterations ?? 3,
|
||||
autoFixEnabled: config?.autoFixEnabled ?? true,
|
||||
stopOnCriticalFailure: config?.stopOnCriticalFailure ?? true,
|
||||
stopThreshold: config?.stopThreshold ?? 0.3
|
||||
};
|
||||
this.errorAnalyzer = new ErrorAnalyzer();
|
||||
this.testExecutor = new TestExecutor();
|
||||
}
|
||||
|
||||
async iterate(initialResult: TestResult): Promise<TestResult> {
|
||||
logger.section('TDD 迭代优化');
|
||||
|
||||
let currentResult = initialResult;
|
||||
let iteration = 0;
|
||||
|
||||
while (iteration < this.config.maxIterations) {
|
||||
iteration++;
|
||||
logger.info(`开始第 ${iteration} 次迭代`);
|
||||
|
||||
if (currentResult.failedTests === 0) {
|
||||
logger.info('所有测试通过,无需迭代');
|
||||
break;
|
||||
}
|
||||
|
||||
const failedTests = this.extractFailedTests(currentResult);
|
||||
logger.info(`发现 ${failedTests.length} 个失败测试`);
|
||||
|
||||
const analyses = this.errorAnalyzer.analyzeBatch(failedTests);
|
||||
const stats = this.errorAnalyzer.getErrorStatistics(analyses);
|
||||
|
||||
logger.info('错误统计', {
|
||||
byType: stats.byType,
|
||||
byCategory: stats.byCategory,
|
||||
autoFixable: stats.autoFixableCount
|
||||
});
|
||||
|
||||
if (this.shouldStop(stats, failedTests.length)) {
|
||||
logger.warn('达到停止条件,终止迭代');
|
||||
break;
|
||||
}
|
||||
|
||||
if (!this.config.autoFixEnabled) {
|
||||
logger.info('自动修复已禁用,终止迭代');
|
||||
break;
|
||||
}
|
||||
|
||||
const fixes = await this.generateFixes(analyses, failedTests);
|
||||
|
||||
if (fixes.length === 0) {
|
||||
logger.info('没有可自动修复的问题,终止迭代');
|
||||
break;
|
||||
}
|
||||
|
||||
const appliedFixes = await this.applyFixes(fixes);
|
||||
|
||||
if (appliedFixes.length === 0) {
|
||||
logger.info('没有成功应用的修复,终止迭代');
|
||||
break;
|
||||
}
|
||||
|
||||
logger.info(`应用了 ${appliedFixes.length} 个修复,重新运行测试...`);
|
||||
|
||||
const newResult = await this.testExecutor.executeAll();
|
||||
|
||||
const tddIteration: TDDIteration = {
|
||||
iteration,
|
||||
previousResult: currentResult,
|
||||
currentResult: newResult,
|
||||
fixes: appliedFixes,
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
this.iterations.push(tddIteration);
|
||||
|
||||
if (newResult.passRate > currentResult.passRate) {
|
||||
logger.info(`通过率提升: ${currentResult.passRate.toFixed(2)}% -> ${newResult.passRate.toFixed(2)}%`);
|
||||
} else {
|
||||
logger.warn(`通过率未提升: ${currentResult.passRate.toFixed(2)}% -> ${newResult.passRate.toFixed(2)}%`);
|
||||
}
|
||||
|
||||
currentResult = newResult;
|
||||
}
|
||||
|
||||
logger.info(`TDD 迭代完成,共 ${iteration} 次迭代`);
|
||||
return currentResult;
|
||||
}
|
||||
|
||||
private extractFailedTests(result: TestResult): TestCase[] {
|
||||
const failedTests: TestCase[] = [];
|
||||
|
||||
for (const module of result.modules) {
|
||||
for (const suite of module.suites) {
|
||||
for (const test of suite.tests) {
|
||||
if (test.status === TestStatus.FAILED) {
|
||||
failedTests.push(test);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return failedTests;
|
||||
}
|
||||
|
||||
private shouldStop(stats: ReturnType<ErrorAnalyzer['getErrorStatistics']>, failedCount: number): boolean {
|
||||
if (this.config.stopOnCriticalFailure) {
|
||||
if (stats.bySeverity[ErrorSeverity.CRITICAL] > 0) {
|
||||
logger.warn('发现严重错误,停止迭代');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const criticalAndHighCount =
|
||||
(stats.bySeverity[ErrorSeverity.CRITICAL] || 0) +
|
||||
(stats.bySeverity[ErrorSeverity.HIGH] || 0);
|
||||
|
||||
if (failedCount > 0 && criticalAndHighCount / failedCount > this.config.stopThreshold) {
|
||||
logger.warn('高严重性错误比例过高,停止迭代');
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async generateFixes(
|
||||
analyses: Map<string, ErrorAnalysis>,
|
||||
failedTests: TestCase[]
|
||||
): Promise<TDDFix[]> {
|
||||
const fixes: TDDFix[] = [];
|
||||
|
||||
const analysisEntries = Array.from(analyses.entries());
|
||||
for (const [testId, analysis] of analysisEntries) {
|
||||
if (!analysis.autoFixable || !analysis.fixStrategy) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const testCase = failedTests.find(t => t.id === testId);
|
||||
if (!testCase) continue;
|
||||
|
||||
const fix: TDDFix = {
|
||||
id: `fix-${testId}-${Date.now()}`,
|
||||
type: this.mapFixType(analysis.fixStrategy.type),
|
||||
description: analysis.fixStrategy.description,
|
||||
affectedTests: [testId],
|
||||
status: 'pending'
|
||||
};
|
||||
|
||||
fixes.push(fix);
|
||||
}
|
||||
|
||||
return fixes;
|
||||
}
|
||||
|
||||
private mapFixType(type: string): 'code' | 'test' | 'config' | 'data' {
|
||||
switch (type) {
|
||||
case 'code':
|
||||
return 'code';
|
||||
case 'config':
|
||||
return 'config';
|
||||
case 'data':
|
||||
return 'data';
|
||||
case 'wait':
|
||||
case 'retry':
|
||||
default:
|
||||
return 'test';
|
||||
}
|
||||
}
|
||||
|
||||
private async applyFixes(fixes: TDDFix[]): Promise<TDDFix[]> {
|
||||
const appliedFixes: TDDFix[] = [];
|
||||
|
||||
for (const fix of fixes) {
|
||||
try {
|
||||
const success = await this.applyFix(fix);
|
||||
|
||||
if (success) {
|
||||
fix.status = 'applied';
|
||||
this.appliedFixes.set(fix.id, fix);
|
||||
appliedFixes.push(fix);
|
||||
logger.info(`修复已应用: ${fix.description}`);
|
||||
} else {
|
||||
fix.status = 'failed';
|
||||
logger.warn(`修复应用失败: ${fix.description}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fix.status = 'failed';
|
||||
logger.error(`修复应用出错: ${fix.description}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return appliedFixes;
|
||||
}
|
||||
|
||||
private async applyFix(fix: TDDFix): Promise<boolean> {
|
||||
switch (fix.type) {
|
||||
case 'config':
|
||||
return await this.applyConfigFix(fix);
|
||||
|
||||
case 'test':
|
||||
return await this.applyTestFix(fix);
|
||||
|
||||
case 'code':
|
||||
return await this.applyCodeFix(fix);
|
||||
|
||||
case 'data':
|
||||
return await this.applyDataFix(fix);
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async applyConfigFix(fix: TDDFix): Promise<boolean> {
|
||||
logger.debug(`应用配置修复: ${fix.description}`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async applyTestFix(fix: TDDFix): Promise<boolean> {
|
||||
logger.debug(`应用测试修复: ${fix.description}`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async applyCodeFix(fix: TDDFix): Promise<boolean> {
|
||||
logger.debug(`应用代码修复: ${fix.description}`);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async applyDataFix(fix: TDDFix): Promise<boolean> {
|
||||
logger.debug(`应用数据修复: ${fix.description}`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getIterations(): TDDIteration[] {
|
||||
return [...this.iterations];
|
||||
}
|
||||
|
||||
getAppliedFixes(): TDDFix[] {
|
||||
return Array.from(this.appliedFixes.values());
|
||||
}
|
||||
|
||||
getIterationReport(): {
|
||||
totalIterations: number;
|
||||
totalFixes: number;
|
||||
passRateImprovement: number;
|
||||
finalPassRate: number;
|
||||
} {
|
||||
if (this.iterations.length === 0) {
|
||||
return {
|
||||
totalIterations: 0,
|
||||
totalFixes: 0,
|
||||
passRateImprovement: 0,
|
||||
finalPassRate: 0
|
||||
};
|
||||
}
|
||||
|
||||
const firstIteration = this.iterations[0];
|
||||
const lastIteration = this.iterations[this.iterations.length - 1];
|
||||
|
||||
return {
|
||||
totalIterations: this.iterations.length,
|
||||
totalFixes: this.appliedFixes.size,
|
||||
passRateImprovement: lastIteration.currentResult.passRate - firstIteration.previousResult.passRate,
|
||||
finalPassRate: lastIteration.currentResult.passRate
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { logger } from '../utils/logger';
|
||||
import {
|
||||
TestResult,
|
||||
TestModule,
|
||||
TestSuite,
|
||||
TestCase,
|
||||
TestStatus,
|
||||
TestResultBuilder,
|
||||
TestModuleBuilder,
|
||||
ErrorType
|
||||
} from '../models/test-result';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
interface PlaywrightTest {
|
||||
status: 'passed' | 'failed' | 'timedOut' | 'skipped';
|
||||
duration: number;
|
||||
error?: {
|
||||
message: string;
|
||||
stack?: string;
|
||||
};
|
||||
annotations: Array<{ type: string; description?: string }>;
|
||||
results?: Array<{
|
||||
status: 'passed' | 'failed' | 'timedOut' | 'skipped';
|
||||
duration: number;
|
||||
error?: {
|
||||
message: string;
|
||||
stack?: string;
|
||||
};
|
||||
stdout?: Array<{ text: string }>;
|
||||
stderr?: Array<{ text: string }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface PlaywrightSpec {
|
||||
title: string;
|
||||
tests: PlaywrightTest[];
|
||||
status: 'expected' | 'unexpected' | 'skipped' | 'flaky';
|
||||
file?: string;
|
||||
line?: number;
|
||||
tags?: string[];
|
||||
ok?: boolean;
|
||||
}
|
||||
|
||||
interface PlaywrightSuite {
|
||||
title: string;
|
||||
suites: PlaywrightSuite[];
|
||||
specs: PlaywrightSpec[];
|
||||
}
|
||||
|
||||
interface PlaywrightProject {
|
||||
name: string;
|
||||
suites: PlaywrightSuite[];
|
||||
}
|
||||
|
||||
interface PlaywrightReport {
|
||||
config?: {
|
||||
projects?: PlaywrightProject[];
|
||||
};
|
||||
suites?: PlaywrightSuite[];
|
||||
projects?: PlaywrightProject[];
|
||||
errors?: Array<{
|
||||
message: string;
|
||||
stack?: string;
|
||||
location?: {
|
||||
file: string;
|
||||
line: number;
|
||||
column: number;
|
||||
};
|
||||
}>;
|
||||
stats: {
|
||||
startTime?: string;
|
||||
duration: number;
|
||||
expected: number;
|
||||
unexpected: number;
|
||||
skipped: number;
|
||||
flaky?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class TestExecutor {
|
||||
private testRoot: string;
|
||||
private resultsDir: string;
|
||||
|
||||
constructor() {
|
||||
this.testRoot = process.cwd();
|
||||
this.resultsDir = path.join(this.testRoot, 'test-results');
|
||||
this.ensureResultsDir();
|
||||
}
|
||||
|
||||
private ensureResultsDir(): void {
|
||||
if (!fs.existsSync(this.resultsDir)) {
|
||||
fs.mkdirSync(this.resultsDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async executeAll(): Promise<TestResult> {
|
||||
logger.section('执行全量测试');
|
||||
|
||||
const builder = new TestResultBuilder();
|
||||
builder.setStartTime(new Date());
|
||||
|
||||
const modules: Array<'api' | 'admin' | 'uniapp'> = ['api', 'admin', 'uniapp'];
|
||||
|
||||
for (const moduleName of modules) {
|
||||
logger.info(`开始执行 ${moduleName} 模块测试...`);
|
||||
const module = await this.executeModule(moduleName);
|
||||
builder.addModule(module);
|
||||
}
|
||||
|
||||
builder.setEndTime(new Date());
|
||||
const result = builder.build();
|
||||
|
||||
logger.info('全量测试执行完成', {
|
||||
totalTests: result.totalTests,
|
||||
passed: result.passedTests,
|
||||
failed: result.failedTests,
|
||||
passRate: `${result.passRate.toFixed(2)}%`
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async executeModule(moduleName: 'api' | 'admin' | 'uniapp'): Promise<TestModule> {
|
||||
const builder = new TestModuleBuilder(moduleName);
|
||||
builder.setStartTime(new Date());
|
||||
|
||||
const testDir = this.getTestDir(moduleName);
|
||||
|
||||
if (!fs.existsSync(testDir)) {
|
||||
logger.warn(`${moduleName} 测试目录不存在: ${testDir}`);
|
||||
builder.setEndTime(new Date());
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
const testFiles = this.findTestFiles(testDir);
|
||||
|
||||
if (testFiles.length === 0) {
|
||||
logger.warn(`${moduleName} 模块没有找到测试文件`);
|
||||
builder.setEndTime(new Date());
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
logger.info(`找到 ${testFiles.length} 个测试文件`);
|
||||
|
||||
const reportPath = path.join(this.resultsDir, `${moduleName}-report.json`);
|
||||
await this.runPlaywrightTests(moduleName, testDir, reportPath);
|
||||
|
||||
const suites = await this.parseTestResults(reportPath, moduleName);
|
||||
for (const suite of suites) {
|
||||
builder.addSuite(suite);
|
||||
}
|
||||
|
||||
builder.setEndTime(new Date());
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private getTestDir(moduleName: 'api' | 'admin' | 'uniapp'): string {
|
||||
const baseDir = path.join(this.testRoot, 'e2e');
|
||||
switch (moduleName) {
|
||||
case 'api':
|
||||
return path.join(baseDir, 'config');
|
||||
case 'admin':
|
||||
return path.join(baseDir, 'admin');
|
||||
case 'uniapp':
|
||||
return path.join(baseDir, 'uniapp');
|
||||
default:
|
||||
return baseDir;
|
||||
}
|
||||
}
|
||||
|
||||
private findTestFiles(dir: string): string[] {
|
||||
const files: string[] = [];
|
||||
|
||||
const scanDir = (currentDir: string) => {
|
||||
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
scanDir(fullPath);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.spec.ts')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
scanDir(dir);
|
||||
return files;
|
||||
}
|
||||
|
||||
private async runPlaywrightTests(
|
||||
moduleName: string,
|
||||
testDir: string,
|
||||
reportPath: string
|
||||
): Promise<void> {
|
||||
logger.info(`运行 Playwright 测试: ${moduleName}`);
|
||||
|
||||
const command = [
|
||||
'npx playwright test',
|
||||
testDir,
|
||||
'--reporter=json',
|
||||
'--workers=2',
|
||||
'--retries=1'
|
||||
].join(' ');
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
cwd: this.testRoot,
|
||||
maxBuffer: 1024 * 1024 * 50
|
||||
});
|
||||
|
||||
if (stdout) {
|
||||
const jsonContent = this.extractJsonFromOutput(stdout);
|
||||
if (jsonContent) {
|
||||
fs.writeFileSync(reportPath, jsonContent, 'utf-8');
|
||||
logger.debug(`Playwright 报告已保存: ${reportPath}`);
|
||||
}
|
||||
}
|
||||
if (stderr) {
|
||||
logger.debug(`Playwright 错误输出: ${stderr}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const execError = error as { stdout?: string; stderr?: string; message?: string };
|
||||
if (execError.stdout) {
|
||||
const jsonContent = this.extractJsonFromOutput(execError.stdout);
|
||||
if (jsonContent) {
|
||||
fs.writeFileSync(reportPath, jsonContent, 'utf-8');
|
||||
logger.debug(`Playwright 报告已保存: ${reportPath}`);
|
||||
}
|
||||
}
|
||||
if (execError.stderr) {
|
||||
logger.debug(`Playwright 错误输出: ${execError.stderr}`);
|
||||
}
|
||||
logger.warn(`${moduleName} 测试执行有失败用例`);
|
||||
}
|
||||
}
|
||||
|
||||
private extractJsonFromOutput(output: string): string | null {
|
||||
const ansiRegex = /\x1b\[[0-9;]*m/g;
|
||||
const cleanOutput = output.replace(ansiRegex, '');
|
||||
|
||||
const lines = cleanOutput.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('{')) {
|
||||
try {
|
||||
JSON.parse(trimmed);
|
||||
return trimmed;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const jsonStart = cleanOutput.indexOf('{');
|
||||
if (jsonStart !== -1) {
|
||||
try {
|
||||
const jsonStr = cleanOutput.substring(jsonStart);
|
||||
JSON.parse(jsonStr);
|
||||
return jsonStr;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async parseTestResults(
|
||||
reportPath: string,
|
||||
moduleName: 'api' | 'admin' | 'uniapp'
|
||||
): Promise<TestSuite[]> {
|
||||
const suites: TestSuite[] = [];
|
||||
|
||||
if (!fs.existsSync(reportPath)) {
|
||||
logger.warn(`测试报告不存在: ${reportPath}`);
|
||||
return suites;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(reportPath, 'utf-8');
|
||||
const report = JSON.parse(content) as PlaywrightReport;
|
||||
|
||||
const allSuites = this.getAllSuites(report);
|
||||
const testCases = this.extractTestCases(allSuites, moduleName);
|
||||
|
||||
if (report.errors && report.errors.length > 0) {
|
||||
for (const error of report.errors) {
|
||||
const errorTestCase: TestCase = {
|
||||
id: `${moduleName}-error-${Date.now()}`,
|
||||
name: '测试加载错误',
|
||||
suite: '全局错误',
|
||||
module: moduleName,
|
||||
status: TestStatus.FAILED,
|
||||
duration: 0,
|
||||
startTime: new Date(),
|
||||
steps: [],
|
||||
retries: 0,
|
||||
maxRetries: 0,
|
||||
tags: ['error'],
|
||||
priority: 'high',
|
||||
error: {
|
||||
type: ErrorType.ENVIRONMENT_ERROR,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
timestamp: new Date()
|
||||
}
|
||||
};
|
||||
testCases.push(errorTestCase);
|
||||
}
|
||||
}
|
||||
|
||||
const suite: TestSuite = {
|
||||
id: `${moduleName}-suite`,
|
||||
name: `${moduleName} 测试套件`,
|
||||
module: moduleName,
|
||||
tests: testCases,
|
||||
status: testCases.some(t => t.status === TestStatus.FAILED)
|
||||
? TestStatus.FAILED
|
||||
: TestStatus.PASSED,
|
||||
duration: testCases.reduce((sum, t) => sum + t.duration, 0),
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
passRate: testCases.length > 0
|
||||
? (testCases.filter(t => t.status === TestStatus.PASSED).length / testCases.length) * 100
|
||||
: 0
|
||||
};
|
||||
|
||||
suites.push(suite);
|
||||
} catch (error) {
|
||||
logger.error(`解析测试报告失败: ${reportPath}`, error);
|
||||
}
|
||||
|
||||
return suites;
|
||||
}
|
||||
|
||||
private getAllSuites(report: PlaywrightReport): PlaywrightSuite[] {
|
||||
const allSuites: PlaywrightSuite[] = [];
|
||||
|
||||
const collectSuites = (suites: PlaywrightSuite[]) => {
|
||||
for (const suite of suites) {
|
||||
allSuites.push(suite);
|
||||
if (suite.suites && suite.suites.length > 0) {
|
||||
collectSuites(suite.suites);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (report.projects) {
|
||||
for (const project of report.projects) {
|
||||
collectSuites(project.suites);
|
||||
}
|
||||
}
|
||||
|
||||
if (report.suites) {
|
||||
collectSuites(report.suites);
|
||||
}
|
||||
|
||||
return allSuites;
|
||||
}
|
||||
|
||||
private extractTestCases(
|
||||
suites: PlaywrightSuite[],
|
||||
moduleName: 'api' | 'admin' | 'uniapp'
|
||||
): TestCase[] {
|
||||
const cases: TestCase[] = [];
|
||||
let idCounter = 1;
|
||||
|
||||
for (const suite of suites) {
|
||||
for (const spec of suite.specs) {
|
||||
for (const test of spec.tests) {
|
||||
const testResult = test.results?.[0] || test;
|
||||
const testCase: TestCase = {
|
||||
id: `${moduleName}-test-${idCounter++}`,
|
||||
name: spec.title,
|
||||
suite: suite.title,
|
||||
module: moduleName,
|
||||
status: this.mapStatus(testResult.status),
|
||||
duration: testResult.duration || 0,
|
||||
startTime: new Date(),
|
||||
steps: [],
|
||||
retries: 0,
|
||||
maxRetries: 1,
|
||||
tags: spec.tags || test.annotations?.map(a => a.type) || [],
|
||||
priority: 'medium'
|
||||
};
|
||||
|
||||
if (testResult.error) {
|
||||
testCase.error = {
|
||||
type: this.detectErrorType(testResult.error.message),
|
||||
message: testResult.error.message,
|
||||
stack: testResult.error.stack,
|
||||
timestamp: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
cases.push(testCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cases;
|
||||
}
|
||||
|
||||
private mapStatus(status: string): TestStatus {
|
||||
switch (status) {
|
||||
case 'passed':
|
||||
return TestStatus.PASSED;
|
||||
case 'failed':
|
||||
return TestStatus.FAILED;
|
||||
case 'timedOut':
|
||||
return TestStatus.FAILED;
|
||||
case 'skipped':
|
||||
return TestStatus.SKIPPED;
|
||||
default:
|
||||
return TestStatus.PENDING;
|
||||
}
|
||||
}
|
||||
|
||||
private detectErrorType(message: string): ErrorType {
|
||||
const lowerMessage = message.toLowerCase();
|
||||
|
||||
if (lowerMessage.includes('timeout') || lowerMessage.includes('timed out')) {
|
||||
return ErrorType.TIMEOUT;
|
||||
}
|
||||
if (lowerMessage.includes('not found') || lowerMessage.includes('no element') || lowerMessage.includes('selector')) {
|
||||
return ErrorType.ELEMENT_NOT_FOUND;
|
||||
}
|
||||
if (lowerMessage.includes('api') || lowerMessage.includes('request failed') || lowerMessage.includes('status code')) {
|
||||
return ErrorType.API_ERROR;
|
||||
}
|
||||
if (lowerMessage.includes('assertion') || lowerMessage.includes('expect') || lowerMessage.includes('assert')) {
|
||||
return ErrorType.ASSERTION_ERROR;
|
||||
}
|
||||
if (lowerMessage.includes('network') || lowerMessage.includes('connection') || lowerMessage.includes('econnrefused')) {
|
||||
return ErrorType.NETWORK_ERROR;
|
||||
}
|
||||
if (lowerMessage.includes('auth') || lowerMessage.includes('unauthorized') || lowerMessage.includes('forbidden')) {
|
||||
return ErrorType.AUTH_ERROR;
|
||||
}
|
||||
if (lowerMessage.includes('data') || lowerMessage.includes('json') || lowerMessage.includes('parse')) {
|
||||
return ErrorType.DATA_ERROR;
|
||||
}
|
||||
if (lowerMessage.includes('environment') || lowerMessage.includes('config') || lowerMessage.includes('setup')) {
|
||||
return ErrorType.ENVIRONMENT_ERROR;
|
||||
}
|
||||
|
||||
return ErrorType.UNKNOWN;
|
||||
}
|
||||
|
||||
async runSpecificTest(testPath: string): Promise<TestCase | null> {
|
||||
logger.info(`运行单个测试: ${testPath}`);
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`npx playwright test ${testPath} --reporter=json`,
|
||||
{ cwd: this.testRoot, maxBuffer: 1024 * 1024 * 10 }
|
||||
);
|
||||
|
||||
const report = JSON.parse(stdout) as PlaywrightReport;
|
||||
const allSuites = this.getAllSuites(report);
|
||||
const cases = this.extractTestCases(allSuites, 'api');
|
||||
return cases[0] || null;
|
||||
} catch (error) {
|
||||
logger.error(`运行测试失败: ${testPath}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "📊 生成测试报告..."
|
||||
|
||||
# 生成HTML报告
|
||||
if [ -d "playwright-report" ]; then
|
||||
echo "✅ HTML报告已生成: playwright-report/index.html"
|
||||
else
|
||||
echo "⚠️ HTML报告不存在,请先运行测试"
|
||||
fi
|
||||
|
||||
# 生成JSON报告
|
||||
if [ -f "test-results/results.json" ]; then
|
||||
echo "✅ JSON报告已生成: test-results/results.json"
|
||||
else
|
||||
echo "⚠️ JSON报告不存在,请先运行测试"
|
||||
fi
|
||||
|
||||
# 生成JUnit报告
|
||||
if [ -f "test-results/junit.xml" ]; then
|
||||
echo "✅ JUnit报告已生成: test-results/junit.xml"
|
||||
else
|
||||
echo "⚠️ JUnit报告不存在,请先运行测试"
|
||||
fi
|
||||
|
||||
echo "📊 测试报告生成完成!"
|
||||
@@ -0,0 +1 @@
|
||||
export * from './test-result';
|
||||
@@ -0,0 +1,245 @@
|
||||
export enum TestStatus {
|
||||
PENDING = 'pending',
|
||||
RUNNING = 'running',
|
||||
PASSED = 'passed',
|
||||
FAILED = 'failed',
|
||||
SKIPPED = 'skipped',
|
||||
RETRY = 'retry'
|
||||
}
|
||||
|
||||
export enum ErrorType {
|
||||
TIMEOUT = 'timeout',
|
||||
ELEMENT_NOT_FOUND = 'element_not_found',
|
||||
API_ERROR = 'api_error',
|
||||
ASSERTION_ERROR = 'assertion_error',
|
||||
NETWORK_ERROR = 'network_error',
|
||||
AUTH_ERROR = 'auth_error',
|
||||
DATA_ERROR = 'data_error',
|
||||
ENVIRONMENT_ERROR = 'environment_error',
|
||||
UNKNOWN = 'unknown'
|
||||
}
|
||||
|
||||
export interface TestError {
|
||||
type: ErrorType;
|
||||
message: string;
|
||||
stack?: string;
|
||||
screenshot?: string;
|
||||
video?: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface TestStep {
|
||||
name: string;
|
||||
status: TestStatus;
|
||||
duration: number;
|
||||
error?: TestError;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface TestCase {
|
||||
id: string;
|
||||
name: string;
|
||||
suite: string;
|
||||
module: 'api' | 'admin' | 'uniapp';
|
||||
status: TestStatus;
|
||||
duration: number;
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
steps: TestStep[];
|
||||
error?: TestError;
|
||||
retries: number;
|
||||
maxRetries: number;
|
||||
tags: string[];
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
export interface TestSuite {
|
||||
id: string;
|
||||
name: string;
|
||||
module: 'api' | 'admin' | 'uniapp';
|
||||
tests: TestCase[];
|
||||
status: TestStatus;
|
||||
duration: number;
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
passRate: number;
|
||||
}
|
||||
|
||||
export interface TestModule {
|
||||
name: 'api' | 'admin' | 'uniapp';
|
||||
suites: TestSuite[];
|
||||
status: TestStatus;
|
||||
duration: number;
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
totalTests: number;
|
||||
passedTests: number;
|
||||
failedTests: number;
|
||||
skippedTests: number;
|
||||
passRate: number;
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
modules: TestModule[];
|
||||
status: TestStatus;
|
||||
duration: number;
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
totalTests: number;
|
||||
passedTests: number;
|
||||
failedTests: number;
|
||||
skippedTests: number;
|
||||
passRate: number;
|
||||
environment: TestEnvironment;
|
||||
}
|
||||
|
||||
export interface TestEnvironment {
|
||||
nodeVersion: string;
|
||||
os: string;
|
||||
browserVersions: Record<string, string>;
|
||||
apiBaseUrl: string;
|
||||
adminBaseUrl: string;
|
||||
uniappBaseUrl: string;
|
||||
databaseType: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface TDDIteration {
|
||||
iteration: number;
|
||||
previousResult: TestResult;
|
||||
currentResult: TestResult;
|
||||
fixes: TDDFix[];
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface TDDFix {
|
||||
id: string;
|
||||
type: 'code' | 'test' | 'config' | 'data';
|
||||
description: string;
|
||||
affectedTests: string[];
|
||||
status: 'pending' | 'applied' | 'verified' | 'failed';
|
||||
}
|
||||
|
||||
export class TestResultBuilder {
|
||||
private result: TestResult;
|
||||
|
||||
constructor() {
|
||||
this.result = {
|
||||
modules: [],
|
||||
status: TestStatus.PENDING,
|
||||
duration: 0,
|
||||
startTime: new Date(),
|
||||
totalTests: 0,
|
||||
passedTests: 0,
|
||||
failedTests: 0,
|
||||
skippedTests: 0,
|
||||
passRate: 0,
|
||||
environment: {
|
||||
nodeVersion: process.version,
|
||||
os: process.platform,
|
||||
browserVersions: {},
|
||||
apiBaseUrl: process.env.API_BASE_URL || 'http://localhost:8080',
|
||||
adminBaseUrl: process.env.ADMIN_BASE_URL || 'http://localhost:5174',
|
||||
uniappBaseUrl: process.env.UNIAPP_BASE_URL || 'http://localhost:8081',
|
||||
databaseType: 'h2',
|
||||
timestamp: new Date()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setStartTime(startTime: Date): this {
|
||||
this.result.startTime = startTime;
|
||||
return this;
|
||||
}
|
||||
|
||||
setEndTime(endTime: Date): this {
|
||||
this.result.endTime = endTime;
|
||||
this.result.duration = endTime.getTime() - this.result.startTime.getTime();
|
||||
return this;
|
||||
}
|
||||
|
||||
addModule(module: TestModule): this {
|
||||
this.result.modules.push(module);
|
||||
this.updateTotals();
|
||||
return this;
|
||||
}
|
||||
|
||||
private updateTotals(): void {
|
||||
this.result.totalTests = this.result.modules.reduce((sum, m) => sum + m.totalTests, 0);
|
||||
this.result.passedTests = this.result.modules.reduce((sum, m) => sum + m.passedTests, 0);
|
||||
this.result.failedTests = this.result.modules.reduce((sum, m) => sum + m.failedTests, 0);
|
||||
this.result.skippedTests = this.result.modules.reduce((sum, m) => sum + m.skippedTests, 0);
|
||||
this.result.passRate = this.result.totalTests > 0
|
||||
? (this.result.passedTests / this.result.totalTests) * 100
|
||||
: 0;
|
||||
this.result.status = this.result.failedTests > 0
|
||||
? TestStatus.FAILED
|
||||
: TestStatus.PASSED;
|
||||
}
|
||||
|
||||
build(): TestResult {
|
||||
return { ...this.result };
|
||||
}
|
||||
}
|
||||
|
||||
export class TestModuleBuilder {
|
||||
private module: TestModule;
|
||||
|
||||
constructor(name: 'api' | 'admin' | 'uniapp') {
|
||||
this.module = {
|
||||
name,
|
||||
suites: [],
|
||||
status: TestStatus.PENDING,
|
||||
duration: 0,
|
||||
startTime: new Date(),
|
||||
totalTests: 0,
|
||||
passedTests: 0,
|
||||
failedTests: 0,
|
||||
skippedTests: 0,
|
||||
passRate: 0
|
||||
};
|
||||
}
|
||||
|
||||
setStartTime(startTime: Date): this {
|
||||
this.module.startTime = startTime;
|
||||
return this;
|
||||
}
|
||||
|
||||
setEndTime(endTime: Date): this {
|
||||
this.module.endTime = endTime;
|
||||
this.module.duration = endTime.getTime() - this.module.startTime.getTime();
|
||||
return this;
|
||||
}
|
||||
|
||||
addSuite(suite: TestSuite): this {
|
||||
this.module.suites.push(suite);
|
||||
this.updateTotals();
|
||||
return this;
|
||||
}
|
||||
|
||||
private updateTotals(): void {
|
||||
this.module.totalTests = this.module.suites.reduce((sum, s) => sum + s.tests.length, 0);
|
||||
this.module.passedTests = this.module.suites.reduce(
|
||||
(sum, s) => sum + s.tests.filter(t => t.status === TestStatus.PASSED).length,
|
||||
0
|
||||
);
|
||||
this.module.failedTests = this.module.suites.reduce(
|
||||
(sum, s) => sum + s.tests.filter(t => t.status === TestStatus.FAILED).length,
|
||||
0
|
||||
);
|
||||
this.module.skippedTests = this.module.suites.reduce(
|
||||
(sum, s) => sum + s.tests.filter(t => t.status === TestStatus.SKIPPED).length,
|
||||
0
|
||||
);
|
||||
this.module.passRate = this.module.totalTests > 0
|
||||
? (this.module.passedTests / this.module.totalTests) * 100
|
||||
: 0;
|
||||
this.module.status = this.module.failedTests > 0
|
||||
? TestStatus.FAILED
|
||||
: TestStatus.PASSED;
|
||||
}
|
||||
|
||||
build(): TestModule {
|
||||
return { ...this.module };
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "🚀 开始运行所有测试..."
|
||||
|
||||
# 运行单元测试
|
||||
echo "📝 运行单元测试..."
|
||||
npm run test:unit || { echo "❌ 单元测试失败"; exit 1; }
|
||||
|
||||
# 运行API测试
|
||||
echo "🔌 运行API测试..."
|
||||
npm run test:api || { echo "❌ API测试失败"; exit 1; }
|
||||
|
||||
# 运行E2E测试
|
||||
echo "🌐 运行E2E测试..."
|
||||
npm run test:e2e || { echo "❌ E2E测试失败"; exit 1; }
|
||||
|
||||
echo "✅ 所有测试完成!"
|
||||
@@ -0,0 +1,248 @@
|
||||
import { logger } from './utils/logger';
|
||||
import { TestEnvironmentManager } from './core/environment';
|
||||
import { TestExecutor } from './core/test-executor';
|
||||
import { TDDIterator } from './core/tdd-iterator';
|
||||
import { ReportGenerator } from './core/report-generator';
|
||||
import { TestResult, TestStatus } from './models/test-result';
|
||||
|
||||
interface RunOptions {
|
||||
modules?: Array<'api' | 'admin' | 'uniapp'>;
|
||||
startServices?: boolean;
|
||||
tddEnabled?: boolean;
|
||||
maxIterations?: number;
|
||||
reportFormats?: Array<'html' | 'json' | 'junit' | 'markdown'>;
|
||||
exitOnComplete?: boolean;
|
||||
}
|
||||
|
||||
class RunAllTests {
|
||||
private environment: TestEnvironmentManager;
|
||||
private executor: TestExecutor;
|
||||
private tddIterator: TDDIterator;
|
||||
private reportGenerator: ReportGenerator;
|
||||
|
||||
constructor() {
|
||||
this.environment = new TestEnvironmentManager();
|
||||
this.executor = new TestExecutor();
|
||||
this.tddIterator = new TDDIterator({
|
||||
maxIterations: 3,
|
||||
autoFixEnabled: true,
|
||||
stopOnCriticalFailure: true
|
||||
});
|
||||
this.reportGenerator = new ReportGenerator({
|
||||
formats: ['html', 'json', 'markdown']
|
||||
});
|
||||
}
|
||||
|
||||
async run(options: RunOptions = {}): Promise<number> {
|
||||
const startTime = Date.now();
|
||||
|
||||
logger.section('E2E 全量自动化测试');
|
||||
logger.info('测试启动', { options });
|
||||
|
||||
try {
|
||||
if (options.startServices !== false) {
|
||||
await this.startServices(options.modules);
|
||||
}
|
||||
|
||||
logger.section('执行测试');
|
||||
const initialResult = await this.executor.executeAll();
|
||||
|
||||
let finalResult = initialResult;
|
||||
|
||||
if (options.tddEnabled !== false && initialResult.failedTests > 0) {
|
||||
logger.section('TDD 迭代优化');
|
||||
finalResult = await this.tddIterator.iterate(initialResult);
|
||||
}
|
||||
|
||||
logger.section('生成报告');
|
||||
const iterations = this.tddIterator.getIterations();
|
||||
const reportFiles = await this.reportGenerator.generate(finalResult, iterations);
|
||||
|
||||
logger.info('报告生成完成', { files: reportFiles });
|
||||
|
||||
this.printSummary(finalResult, startTime);
|
||||
|
||||
const exitCode = finalResult.failedTests > 0 ? 1 : 0;
|
||||
|
||||
if (options.exitOnComplete !== false) {
|
||||
await this.cleanup();
|
||||
}
|
||||
|
||||
return exitCode;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('测试执行失败', error);
|
||||
await this.cleanup();
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private async startServices(modules?: Array<'api' | 'admin' | 'uniapp'>): Promise<void> {
|
||||
logger.section('启动服务');
|
||||
|
||||
const servicesToStart = modules || ['api', 'admin', 'uniapp'];
|
||||
|
||||
for (const service of servicesToStart) {
|
||||
switch (service) {
|
||||
case 'api':
|
||||
await this.environment.startAPIService();
|
||||
break;
|
||||
case 'admin':
|
||||
await this.environment.startAdminService();
|
||||
break;
|
||||
case 'uniapp':
|
||||
await this.environment.startUniappService();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await this.waitForServicesReady(servicesToStart);
|
||||
}
|
||||
|
||||
private async waitForServicesReady(services: Array<'api' | 'admin' | 'uniapp'>): Promise<void> {
|
||||
logger.info('等待服务就绪...');
|
||||
|
||||
const healthChecks: Record<string, string> = {
|
||||
api: process.env.API_BASE_URL || 'http://localhost:8080/actuator/health',
|
||||
admin: process.env.ADMIN_BASE_URL || 'http://localhost:5174',
|
||||
uniapp: process.env.UNIAPP_BASE_URL || 'http://localhost:8081'
|
||||
};
|
||||
|
||||
for (const service of services) {
|
||||
const url = healthChecks[service];
|
||||
if (url) {
|
||||
let retries = 30;
|
||||
while (retries > 0) {
|
||||
const isReady = await this.environment.checkServiceHealth(service, url);
|
||||
if (isReady) {
|
||||
logger.info(`${service} 服务就绪`);
|
||||
break;
|
||||
}
|
||||
retries--;
|
||||
await this.sleep(2000);
|
||||
}
|
||||
if (retries === 0) {
|
||||
logger.warn(`${service} 服务未能在预期时间内就绪`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private printSummary(result: TestResult, startTime: number): void {
|
||||
const totalDuration = Date.now() - startTime;
|
||||
|
||||
logger.section('测试执行总结');
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' 测试执行完成');
|
||||
console.log('='.repeat(60));
|
||||
console.log(`\n 总测试数: ${result.totalTests}`);
|
||||
console.log(` ✅ 通过: ${result.passedTests}`);
|
||||
console.log(` ❌ 失败: ${result.failedTests}`);
|
||||
console.log(` ⏭️ 跳过: ${result.skippedTests}`);
|
||||
console.log(`\n 通过率: ${result.passRate.toFixed(2)}%`);
|
||||
console.log(` 总耗时: ${(totalDuration / 1000).toFixed(2)} 秒`);
|
||||
console.log('\n' + '='.repeat(60) + '\n');
|
||||
|
||||
if (result.failedTests > 0) {
|
||||
logger.warn('存在失败的测试用例,请查看详细报告');
|
||||
} else {
|
||||
logger.info('所有测试通过!');
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanup(): Promise<void> {
|
||||
logger.section('清理环境');
|
||||
await this.environment.cleanup();
|
||||
logger.info('环境清理完成,准备退出');
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<number> {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
const options: RunOptions = {
|
||||
modules: undefined,
|
||||
startServices: true,
|
||||
tddEnabled: true,
|
||||
maxIterations: 3,
|
||||
reportFormats: ['html', 'json', 'markdown'],
|
||||
exitOnComplete: true
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
|
||||
switch (arg) {
|
||||
case '--modules':
|
||||
case '-m':
|
||||
options.modules = args[++i]?.split(',') as Array<'api' | 'admin' | 'uniapp'>;
|
||||
break;
|
||||
case '--no-services':
|
||||
options.startServices = false;
|
||||
break;
|
||||
case '--no-tdd':
|
||||
options.tddEnabled = false;
|
||||
break;
|
||||
case '--iterations':
|
||||
case '-i':
|
||||
options.maxIterations = parseInt(args[++i], 10);
|
||||
break;
|
||||
case '--formats':
|
||||
case '-f':
|
||||
options.reportFormats = args[++i]?.split(',') as Array<'html' | 'json' | 'junit' | 'markdown'>;
|
||||
break;
|
||||
case '--help':
|
||||
case '-h':
|
||||
printHelp();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
const runner = new RunAllTests();
|
||||
return runner.run(options);
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`
|
||||
E2E 全量自动化测试运行器
|
||||
|
||||
用法: npx ts-node scripts/run-all-tests.ts [选项]
|
||||
|
||||
选项:
|
||||
-m, --modules <modules> 指定测试模块 (api,admin,uniapp),默认全部
|
||||
--no-services 不启动服务(服务已运行时使用)
|
||||
--no-tdd 禁用 TDD 迭代
|
||||
-i, --iterations <n> TDD 最大迭代次数,默认 3
|
||||
-f, --formats <formats> 报告格式 (html,json,junit,markdown),默认 html,json,markdown
|
||||
-h, --help 显示帮助信息
|
||||
|
||||
示例:
|
||||
# 运行所有测试
|
||||
npx ts-node scripts/run-all-tests.ts
|
||||
|
||||
# 只运行 API 和 Admin 测试
|
||||
npx ts-node scripts/run-all-tests.ts --modules api,admin
|
||||
|
||||
# 服务已运行,只执行测试
|
||||
npx ts-node scripts/run-all-tests.ts --no-services
|
||||
|
||||
# 禁用 TDD 迭代
|
||||
npx ts-node scripts/run-all-tests.ts --no-tdd
|
||||
`);
|
||||
}
|
||||
|
||||
main()
|
||||
.then(exitCode => {
|
||||
process.exit(exitCode);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('执行出错:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
export { RunAllTests, RunOptions };
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
#!/bin/bash
|
||||
|
||||
# E2E测试运行脚本
|
||||
# 支持多种运行模式和报告生成
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 默认配置
|
||||
ENV="local"
|
||||
PROJECT=""
|
||||
TAG=""
|
||||
REPORT=false
|
||||
HEADLESS=false
|
||||
DEBUG=false
|
||||
|
||||
# 帮助信息
|
||||
show_help() {
|
||||
echo "E2E测试运行脚本"
|
||||
echo ""
|
||||
echo "用法: ./run-e2e-tests.sh [选项]"
|
||||
echo ""
|
||||
echo "选项:"
|
||||
echo " -e, --env <环境> 指定测试环境 (local|dev|test|ci) [默认: local]"
|
||||
echo " -p, --project <项目> 指定测试项目 (admin|uniapp|integration)"
|
||||
echo " -t, --tag <标签> 按标签运行测试 (如: @boundary, @error)"
|
||||
echo " -r, --report 生成完整测试报告"
|
||||
echo " -h, --headless 无头模式运行"
|
||||
echo " -d, --debug 调试模式"
|
||||
echo " --help 显示帮助信息"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " ./run-e2e-tests.sh -e ci -r # CI环境运行所有测试并生成报告"
|
||||
echo " ./run-e2e-tests.sh -p admin -t @boundary # 只运行admin的边界测试"
|
||||
echo " ./run-e2e-tests.sh -p uniapp -h # 无头模式运行uniapp测试"
|
||||
}
|
||||
|
||||
# 解析参数
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-e|--env)
|
||||
ENV="$2"
|
||||
shift 2
|
||||
;;
|
||||
-p|--project)
|
||||
PROJECT="$2"
|
||||
shift 2
|
||||
;;
|
||||
-t|--tag)
|
||||
TAG="$2"
|
||||
shift 2
|
||||
;;
|
||||
-r|--report)
|
||||
REPORT=true
|
||||
shift
|
||||
;;
|
||||
-h|--headless)
|
||||
HEADLESS=true
|
||||
shift
|
||||
;;
|
||||
-d|--debug)
|
||||
DEBUG=true
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}错误: 未知选项 $1${NC}"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 设置环境变量
|
||||
export E2E_ENV="$ENV"
|
||||
if [ "$HEADLESS" = true ]; then
|
||||
export HEADLESS="true"
|
||||
fi
|
||||
if [ "$DEBUG" = true ]; then
|
||||
export DEBUG="pw:api"
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo -e "${BLUE} E2E端到端测试运行器${NC}"
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo ""
|
||||
echo -e "环境: ${GREEN}$ENV${NC}"
|
||||
echo -e "项目: ${GREEN}${PROJECT:-全部}${NC}"
|
||||
echo -e "标签: ${GREEN}${TAG:-无}${NC}"
|
||||
echo -e "报告: ${GREEN}$REPORT${NC}"
|
||||
echo -e "无头: ${GREEN}$HEADLESS${NC}"
|
||||
echo ""
|
||||
|
||||
# 构建Playwright命令
|
||||
CMD="npx playwright test"
|
||||
|
||||
# 添加项目过滤
|
||||
if [ -n "$PROJECT" ]; then
|
||||
CMD="$CMD --project=$PROJECT"
|
||||
fi
|
||||
|
||||
# 添加标签过滤
|
||||
if [ -n "$TAG" ]; then
|
||||
CMD="$CMD --grep='$TAG'"
|
||||
fi
|
||||
|
||||
# 添加报告器
|
||||
if [ "$REPORT" = true ]; then
|
||||
CMD="$CMD --reporter=html,json,junit,list"
|
||||
fi
|
||||
|
||||
# 添加调试选项
|
||||
if [ "$DEBUG" = true ]; then
|
||||
CMD="$CMD --debug"
|
||||
fi
|
||||
|
||||
# 显示执行的命令
|
||||
echo -e "${YELLOW}执行命令: $CMD${NC}"
|
||||
echo ""
|
||||
|
||||
# 运行测试
|
||||
eval $CMD
|
||||
|
||||
# 生成报告
|
||||
if [ "$REPORT" = true ]; then
|
||||
echo ""
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo -e "${BLUE} 生成测试报告${NC}"
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
|
||||
# 显示HTML报告路径
|
||||
if [ -f "test-results/html-report/index.html" ]; then
|
||||
echo -e "${GREEN}HTML报告: test-results/html-report/index.html${NC}"
|
||||
|
||||
# 尝试自动打开报告(macOS)
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
open test-results/html-report/index.html
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "test-results/e2e-results.json" ]; then
|
||||
echo -e "${GREEN}JSON报告: test-results/e2e-results.json${NC}"
|
||||
fi
|
||||
|
||||
if [ -f "test-results/junit-report.xml" ]; then
|
||||
echo -e "${GREEN}JUnit报告: test-results/junit-report.xml${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}测试完成!${NC}"
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
|
||||
# TDD测试执行脚本
|
||||
# 用于执行所有TDD测试套件
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 开始执行TDD测试套件..."
|
||||
echo ""
|
||||
|
||||
# 设置环境变量
|
||||
export E2E_MOCK_ENABLED=false
|
||||
export E2E_BASE_URL=http://localhost:5174
|
||||
export VITE_E2E_TEST=true
|
||||
|
||||
# 检查后端服务
|
||||
echo "🔍 检查后端API服务..."
|
||||
if curl -s http://127.0.0.1:8080/api/actuator/health > /dev/null; then
|
||||
echo "✅ 后端API服务运行正常"
|
||||
else
|
||||
echo "❌ 后端API服务未启动,请先启动后端服务"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查前端服务
|
||||
echo "🔍 检查前端Admin服务..."
|
||||
if curl -s http://localhost:5174 > /dev/null; then
|
||||
echo "✅ 前端Admin服务运行正常"
|
||||
else
|
||||
echo "❌ 前端Admin服务未启动,请先启动前端服务"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🧪 开始执行测试..."
|
||||
echo ""
|
||||
|
||||
# 执行认证模块测试
|
||||
echo "📋 执行用户认证模块测试..."
|
||||
npx playwright test e2e/auth-complete.spec.ts --project=chromium --reporter=list || true
|
||||
|
||||
echo ""
|
||||
echo "📋 执行用户管理模块测试..."
|
||||
npx playwright test e2e/user-management-complete.spec.ts --project=chromium --reporter=list || true
|
||||
|
||||
echo ""
|
||||
echo "📋 执行角色管理模块测试..."
|
||||
npx playwright test e2e/role-management-complete.spec.ts --project=chromium --reporter=list || true
|
||||
|
||||
echo ""
|
||||
echo "📋 执行菜单管理模块测试..."
|
||||
npx playwright test e2e/menu-management-complete.spec.ts --project=chromium --reporter=list || true
|
||||
|
||||
echo ""
|
||||
echo "✅ TDD测试套件执行完成!"
|
||||
echo ""
|
||||
echo "📊 查看测试报告:"
|
||||
echo " npx playwright show-report"
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "========================================="
|
||||
echo " 启动测试环境"
|
||||
echo "========================================"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
check_port() {
|
||||
local port=$1
|
||||
if lsof -Pi :$port -sTCP:LISTEN -t >/dev/null 2>&1; then
|
||||
echo "端口 $port 已被占用"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
echo "----------------------------------------"
|
||||
echo " 检查服务状态..."
|
||||
echo "----------------------------------------"
|
||||
|
||||
API_RUNNING=false
|
||||
ADMIN_RUNNING=false
|
||||
|
||||
if lsof -Pi :8080 -sTCP:LISTEN -t >/dev/null 2>&1; then
|
||||
echo "API服务已在运行 (端口 8080)"
|
||||
API_RUNNING=true
|
||||
fi
|
||||
|
||||
if lsof -Pi :5173 -sTCP:LISTEN -t >/dev/null 2>&1; then
|
||||
echo "Admin服务已在运行 (端口 5173)"
|
||||
ADMIN_RUNNING=true
|
||||
fi
|
||||
|
||||
if [ "$API_RUNNING" = true ] && [ "$ADMIN_RUNNING" = true ]; then
|
||||
echo "所有服务已在运行"
|
||||
bash "$SCRIPT_DIR/check-services.sh"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "----------------------------------------"
|
||||
echo " 启动 API 服务..."
|
||||
echo "----------------------------------------"
|
||||
if [ "$API_RUNNING" = false ]; then
|
||||
cd "$PROJECT_ROOT/../everything-is-suitable-api/everything-is-suitable-app"
|
||||
nohup mvn spring-boot:run -Dspring-boot.run.profiles=dev > /tmp/api.log 2>&1 &
|
||||
API_PID=$!
|
||||
echo "API服务启动中... (PID: $API_PID)"
|
||||
sleep 30
|
||||
else
|
||||
echo "API服务已在运行"
|
||||
fi
|
||||
|
||||
echo "----------------------------------------"
|
||||
echo " 启动 Admin 服务..."
|
||||
echo "----------------------------------------"
|
||||
if [ "$ADMIN_RUNNING" = false ]; then
|
||||
cd "$PROJECT_ROOT/../everything-is-suitable-admin"
|
||||
nohup npm run dev > /tmp/admin.log 2>&1 &
|
||||
ADMIN_PID=$!
|
||||
echo "Admin服务启动中... (PID: $ADMIN_PID)"
|
||||
sleep 10
|
||||
else
|
||||
echo "Admin服务已在运行"
|
||||
fi
|
||||
|
||||
echo "----------------------------------------"
|
||||
echo " 验证服务健康..."
|
||||
echo "----------------------------------------"
|
||||
bash "$SCRIPT_DIR/check-services.sh"
|
||||
|
||||
echo "========================================="
|
||||
echo " ✅ 所有服务启动成功!"
|
||||
echo "========================================="
|
||||
echo "API服务日志: /tmp/api.log"
|
||||
echo "Admin服务日志: /tmp/admin.log"
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "========================================="
|
||||
echo " 停止测试环境"
|
||||
echo "========================================"
|
||||
|
||||
echo "----------------------------------------"
|
||||
echo " 停止 API 服务..."
|
||||
echo "----------------------------------------"
|
||||
API_PIDS=$(ps aux | grep "everything-is-suitable-app" | grep -v grep | awk '{print $2}')
|
||||
if [ -n "$API_PIDS" ]; then
|
||||
echo "找到API服务进程: $API_PIDS"
|
||||
echo "$API_PIDS" | xargs kill -9 2>/dev/null || true
|
||||
echo "API服务已停止"
|
||||
else
|
||||
echo "API服务未运行"
|
||||
fi
|
||||
|
||||
echo "----------------------------------------"
|
||||
echo " 停止 Admin 服务..."
|
||||
echo "----------------------------------------"
|
||||
ADMIN_PIDS=$(ps aux | grep "vite.*5173" | grep -v grep | awk '{print $2}')
|
||||
if [ -n "$ADMIN_PIDS" ]; then
|
||||
echo "找到Admin服务进程: $ADMIN_PIDS"
|
||||
echo "$ADMIN_PIDS" | xargs kill -9 2>/dev/null || true
|
||||
echo "Admin服务已停止"
|
||||
else
|
||||
echo "Admin服务未运行"
|
||||
fi
|
||||
|
||||
echo "----------------------------------------"
|
||||
echo " 清理日志文件..."
|
||||
echo "----------------------------------------"
|
||||
rm -f /tmp/api.log /tmp/admin.log 2>/dev/null || true
|
||||
echo "日志文件已清理"
|
||||
|
||||
echo "========================================="
|
||||
echo " ✅ 所有服务已停止"
|
||||
echo "========================================="
|
||||
@@ -0,0 +1 @@
|
||||
export * from './logger';
|
||||
@@ -0,0 +1,164 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export enum LogLevel {
|
||||
DEBUG = 'debug',
|
||||
INFO = 'info',
|
||||
WARN = 'warn',
|
||||
ERROR = 'error'
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: string;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class Logger {
|
||||
private static instance: Logger;
|
||||
private logDir: string;
|
||||
private logFile: string;
|
||||
private logLevel: LogLevel;
|
||||
private context: Record<string, unknown>;
|
||||
|
||||
private constructor(logDir: string = 'test-results/logs') {
|
||||
this.logDir = logDir;
|
||||
this.logFile = path.join(logDir, `test-run-${new Date().toISOString().replace(/[:.]/g, '-')}.log`);
|
||||
this.logLevel = this.parseLogLevel(process.env.LOG_LEVEL || 'info');
|
||||
this.context = {};
|
||||
this.ensureLogDir();
|
||||
}
|
||||
|
||||
static getInstance(logDir?: string): Logger {
|
||||
if (!Logger.instance) {
|
||||
Logger.instance = new Logger(logDir);
|
||||
}
|
||||
return Logger.instance;
|
||||
}
|
||||
|
||||
private parseLogLevel(level: string): LogLevel {
|
||||
switch (level.toLowerCase()) {
|
||||
case 'debug': return LogLevel.DEBUG;
|
||||
case 'info': return LogLevel.INFO;
|
||||
case 'warn': return LogLevel.WARN;
|
||||
case 'error': return LogLevel.ERROR;
|
||||
default: return LogLevel.INFO;
|
||||
}
|
||||
}
|
||||
|
||||
private ensureLogDir(): void {
|
||||
if (!fs.existsSync(this.logDir)) {
|
||||
fs.mkdirSync(this.logDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
setContext(context: Record<string, unknown>): this {
|
||||
this.context = { ...this.context, ...context };
|
||||
return this;
|
||||
}
|
||||
|
||||
clearContext(): this {
|
||||
this.context = {};
|
||||
return this;
|
||||
}
|
||||
|
||||
private formatTimestamp(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
private formatMessage(level: LogLevel, message: string): string {
|
||||
const timestamp = this.formatTimestamp();
|
||||
const levelStr = level.toUpperCase().padEnd(5);
|
||||
const contextStr = Object.keys(this.context).length > 0
|
||||
? ` [${JSON.stringify(this.context)}]`
|
||||
: '';
|
||||
return `${timestamp} | ${levelStr} |${contextStr} ${message}`;
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
const levels = [LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR];
|
||||
return levels.indexOf(level) >= levels.indexOf(this.logLevel);
|
||||
}
|
||||
|
||||
private writeToFile(entry: LogEntry): void {
|
||||
try {
|
||||
this.ensureLogDir();
|
||||
const logLine = JSON.stringify(entry) + '\n';
|
||||
fs.appendFileSync(this.logFile, logLine, 'utf-8');
|
||||
} catch {
|
||||
// Ignore file write errors
|
||||
}
|
||||
}
|
||||
|
||||
private log(level: LogLevel, message: string, context?: Record<string, unknown>): void {
|
||||
if (!this.shouldLog(level)) return;
|
||||
|
||||
const entry: LogEntry = {
|
||||
timestamp: this.formatTimestamp(),
|
||||
level,
|
||||
message,
|
||||
context: { ...this.context, ...context }
|
||||
};
|
||||
|
||||
this.writeToFile(entry);
|
||||
|
||||
const formattedMessage = this.formatMessage(level, message);
|
||||
const colors: Record<LogLevel, string> = {
|
||||
[LogLevel.DEBUG]: '\x1b[36m',
|
||||
[LogLevel.INFO]: '\x1b[32m',
|
||||
[LogLevel.WARN]: '\x1b[33m',
|
||||
[LogLevel.ERROR]: '\x1b[31m'
|
||||
};
|
||||
const reset = '\x1b[0m';
|
||||
console.log(`${colors[level]}${formattedMessage}${reset}`);
|
||||
}
|
||||
|
||||
debug(message: string, context?: Record<string, unknown>): void {
|
||||
this.log(LogLevel.DEBUG, message, context);
|
||||
}
|
||||
|
||||
info(message: string, context?: Record<string, unknown>): void {
|
||||
this.log(LogLevel.INFO, message, context);
|
||||
}
|
||||
|
||||
warn(message: string, context?: Record<string, unknown>): void {
|
||||
this.log(LogLevel.WARN, message, context);
|
||||
}
|
||||
|
||||
error(message: string, error?: Error | unknown, context?: Record<string, unknown>): void {
|
||||
const errorContext = error instanceof Error
|
||||
? { errorMessage: error.message, stack: error.stack, ...context }
|
||||
: { error, ...context };
|
||||
this.log(LogLevel.ERROR, message, errorContext);
|
||||
}
|
||||
|
||||
section(title: string): void {
|
||||
const separator = '='.repeat(60);
|
||||
console.log(`\n${separator}`);
|
||||
console.log(` ${title}`);
|
||||
console.log(`${separator}\n`);
|
||||
this.info(`=== ${title} ===`);
|
||||
}
|
||||
|
||||
progress(current: number, total: number, message: string): void {
|
||||
const percentage = Math.round((current / total) * 100);
|
||||
const bar = this.createProgressBar(percentage);
|
||||
process.stdout.write(`\r${bar} ${percentage}% - ${message}`);
|
||||
if (current === total) {
|
||||
process.stdout.write('\n');
|
||||
}
|
||||
}
|
||||
|
||||
private createProgressBar(percentage: number, width: number = 30): string {
|
||||
const filled = Math.round((percentage / 100) * width);
|
||||
const empty = width - filled;
|
||||
return `[${'█'.repeat(filled)}${'░'.repeat(empty)}]`;
|
||||
}
|
||||
|
||||
getLogFile(): string {
|
||||
return this.logFile;
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = Logger.getInstance();
|
||||
Reference in New Issue
Block a user