Files
wehub-resource-sync bf122cd71b
AI Engine CI / engine (push) Failing after 0s
Check generated models / generated-models (push) Failing after 1s
Enterprise E2E (Playwright) / pick (push) Failing after 8s
Enterprise E2E (Playwright) / playwright-e2e-enterprise (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 11:59:14 +08:00

128 lines
5.0 KiB
TypeScript

// Reads a Playwright JSON report and surfaces "flaky" tests (tests that
// failed at least once, then passed on retry) in GitHub Actions WITHOUT
// failing the job:
// - emits one ::warning:: workflow command per flaky test, so the run and
// PR show a yellow warning triangle + count, and the annotation links to
// the test's source line
// - appends a summary table to the job summary ($GITHUB_STEP_SUMMARY)
//
// A green-but-flaky job is otherwise invisible (Playwright exits 0 once a
// retry passes), which lets flakes accrete unnoticed. This makes them visible
// without turning them into hard failures.
//
// Run: `npx tsx editor/scripts/report-flaky-tests.mts <results.json> [more.json...]`
// (a single path is also read from PLAYWRIGHT_JSON_OUTPUT_FILE). Multiple
// reports are merged + de-duplicated, so a job that runs Playwright in
// several segments (e.g. the enterprise OAuth/SAML/license phases) can
// pass one report per phase. A missing report or zero flaky tests is a
// silent no-op, so it is safe to run with `if: always()` after any
// Playwright step.
import { appendFileSync, existsSync, readFileSync } from "fs";
import { isAbsolute, join, relative } from "path";
import type { JSONReport, JSONReportSuite } from "@playwright/test/reporter";
interface FlakyTest {
file: string;
line: number;
title: string;
}
// Playwright records each test's outcome as expected|unexpected|flaky|skipped.
// "flaky" means it needed a retry to pass, which is exactly what we surface.
function collectFlaky(
report: JSONReport,
workspace: string,
rootDir: string,
): FlakyTest[] {
const flaky: FlakyTest[] = [];
const walk = (suite: JSONReportSuite, trail: string[], depth: number) => {
// The outermost suite per file has title === the file path; skip it so the
// human-readable title is just "describe > test" (the path is shown
// separately as the location). Nested suites are the describe() blocks.
const titles = depth > 0 && suite.title ? [...trail, suite.title] : trail;
for (const spec of suite.specs ?? []) {
if ((spec.tests ?? []).some((t) => t.status === "flaky")) {
const abs = spec.file
? isAbsolute(spec.file)
? spec.file
: join(rootDir, spec.file)
: "";
const rel = abs ? relative(workspace, abs) : "";
flaky.push({
// Drop the path from the annotation if it escapes the workspace, so
// we never emit a broken file= link (the warning still shows).
file: rel && !rel.startsWith("..") ? rel : "",
line: spec.line || 0,
title: [...titles, spec.title].filter(Boolean).join(" > "),
});
}
}
for (const child of suite.suites ?? []) walk(child, titles, depth + 1);
};
for (const suite of report.suites ?? []) walk(suite, [], 0);
return flaky;
}
// Deliberately no process.exit() calls: every path falls through to a natural
// exit(0). This step must never fail the job, and it keeps CI green even when
// the report is missing or clean.
function main(): void {
// Accept one or more report paths: a job may run Playwright in several
// segments, each writing its own report (the enterprise job does this for
// OAuth / SAML / license phases). Fall back to the env var when no paths are
// passed. Missing files are skipped, not fatal.
const reportPaths = process.argv.slice(2);
const envPath = process.env.PLAYWRIGHT_JSON_OUTPUT_FILE;
if (reportPaths.length === 0 && envPath) {
reportPaths.push(envPath);
}
const workspace = process.env.GITHUB_WORKSPACE || process.cwd();
const seen = new Set<string>();
const flaky: FlakyTest[] = [];
for (const reportPath of reportPaths) {
if (!reportPath || !existsSync(reportPath)) {
// No report (e.g. the build failed before this segment ran).
continue;
}
const report = JSON.parse(readFileSync(reportPath, "utf8")) as JSONReport;
const rootDir = report.config?.rootDir || process.cwd();
for (const test of collectFlaky(report, workspace, rootDir)) {
const key = `${test.file}:${test.line}:${test.title}`;
if (!seen.has(key)) {
seen.add(key);
flaky.push(test);
}
}
}
if (flaky.length === 0) {
return;
}
for (const f of flaky) {
const loc = f.file ? `file=${f.file},line=${f.line},` : "";
process.stdout.write(
`::warning ${loc}title=Flaky test::${f.title} passed only on retry\n`,
);
}
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
if (summaryPath) {
const plural = flaky.length === 1 ? "" : "s";
const lines = [
`### :warning: ${flaky.length} flaky test${plural} (passed on retry)`,
"",
"These passed, but not on the first attempt. Worth fixing before they turn into hard failures.",
"",
"| Test | Location |",
"| --- | --- |",
...flaky.map((f) => `| ${f.title} | \`${f.file || "?"}:${f.line}\` |`),
"",
];
appendFileSync(summaryPath, lines.join("\n") + "\n");
}
}
main();