first-commit
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run

This commit is contained in:
Zakaria
2026-05-04 14:58:14 -04:00
commit a46764fb1b
1210 changed files with 233231 additions and 0 deletions
+289
View File
@@ -0,0 +1,289 @@
import fs from 'node:fs';
import path from 'node:path';
import type { FullConfig, Reporter, Suite, TestCase, TestResult } from '@playwright/test/reporter';
import caseMetadata from '../cases/report-metadata.ts';
interface MarkdownReporterOptions {
outputFile?: string;
}
interface CaseRow {
caseId: string;
title: string;
module: string;
assertions: string[];
status: string;
durationMs: number;
retries: number;
file: string;
line: number | null;
attachments: Array<{ name: string; contentType: string; path: string }>;
error: string | null;
}
interface Summary {
total: number;
passed: number;
failed: number;
flaky: number;
skipped: number;
timedOut: number;
interrupted: number;
durationMs: number;
}
interface MarkdownInput {
startedAt: Date;
finishedAt: Date;
summary: Summary;
rows: CaseRow[];
outputFile: string;
}
class MarkdownReporter implements Reporter {
private rootSuite: Suite | null = null;
private startedAt: Date | null = null;
private readonly options: MarkdownReporterOptions;
constructor(options: MarkdownReporterOptions = {}) {
this.options = options;
}
onBegin(_config: FullConfig, suite: Suite): void {
this.rootSuite = suite;
this.startedAt = new Date();
}
async onEnd(): Promise<void> {
if (!this.rootSuite) return;
const rows: CaseRow[] = [];
visitSuite(this.rootSuite, rows);
rows.sort((a, b) => a.caseId.localeCompare(b.caseId));
const summary = summarize(rows);
const startedAt = this.startedAt ?? new Date();
const finishedAt = new Date();
const outputFile = this.options.outputFile || './reports/latest.md';
const resolvedOutput = path.resolve(process.cwd(), outputFile);
fs.mkdirSync(path.dirname(resolvedOutput), { recursive: true });
fs.writeFileSync(
resolvedOutput,
buildMarkdown({
startedAt,
finishedAt,
summary,
rows,
outputFile,
}),
'utf8',
);
}
}
function visitSuite(suite: Suite, rows: CaseRow[]): void {
for (const child of suite.suites || []) {
visitSuite(child, rows);
}
for (const test of suite.tests || []) {
const finalResult = test.results[test.results.length - 1];
if (!finalResult) continue;
rows.push(buildCaseRow(test, finalResult));
}
}
function buildCaseRow(test: TestCase, finalResult: TestResult): CaseRow {
const parsed = parseCaseTitle(test.title);
const metadata = caseMetadata[parsed.caseId];
return {
caseId: parsed.caseId,
title: parsed.title,
module: metadata?.module || '未分组',
assertions: metadata?.assertions || [],
status: normalizeStatus(finalResult.status, test.outcome?.()),
durationMs: finalResult.duration ?? 0,
retries: Math.max(0, test.results.length - 1),
file: test.location?.file ?? '',
line: test.location?.line ?? null,
attachments: (finalResult.attachments || [])
.map((entry) => ({
name: entry.name || '',
contentType: entry.contentType || '',
path: entry.path ? toRelative(entry.path) : '',
}))
.filter((entry) => entry.path.length > 0),
error: compactError(finalResult.error),
};
}
function parseCaseTitle(title: string): { caseId: string; title: string } {
const idx = title.indexOf(': ');
if (idx === -1) {
return { caseId: title, title };
}
return {
caseId: title.slice(0, idx).trim(),
title: title.slice(idx + 2).trim(),
};
}
function normalizeStatus(status: string | undefined, outcome: string | undefined): string {
if (outcome === 'flaky') return 'flaky';
return status || 'unknown';
}
function compactError(error: TestResult['error']): string | null {
if (!error) return null;
const raw = [error.message, error.value, error.stack]
.filter(Boolean)
.join('\n')
.trim();
if (!raw) return null;
return raw.split('\n').slice(0, 8).join('\n');
}
function summarize(rows: CaseRow[]): Summary {
const summary = {
total: rows.length,
passed: 0,
failed: 0,
flaky: 0,
skipped: 0,
timedOut: 0,
interrupted: 0,
durationMs: rows.reduce((sum, row) => sum + row.durationMs, 0),
};
for (const row of rows) {
if (row.status === 'passed') summary.passed += 1;
else if (row.status === 'failed') summary.failed += 1;
else if (row.status === 'flaky') summary.flaky += 1;
else if (row.status === 'skipped') summary.skipped += 1;
else if (row.status === 'timedOut') summary.timedOut += 1;
else if (row.status === 'interrupted') summary.interrupted += 1;
}
return summary;
}
function buildMarkdown({ startedAt, finishedAt, summary, rows, outputFile }: MarkdownInput): string {
const lines: string[] = [];
lines.push('# UI 自动化测试报告');
lines.push('');
lines.push(`- 生成时间:${finishedAt.toISOString()}`);
lines.push(`- 开始时间:${startedAt.toISOString()}`);
lines.push(`- 结束时间:${finishedAt.toISOString()}`);
lines.push(`- 报告文件:\`${outputFile}\``);
lines.push(`- 执行结果:${summary.failed === 0 && summary.timedOut === 0 ? '通过' : '失败'}`);
lines.push('');
lines.push('## 汇总');
lines.push('');
lines.push(`- 总用例:${summary.total}`);
lines.push(`- 通过:${summary.passed}`);
lines.push(`- 失败:${summary.failed}`);
lines.push(`- Flaky${summary.flaky}`);
lines.push(`- 跳过:${summary.skipped}`);
lines.push(`- 超时:${summary.timedOut}`);
lines.push(`- 中断:${summary.interrupted}`);
lines.push(`- 总耗时:${formatDuration(summary.durationMs)}`);
lines.push('');
lines.push('## 用例结果');
lines.push('');
lines.push('| Case ID | 模块 | 标题 | 状态 | 耗时 | 重试 |');
lines.push('| --- | --- | --- | --- | --- | --- |');
for (const row of rows) {
lines.push(
`| \`${escapeCell(row.caseId)}\` | ${escapeCell(row.module)} | ${escapeCell(row.title)} | ${statusLabel(row.status)} | ${formatDuration(row.durationMs)} | ${row.retries} |`,
);
}
lines.push('');
lines.push('## 关键断言');
lines.push('');
for (const row of rows) {
lines.push(`### ${row.caseId}`);
lines.push('');
lines.push(`- 模块:${row.module}`);
lines.push(`- 标题:${row.title}`);
lines.push(`- 状态:${statusLabel(row.status)}`);
if (row.assertions.length > 0) {
lines.push('- 本次验证点:');
for (const assertion of row.assertions) {
lines.push(` - ${assertion}`);
}
} else {
lines.push('- 本次验证点:未配置');
}
lines.push('');
}
const problematic = rows.filter((row) => row.status !== 'passed');
if (problematic.length > 0) {
lines.push('');
lines.push('## 异常详情');
lines.push('');
for (const row of problematic) {
lines.push(`### ${row.caseId}`);
lines.push('');
lines.push(`- 标题:${row.title}`);
lines.push(`- 状态:${statusLabel(row.status)}`);
lines.push(`- 位置:\`${toRelative(row.file)}${row.line ? `:${row.line}` : ''}\``);
if (row.error) {
lines.push('- 错误:');
lines.push('```text');
lines.push(row.error);
lines.push('```');
}
if (row.attachments.length > 0) {
lines.push('- 附件:');
for (const attachment of row.attachments) {
lines.push(` - \`${attachment.name}\` · \`${attachment.path}\``);
}
}
lines.push('');
}
}
lines.push('## 原始产物');
lines.push('');
lines.push('- HTML 报告入口:`e2e/reports/ui-test-report.html`');
lines.push('- Playwright HTML 底层目录:`e2e/reports/playwright-html-report/`');
lines.push('- JSON 结果:`e2e/reports/results.json`');
lines.push('- JUnit 结果:`e2e/reports/junit.xml`');
lines.push('- Playwright 附件:`e2e/reports/test-results/`');
lines.push('');
lines.push('## 说明');
lines.push('');
lines.push('- 这份报告记录的是本次实际执行到的 UI 自动化用例。');
lines.push('- 用例设计来源见 `e2e/cases/` 以及各模块文档。');
lines.push('- 如果用例失败,优先查看本报告中的附件路径和 HTML 报告。');
lines.push('');
return `${lines.join('\n')}\n`;
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function statusLabel(status: string): string {
if (status === 'passed') return 'passed';
if (status === 'failed') return 'failed';
if (status === 'flaky') return 'flaky';
if (status === 'skipped') return 'skipped';
if (status === 'timedOut') return 'timedOut';
if (status === 'interrupted') return 'interrupted';
return status;
}
function toRelative(filePath: string): string {
if (!filePath) return '';
return path.relative(process.cwd(), filePath) || filePath;
}
function escapeCell(value: string): string {
return String(value).replace(/\|/g, '\\|');
}
export default MarkdownReporter;