feat(admin): 添加用户管理相关文件
添加用户管理视图、API和状态管理文件
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user