Files
kandev/apps/web/e2e/scripts/retry-summary.test.ts
2026-08-13 18:53:59 +01:00

296 lines
11 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { computeOutcome, summarizeObservations, type TestOutcome } from "./retry-summary";
import { parseBlobReports, type ShardMetadata } from "./e2e-timings";
import type { TimingObservation } from "./e2e-timings";
const fixturesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "__fixtures__");
const parityFixtureDir = path.join(fixturesDir, "playwright-parity");
const repeatEachFixtureDir = path.join(fixturesDir, "repeat-each-parity");
function playwrightStats(directory: string): Record<string, number> {
return JSON.parse(
fs.readFileSync(path.join(directory, "playwright-stats.json"), "utf8"),
) as Record<string, number>;
}
function observation(overrides: Partial<TimingObservation>): TimingObservation {
return {
key: "chromium::tests/example.spec.ts::works",
project: "chromium",
file: "tests/example.spec.ts",
title: "works",
retry: 0,
status: "passed",
durationSeconds: 1,
errors: [],
attachments: [],
...overrides,
};
}
describe("retry summary", () => {
it("distinguishes first-attempt passes, passed retries, and failures", () => {
const summary = summarizeObservations(
[
observation({}),
observation({
key: "chromium::tests/flaky.spec.ts::flaky",
title: "flaky",
status: "failed",
}),
observation({
key: "chromium::tests/flaky.spec.ts::flaky",
title: "flaky",
retry: 1,
status: "passed",
durationSeconds: 2,
errors: [{ message: "temporary failure" }],
}),
observation({
key: "chromium::tests/slow.spec.ts::slow",
title: "slow",
status: "timedOut",
errors: [{ message: "Timeout 60000ms exceeded" }],
}),
observation({
key: "chromium::tests/terminal.spec.ts::terminal",
title: "terminal",
status: "failed",
errors: [{ message: "terminal failure" }],
attachments: [{ name: "trace", path: "resources/trace.zip" }],
}),
],
"2026-08-10T10:02:00.000Z",
{
attachmentArtifact: {
name: "e2e-timing-diagnostics",
url: "https://github.com/kdlbs/kandev/actions/runs/42#artifacts",
pathPrefix: "retry-artifacts",
availablePaths: new Set(["resources/trace.zip"]),
},
},
);
expect(summary.counts).toEqual({
passedFirstAttempt: 1,
passedAfterRetry: 1,
failed: 1,
timedOut: 1,
skipped: 0,
});
expect(summary.flake).toEqual({
flaky: 1,
unexpected: 2,
executed: 4,
ratePerThousand: 250,
flakyTests: ["chromium::tests/flaky.spec.ts::flaky"],
});
expect(summary.tests).toEqual(
expect.arrayContaining([
expect.objectContaining({ key: "chromium::tests/flaky.spec.ts::flaky", attempts: 2 }),
expect.objectContaining({
key: "chromium::tests/slow.spec.ts::slow",
finalStatus: "timedOut",
}),
expect.objectContaining({
key: "chromium::tests/terminal.spec.ts::terminal",
finalStatus: "failed",
errorCategory: "failure",
attachments: [
{
name: "trace",
path: "retry-artifacts/trace.zip",
artifactName: "e2e-timing-diagnostics",
artifactUrl: "https://github.com/kdlbs/kandev/actions/runs/42#artifacts",
},
],
}),
]),
);
});
it("matches Playwright's outcome algorithm, including the cases a retry count gets wrong", () => {
// A retried-then-passed test is the flake we want to count.
expect(computeOutcome(["failed", "passed"])).toBe("flaky");
expect(computeOutcome(["timedOut", "timedOut", "passed"])).toBe("flaky");
// Playwright never counts an interrupted attempt as unexpected, so an
// interrupted-then-passed test is expected, not flaky.
expect(computeOutcome(["interrupted", "passed"])).toBe("expected");
expect(computeOutcome(["passed"])).toBe("expected");
expect(computeOutcome(["failed", "failed", "failed"])).toBe("unexpected");
// A did-not-run attempt of a test expected to pass is neither expected nor
// unexpected, so failing and then skipping stays unexpected.
expect(computeOutcome(["failed", "skipped"])).toBe("unexpected");
expect(computeOutcome(["skipped"])).toBe("skipped");
expect(computeOutcome(["interrupted"])).toBe("skipped");
// `test.fail()` inverts the expectation: failing is expected, passing is not.
expect(computeOutcome(["failed"], "failed")).toBe("expected");
expect(computeOutcome(["passed"], "failed")).toBe("unexpected");
expect(computeOutcome(["passed", "failed"], "failed")).toBe("flaky");
});
it("reads the expected status recorded on each attempt", () => {
const summary = summarizeObservations(
[
observation({
key: "chromium::tests/expected-failure.spec.ts::fails on purpose",
status: "failed",
expectedStatus: "failed",
}),
],
"2026-08-10T10:02:00.000Z",
);
expect(summary.tests[0]?.outcome).toBe("expected");
expect(summary.flake).toMatchObject({ flaky: 0, unexpected: 0, executed: 1 });
});
it("reports the flake rate per thousand executed tests, ignoring skipped ones", () => {
const observations = Array.from({ length: 8 }, (_, index) =>
observation({ key: `chromium::tests/bulk.spec.ts::case ${index}`, title: `case ${index}` }),
);
observations.push(
observation({ key: "chromium::tests/skipped.spec.ts::off", status: "skipped" }),
observation({ key: "chromium::tests/flaky.spec.ts::flaky", status: "failed" }),
observation({ key: "chromium::tests/flaky.spec.ts::flaky", retry: 1, status: "passed" }),
);
const summary = summarizeObservations(observations, "2026-08-10T10:02:00.000Z");
expect(summary.flake.executed).toBe(9);
expect(summary.flake.ratePerThousand).toBe(111.11);
});
it("ignores a blob report that was parsed twice instead of inventing retries", () => {
const attempt = observation({ key: "chromium::tests/a.spec.ts::a", status: "passed" });
const summary = summarizeObservations([attempt, { ...attempt }], "2026-08-10T10:02:00.000Z");
expect(summary.tests[0]?.attempts).toBe(1);
expect(summary.tests[0]?.outcome).toBe("expected");
expect(summary.flake).toMatchObject({ flaky: 0, executed: 1 });
});
// The whole point of the flake rate is that it is Playwright's number. This
// parses a real blob report and asserts our totals against the `stats` block
// Playwright itself produced from that same blob; see the fixture's README.
it("reproduces Playwright's own per-outcome totals for a recorded run", () => {
const summary = summarizeObservations(
parseBlobReports(parityFixtureDir),
"2026-08-13T10:00:00.000Z",
);
const tally = (outcome: TestOutcome) =>
summary.tests.filter((test) => test.outcome === outcome).length;
const stats = playwrightStats(parityFixtureDir);
expect({
expected: tally("expected"),
unexpected: tally("unexpected"),
flaky: tally("flaky"),
skipped: tally("skipped"),
}).toEqual(stats);
expect(summary.flake.flaky).toBe(stats.flaky);
expect(summary.flake.flakyTests).toEqual([
"chromium::tests/outcomes.spec.ts::flakes once then passes",
"chromium::tests/outcomes.spec.ts::flakes twice then passes",
]);
// Totals alone are not enough: misreading `expectedStatus` swaps the two
// `test.fail()` tests between expected and unexpected and leaves every
// total identical, so pin each test's verdict as well.
expect(Object.fromEntries(summary.tests.map((test) => [test.title, test.outcome]))).toEqual({
"always passes": "expected",
"flakes once then passes": "flaky",
"flakes twice then passes": "flaky",
"always fails": "unexpected",
"is skipped": "skipped",
"is expected to fail and does": "expected",
"is expected to fail but passes": "unexpected",
});
});
// `--repeat-each` is the flag used to verify de-flaking work, so undercounting
// here would be undercounting exactly when the number is being relied on.
it("counts every --repeat-each repetition as its own execution", () => {
const summary = summarizeObservations(
parseBlobReports(repeatEachFixtureDir),
"2026-08-13T10:00:00.000Z",
);
const stats = playwrightStats(repeatEachFixtureDir);
expect(summary.flake.executed).toBe(4);
expect(summary.flake.flaky).toBe(stats.flaky);
expect(summary.tests.map((test) => test.statuses)).toEqual([
["passed"],
["passed"],
["failed", "passed"],
["failed", "passed"],
]);
// All four repetitions share one project/file/title; only the test id splits them.
expect(new Set(summary.tests.map((test) => test.key)).size).toBe(1);
expect(new Set(summary.tests.map((test) => test.testId)).size).toBe(4);
});
it("still collapses a repeated report copy of the same execution", () => {
const observations = parseBlobReports(repeatEachFixtureDir);
const summary = summarizeObservations(
[...observations, ...observations.map((observation) => ({ ...observation }))],
"2026-08-13T10:00:00.000Z",
);
expect(summary.flake.executed).toBe(4);
expect(summary.tests.map((test) => test.attempts)).toEqual([1, 1, 2, 2]);
});
it("compares planned and actual shard durations and keeps plan health counters", () => {
const metadata: ShardMetadata[] = [
{
cohort: "normal",
shard: 1,
shardCount: 2,
startedAt: "2026-08-10T10:00:00.000Z",
finishedAt: "2026-08-10T10:02:00.000Z",
exitCode: 0,
predictedSeconds: 90,
unitIds: ["chromium::tests/example.spec.ts"],
},
];
const summary = summarizeObservations([], "2026-08-10T10:02:00.000Z", {
shardMetadata: metadata,
planSummaries: [
{
profileMode: "count-fallback",
unknownUnits: 3,
warmUnits: 1,
staleUnits: 2,
targetSeconds: 120,
},
],
});
expect(summary.planning).toEqual({
mode: "count-fallback",
unknownUnits: 3,
warmUnits: 1,
staleUnits: 2,
targetSeconds: 120,
});
expect(summary.shards).toEqual([
expect.objectContaining({
cohort: "normal",
shard: 1,
predictedSeconds: 90,
actualSeconds: 120,
deltaSeconds: 30,
exitCode: 0,
}),
]);
});
});