feat(admin): 添加用户管理相关文件

添加用户管理视图、API和状态管理文件
This commit is contained in:
张翔
2026-03-28 14:37:29 +08:00
commit 08ea5fbe98
1643 changed files with 255646 additions and 0 deletions
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
}
@@ -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;
}
}
}