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 { 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 { 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 { 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 { 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 { 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; } } }