import type { FullConfig, FullResult, Reporter, TestCase, TestResult } from "@playwright/test/reporter"; import fs from "node:fs"; import path from "node:path"; type Options = { runId: string }; function safeName(value: string) { return value.replace(/[\\/:*?"<>|]/g, "_").replace(/\s+/g, "-").slice(0, 100); } function relativeTestPath(test: TestCase) { const file = test.location.file.replace(/\\/g, "/"); const marker = "/tests/e2e/"; const index = file.lastIndexOf(marker); return index >= 0 ? file.slice(index + marker.length) : path.basename(file); } export default class ChineseFailureReporter implements Reporter { private readonly options: Options; private config!: FullConfig; private failed = 0; constructor(options: Options) { this.options = options; } onConfigure(config: FullConfig) { this.config = config; fs.mkdirSync(path.join(projectRoot(config), "test-reports", this.options.runId, "failures"), { recursive: true }); } onBegin(config: FullConfig) { this.config = config; fs.mkdirSync(path.join(projectRoot(config), "test-reports", this.options.runId, "failures"), { recursive: true }); } onTestEnd(test: TestCase, result: TestResult) { if (result.status === "passed" || result.status === "skipped") return; this.failed += 1; const testFile = relativeTestPath(test).replace(/\.ts$/, ""); const target = path.join(projectRoot(this.config), "test-reports", this.options.runId, "failures", testFile, safeName(test.title)); fs.mkdirSync(target, { recursive: true }); const attachments: string[] = []; for (const attachment of result.attachments) { if (!attachment.path) continue; const targetPath = path.join(target, `${safeName(attachment.name)}${path.extname(attachment.path)}`); fs.copyFileSync(attachment.path, targetPath); attachments.push(path.relative(projectRoot(this.config), targetPath)); } const errors = result.errors.map((error) => error.message ?? error.stack ?? "未知错误").join("\n\n"); fs.writeFileSync(path.join(target, "失败说明.md"), `# ${test.title}\n\n- 文件:\`${relativeTestPath(test)}\`\n- 项目:${test.parent.project()?.name ?? "未知"}\n- 重试次数:${result.retry}\n- 状态:${result.status}\n\n## 失败原因\n\n${errors || "未捕获到错误文本,请查看 trace。"}\n\n## 附件\n\n${attachments.length ? attachments.map((item) => `- \`${item}\``).join("\n") : "无"}\n`, "utf8"); } onEnd(result: FullResult) { const root = path.join(projectRoot(this.config), "test-reports", this.options.runId); fs.mkdirSync(root, { recursive: true }); fs.writeFileSync(path.join(root, "SUMMARY.md"), `# 第 ${this.options.runId} 轮测试\n\n- 总体状态:${result.status}\n- 失败测试数:${this.failed}\n- 失败详情:${this.failed ? "见 failures/ 目录" : "无"}\n`, "utf8"); } } function projectRoot(config: FullConfig) { return path.resolve(config.rootDir, "../.."); }