Files
Stirling-PDF/frontend/editor/scripts/report-flaky-tests.mts
T
James Brunton f881828cd8 Fix intermittently failing Playwright tests (#6886)
# Description of Changes
Fixes intermittently failing tests (and replaces one that wasn't useful
in its previous state) and also adds a CI check to warn if there are any
Playwright tests which failed on their first go and succeeded on
retries, to hopefully help find intermittently failing tests more
quickly and avoid them being merged in the first place.
2026-07-06 21:37:21 +00: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();