/** * 自定义测试报告 + AI 可读输出 * * 作为 Playwright Reporter 收集结果, * 测试结束后生成 test-results/ai-summary.md(供 AI 编辑器解析) */ import type { Reporter, FullConfig, Suite, TestCase, TestResult, FullResult } from '@playwright/test/reporter'; import fs from 'fs'; import path from 'path'; // ============================================================ // 测试结果数据结构 // ============================================================ export interface TestResultItem { id: string; name: string; status: 'passed' | 'failed' | 'skipped' | 'timedOut'; duration: number; // ms error?: string; screenshots?: string[]; } export interface ModuleReport { module: string; tests: TestResultItem[]; passed: number; failed: number; skipped: number; passRate: number; } export interface FullReport { timestamp: string; total: number; passed: number; failed: number; skipped: number; passRate: number; duration: number; modules: ModuleReport[]; } // ============================================================ // Playwright Custom Reporter // ============================================================ class AISummaryReporter implements Reporter { private allResults: TestResultItem[] = []; private startTime = 0; private outputDir = ''; onBegin(_config: FullConfig, suite: Suite) { this.startTime = Date.now(); this.outputDir = _config.projects[0]?.outputDir || 'test-results'; // 确保输出目录存在 if (!fs.existsSync(this.outputDir)) { fs.mkdirSync(this.outputDir, { recursive: true }); } } onTestEnd(test: TestCase, result: TestResult) { const titlePath = test.titlePath(); // e.g. ['前端核心页面 E2E', 'E01: ...'] const module = titlePath.length > 1 ? titlePath.slice(0, -1).join(' › ') : '(顶层)'; const name = titlePath[titlePath.length - 1]; // 收集截图路径 const screenshots: string[] = []; for (const attachment of result.attachments) { if (attachment.name === 'screenshot' && attachment.path) { screenshots.push(path.relative(process.cwd(), attachment.path)); } } this.allResults.push({ id: test.id, name: titlePath.join(' › '), status: result.status as TestResultItem['status'], duration: result.duration, error: result.error?.message?.split('\n')[0], // 只取第一行错误摘要 screenshots, }); } onEnd(result: FullResult) { const duration = Date.now() - this.startTime; const report = this.buildReport(duration); this.writeMarkdownSummary(report, result.status); this.writeJsonReport(report); } /** 构建结构化报告 */ private buildReport(duration: number): FullReport { // 按模块分组 const moduleMap = new Map(); for (const r of this.allResults) { const mod = r.name.includes(' › ') ? r.name.split(' › ')[0] : '(顶层)'; if (!moduleMap.has(mod)) moduleMap.set(mod, []); moduleMap.get(mod)!.push(r); } const modules: ModuleReport[] = []; for (const [name, tests] of moduleMap) { const passed = tests.filter(t => t.status === 'passed').length; const failed = tests.filter(t => t.status === 'failed').length; const skipped = tests.filter(t => t.status === 'skipped').length; modules.push({ module: name, tests, passed, failed, skipped, passRate: tests.length ? Math.round((passed / tests.length) * 100) : 100, }); } const passed = this.allResults.filter(t => t.status === 'passed').length; const failed = this.allResults.filter(t => t.status === 'failed').length; const skipped = this.allResults.filter(t => t.status === 'skipped').length; return { timestamp: new Date().toISOString(), total: this.allResults.length, passed, failed, skipped, passRate: this.allResults.length ? Math.round((passed / this.allResults.length) * 100) : 100, duration, modules, }; } /** 生成 AI 可读的 Markdown 摘要 */ private writeMarkdownSummary(report: FullReport, status: string) { const lines: string[] = []; lines.push('# 自动化测试报告'); lines.push(''); lines.push(`> 时间: ${report.timestamp}`); lines.push(`> 耗时: ${(report.duration / 1000).toFixed(1)}s`); lines.push(`> 状态: ${status === 'passed' ? '✅ 全部通过' : '❌ 存在失败'}`); lines.push(''); // ---- 汇总表格 ---- lines.push('## 汇总'); lines.push(''); lines.push('| 指标 | 值 |'); lines.push('|------|-----|'); lines.push(`| 用例总数 | ${report.total} |`); lines.push(`| 通过 | ${report.passed} |`); lines.push(`| 失败 | ${report.failed} |`); lines.push(`| 跳过 | ${report.skipped} |`); lines.push(`| 通过率 | **${report.passRate}%** |`); lines.push(''); // ---- 按模块详情 ---- for (const mod of report.modules) { const icon = mod.failed > 0 ? '❌' : '✅'; lines.push(`## ${icon} ${mod.module} (${mod.passed}/${mod.tests.length})`); lines.push(''); lines.push('| 用例 | 结果 | 耗时 | 截图 |'); lines.push('|------|------|------|------|'); for (const t of mod.tests) { const shortName = t.name.split(' › ').pop() || t.name; const statusIcon = t.status === 'passed' ? '✅' : t.status === 'failed' ? '❌' : '⊘'; const screenshots = t.screenshots?.length ? t.screenshots.map(s => `[📷](${s})`).join(' ') : '-'; lines.push(`| ${shortName} | ${statusIcon} | ${(t.duration / 1000).toFixed(1)}s | ${screenshots} |`); } lines.push(''); // 失败详情 const failures = mod.tests.filter(t => t.status === 'failed'); if (failures.length > 0) { lines.push('### 失败详情'); lines.push(''); for (const f of failures) { lines.push(`- **${f.name.split(' › ').pop()}**: ${f.error || '未知错误'}`); } lines.push(''); } } // ---- 控制台输出 ---- lines.push('---'); lines.push(''); // 同时输出到控制台 const consoleOutput = [ '', '='.repeat(60), ` ${status === 'passed' ? '✅' : '❌'} 测试完成 | ${report.passed}/${report.total} 通过 (${report.passRate}%) | ${(report.duration / 1000).toFixed(1)}s`, '='.repeat(60), ]; if (report.failed > 0) { for (const mod of report.modules) { for (const t of mod.tests) { if (t.status === 'failed') { consoleOutput.push(` ❌ ${t.name.split(' › ').pop()}: ${t.error || '未知'}`); } } } } console.log(consoleOutput.join('\n')); // 写入文件 const filePath = path.join(this.outputDir, 'ai-summary.md'); fs.writeFileSync(filePath, lines.join('\n'), 'utf-8'); console.log(`\n 📄 AI 摘要报告: ${filePath}`); } /** 写入 JSON 报告 */ private writeJsonReport(report: FullReport) { const filePath = path.join(this.outputDir, 'ai-summary.json'); fs.writeFileSync(filePath, JSON.stringify(report, null, 2), 'utf-8'); } } export default AISummaryReporter; // ============================================================ // 向后兼容:模块报告工厂(供测试内手动使用) // ============================================================ const allModules: ModuleReport[] = []; export function createModuleReport(module: string): { addResult: (result: TestResultItem) => void; finalize: () => ModuleReport; } { const tests: TestResultItem[] = []; return { addResult(result: TestResultItem) { tests.push(result); }, finalize() { const passed = tests.filter(t => t.status === 'passed').length; const failed = tests.filter(t => t.status === 'failed').length; const skipped = tests.filter(t => t.status === 'skipped').length; const report: ModuleReport = { module, tests, passed, failed, skipped, passRate: tests.length ? Math.round((passed / tests.length) * 100) : 100 }; allModules.push(report); return report; }, }; } export function generateFullReport(startTime: number): FullReport { const duration = Date.now() - startTime; const totalTests = allModules.reduce((s, m) => s + m.tests.length, 0); const totalPassed = allModules.reduce((s, m) => s + m.passed, 0); const totalFailed = allModules.reduce((s, m) => s + m.failed, 0); const totalSkipped = allModules.reduce((s, m) => s + m.skipped, 0); return { timestamp: new Date().toISOString(), total: totalTests, passed: totalPassed, failed: totalFailed, skipped: totalSkipped, passRate: totalTests ? Math.round((totalPassed / totalTests) * 100) : 100, duration, modules: allModules, }; } export function printConsoleReport(report: FullReport) { const lines = [ '', '='.repeat(60), ` ${report.passed}/${report.total} 通过 (${report.passRate}%) | ${(report.duration / 1000).toFixed(1)}s`, '='.repeat(60), ]; for (const mod of report.modules) { lines.push(` ${mod.failed > 0 ? '❌' : '✅'} ${mod.module}: ${mod.passed}/${mod.tests.length}`); for (const t of mod.tests) { if (t.status === 'failed') lines.push(` ❌ ${t.name}: ${t.error || ''}`); } } if (report.failed) lines.push(` ⚠ ${report.failed} 个失败用例`); else lines.push(' ✅ 全部通过'); console.log(lines.join('\n')); } export function saveJsonReport(report: FullReport, outputDir: string) { if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true }); fs.writeFileSync(path.join(outputDir, `regression-report-${Date.now()}.json`), JSON.stringify(report, null, 2)); } export function getExitCode(report: FullReport, threshold = 90): number { return report.passRate >= threshold ? 0 : 1; }