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