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
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:
@@ -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;
|
||||
Reference in New Issue
Block a user