| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285 |
- /**
- * 自定义测试报告 + 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<string, TestResultItem[]>();
- 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;
- }
|