reporter.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. /**
  2. * 自定义测试报告 + AI 可读输出
  3. *
  4. * 作为 Playwright Reporter 收集结果,
  5. * 测试结束后生成 test-results/ai-summary.md(供 AI 编辑器解析)
  6. */
  7. import type {
  8. Reporter, FullConfig, Suite, TestCase, TestResult, FullResult
  9. } from '@playwright/test/reporter';
  10. import fs from 'fs';
  11. import path from 'path';
  12. // ============================================================
  13. // 测试结果数据结构
  14. // ============================================================
  15. export interface TestResultItem {
  16. id: string;
  17. name: string;
  18. status: 'passed' | 'failed' | 'skipped' | 'timedOut';
  19. duration: number; // ms
  20. error?: string;
  21. screenshots?: string[];
  22. }
  23. export interface ModuleReport {
  24. module: string;
  25. tests: TestResultItem[];
  26. passed: number;
  27. failed: number;
  28. skipped: number;
  29. passRate: number;
  30. }
  31. export interface FullReport {
  32. timestamp: string;
  33. total: number;
  34. passed: number;
  35. failed: number;
  36. skipped: number;
  37. passRate: number;
  38. duration: number;
  39. modules: ModuleReport[];
  40. }
  41. // ============================================================
  42. // Playwright Custom Reporter
  43. // ============================================================
  44. class AISummaryReporter implements Reporter {
  45. private allResults: TestResultItem[] = [];
  46. private startTime = 0;
  47. private outputDir = '';
  48. onBegin(_config: FullConfig, suite: Suite) {
  49. this.startTime = Date.now();
  50. this.outputDir = _config.projects[0]?.outputDir || 'test-results';
  51. // 确保输出目录存在
  52. if (!fs.existsSync(this.outputDir)) {
  53. fs.mkdirSync(this.outputDir, { recursive: true });
  54. }
  55. }
  56. onTestEnd(test: TestCase, result: TestResult) {
  57. const titlePath = test.titlePath(); // e.g. ['前端核心页面 E2E', 'E01: ...']
  58. const module = titlePath.length > 1 ? titlePath.slice(0, -1).join(' › ') : '(顶层)';
  59. const name = titlePath[titlePath.length - 1];
  60. // 收集截图路径
  61. const screenshots: string[] = [];
  62. for (const attachment of result.attachments) {
  63. if (attachment.name === 'screenshot' && attachment.path) {
  64. screenshots.push(path.relative(process.cwd(), attachment.path));
  65. }
  66. }
  67. this.allResults.push({
  68. id: test.id,
  69. name: titlePath.join(' › '),
  70. status: result.status as TestResultItem['status'],
  71. duration: result.duration,
  72. error: result.error?.message?.split('\n')[0], // 只取第一行错误摘要
  73. screenshots,
  74. });
  75. }
  76. onEnd(result: FullResult) {
  77. const duration = Date.now() - this.startTime;
  78. const report = this.buildReport(duration);
  79. this.writeMarkdownSummary(report, result.status);
  80. this.writeJsonReport(report);
  81. }
  82. /** 构建结构化报告 */
  83. private buildReport(duration: number): FullReport {
  84. // 按模块分组
  85. const moduleMap = new Map<string, TestResultItem[]>();
  86. for (const r of this.allResults) {
  87. const mod = r.name.includes(' › ') ? r.name.split(' › ')[0] : '(顶层)';
  88. if (!moduleMap.has(mod)) moduleMap.set(mod, []);
  89. moduleMap.get(mod)!.push(r);
  90. }
  91. const modules: ModuleReport[] = [];
  92. for (const [name, tests] of moduleMap) {
  93. const passed = tests.filter(t => t.status === 'passed').length;
  94. const failed = tests.filter(t => t.status === 'failed').length;
  95. const skipped = tests.filter(t => t.status === 'skipped').length;
  96. modules.push({
  97. module: name,
  98. tests,
  99. passed, failed, skipped,
  100. passRate: tests.length ? Math.round((passed / tests.length) * 100) : 100,
  101. });
  102. }
  103. const passed = this.allResults.filter(t => t.status === 'passed').length;
  104. const failed = this.allResults.filter(t => t.status === 'failed').length;
  105. const skipped = this.allResults.filter(t => t.status === 'skipped').length;
  106. return {
  107. timestamp: new Date().toISOString(),
  108. total: this.allResults.length,
  109. passed, failed, skipped,
  110. passRate: this.allResults.length ? Math.round((passed / this.allResults.length) * 100) : 100,
  111. duration,
  112. modules,
  113. };
  114. }
  115. /** 生成 AI 可读的 Markdown 摘要 */
  116. private writeMarkdownSummary(report: FullReport, status: string) {
  117. const lines: string[] = [];
  118. lines.push('# 自动化测试报告');
  119. lines.push('');
  120. lines.push(`> 时间: ${report.timestamp}`);
  121. lines.push(`> 耗时: ${(report.duration / 1000).toFixed(1)}s`);
  122. lines.push(`> 状态: ${status === 'passed' ? '✅ 全部通过' : '❌ 存在失败'}`);
  123. lines.push('');
  124. // ---- 汇总表格 ----
  125. lines.push('## 汇总');
  126. lines.push('');
  127. lines.push('| 指标 | 值 |');
  128. lines.push('|------|-----|');
  129. lines.push(`| 用例总数 | ${report.total} |`);
  130. lines.push(`| 通过 | ${report.passed} |`);
  131. lines.push(`| 失败 | ${report.failed} |`);
  132. lines.push(`| 跳过 | ${report.skipped} |`);
  133. lines.push(`| 通过率 | **${report.passRate}%** |`);
  134. lines.push('');
  135. // ---- 按模块详情 ----
  136. for (const mod of report.modules) {
  137. const icon = mod.failed > 0 ? '❌' : '✅';
  138. lines.push(`## ${icon} ${mod.module} (${mod.passed}/${mod.tests.length})`);
  139. lines.push('');
  140. lines.push('| 用例 | 结果 | 耗时 | 截图 |');
  141. lines.push('|------|------|------|------|');
  142. for (const t of mod.tests) {
  143. const shortName = t.name.split(' › ').pop() || t.name;
  144. const statusIcon = t.status === 'passed' ? '✅' : t.status === 'failed' ? '❌' : '⊘';
  145. const screenshots = t.screenshots?.length
  146. ? t.screenshots.map(s => `[📷](${s})`).join(' ')
  147. : '-';
  148. lines.push(`| ${shortName} | ${statusIcon} | ${(t.duration / 1000).toFixed(1)}s | ${screenshots} |`);
  149. }
  150. lines.push('');
  151. // 失败详情
  152. const failures = mod.tests.filter(t => t.status === 'failed');
  153. if (failures.length > 0) {
  154. lines.push('### 失败详情');
  155. lines.push('');
  156. for (const f of failures) {
  157. lines.push(`- **${f.name.split(' › ').pop()}**: ${f.error || '未知错误'}`);
  158. }
  159. lines.push('');
  160. }
  161. }
  162. // ---- 控制台输出 ----
  163. lines.push('---');
  164. lines.push('');
  165. // 同时输出到控制台
  166. const consoleOutput = [
  167. '',
  168. '='.repeat(60),
  169. ` ${status === 'passed' ? '✅' : '❌'} 测试完成 | ${report.passed}/${report.total} 通过 (${report.passRate}%) | ${(report.duration / 1000).toFixed(1)}s`,
  170. '='.repeat(60),
  171. ];
  172. if (report.failed > 0) {
  173. for (const mod of report.modules) {
  174. for (const t of mod.tests) {
  175. if (t.status === 'failed') {
  176. consoleOutput.push(` ❌ ${t.name.split(' › ').pop()}: ${t.error || '未知'}`);
  177. }
  178. }
  179. }
  180. }
  181. console.log(consoleOutput.join('\n'));
  182. // 写入文件
  183. const filePath = path.join(this.outputDir, 'ai-summary.md');
  184. fs.writeFileSync(filePath, lines.join('\n'), 'utf-8');
  185. console.log(`\n 📄 AI 摘要报告: ${filePath}`);
  186. }
  187. /** 写入 JSON 报告 */
  188. private writeJsonReport(report: FullReport) {
  189. const filePath = path.join(this.outputDir, 'ai-summary.json');
  190. fs.writeFileSync(filePath, JSON.stringify(report, null, 2), 'utf-8');
  191. }
  192. }
  193. export default AISummaryReporter;
  194. // ============================================================
  195. // 向后兼容:模块报告工厂(供测试内手动使用)
  196. // ============================================================
  197. const allModules: ModuleReport[] = [];
  198. export function createModuleReport(module: string): {
  199. addResult: (result: TestResultItem) => void;
  200. finalize: () => ModuleReport;
  201. } {
  202. const tests: TestResultItem[] = [];
  203. return {
  204. addResult(result: TestResultItem) {
  205. tests.push(result);
  206. },
  207. finalize() {
  208. const passed = tests.filter(t => t.status === 'passed').length;
  209. const failed = tests.filter(t => t.status === 'failed').length;
  210. const skipped = tests.filter(t => t.status === 'skipped').length;
  211. const report: ModuleReport = { module, tests, passed, failed, skipped, passRate: tests.length ? Math.round((passed / tests.length) * 100) : 100 };
  212. allModules.push(report);
  213. return report;
  214. },
  215. };
  216. }
  217. export function generateFullReport(startTime: number): FullReport {
  218. const duration = Date.now() - startTime;
  219. const totalTests = allModules.reduce((s, m) => s + m.tests.length, 0);
  220. const totalPassed = allModules.reduce((s, m) => s + m.passed, 0);
  221. const totalFailed = allModules.reduce((s, m) => s + m.failed, 0);
  222. const totalSkipped = allModules.reduce((s, m) => s + m.skipped, 0);
  223. return {
  224. timestamp: new Date().toISOString(),
  225. total: totalTests, passed: totalPassed, failed: totalFailed, skipped: totalSkipped,
  226. passRate: totalTests ? Math.round((totalPassed / totalTests) * 100) : 100,
  227. duration, modules: allModules,
  228. };
  229. }
  230. export function printConsoleReport(report: FullReport) {
  231. const lines = [
  232. '', '='.repeat(60),
  233. ` ${report.passed}/${report.total} 通过 (${report.passRate}%) | ${(report.duration / 1000).toFixed(1)}s`,
  234. '='.repeat(60),
  235. ];
  236. for (const mod of report.modules) {
  237. lines.push(` ${mod.failed > 0 ? '❌' : '✅'} ${mod.module}: ${mod.passed}/${mod.tests.length}`);
  238. for (const t of mod.tests) {
  239. if (t.status === 'failed') lines.push(` ❌ ${t.name}: ${t.error || ''}`);
  240. }
  241. }
  242. if (report.failed) lines.push(` ⚠ ${report.failed} 个失败用例`);
  243. else lines.push(' ✅ 全部通过');
  244. console.log(lines.join('\n'));
  245. }
  246. export function saveJsonReport(report: FullReport, outputDir: string) {
  247. if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
  248. fs.writeFileSync(path.join(outputDir, `regression-report-${Date.now()}.json`), JSON.stringify(report, null, 2));
  249. }
  250. export function getExitCode(report: FullReport, threshold = 90): number {
  251. return report.passRate >= threshold ? 0 : 1;
  252. }