Files

168 lines
6.7 KiB
JavaScript

// Flake-rate reporting over repeated vitest runs.
//
// collect: run one lane with the vitest JSON reporter and save the receipt.
// node scripts/flake-report.mjs collect [--project pr|e2e] [-- <extra vitest args>]
// report: aggregate every saved receipt into per-spec pass rates.
// node scripts/flake-report.mjs report [--project pr|e2e] [--threshold 0.95]
//
// A spec is "flaky" when the same spec file both passed and failed across the
// collected runs. Specs below the pass-rate threshold belong on the quarantine
// list until diagnosed (see diagnose-a-red-run).
import { execFileSync } from "node:child_process"
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
import { join, relative, resolve } from "node:path"
import { fileURLToPath } from "node:url"
const evalsRoot = fileURLToPath(new URL("..", import.meta.url))
export function runsDirectory(project) {
return resolve(evalsRoot, "results", "flake", project)
}
function parseArguments(argv) {
const separator = argv.indexOf("--")
const own = separator === -1 ? argv : argv.slice(0, separator)
const passthrough = separator === -1 ? [] : argv.slice(separator + 1)
const mode = own[0]
if (mode !== "collect" && mode !== "report") {
throw new Error("usage: flake-report.mjs <collect|report> [--project pr|e2e] [--threshold 0.95] [-- vitest args]")
}
let project = "pr"
let threshold = 0.95
for (let index = 1; index < own.length; index += 1) {
if (own[index] === "--project") {
project = own[index + 1] ?? ""
index += 1
} else if (own[index] === "--threshold") {
threshold = Number(own[index + 1])
index += 1
} else {
throw new Error(`unknown argument: ${own[index]}`)
}
}
if (project !== "pr" && project !== "e2e") throw new Error("--project must be pr or e2e")
if (!Number.isFinite(threshold) || threshold <= 0 || threshold > 1) throw new Error("--threshold must be in (0, 1]")
return { mode, project, threshold, passthrough }
}
export function normalizeRun(receipt, root) {
if (typeof receipt !== "object" || receipt === null || !Array.isArray(receipt.testResults)) {
throw new Error("receipt is not a vitest JSON reporter payload")
}
return receipt.testResults.map((file) => {
const tests = Array.isArray(file.assertionResults) ? file.assertionResults : []
const durationMs = tests.reduce((total, test) => total + (typeof test.duration === "number" ? test.duration : 0), 0)
return {
spec: relative(root, file.name).replaceAll("\\", "/"),
passed: file.status === "passed",
skipped: tests.length > 0 && tests.every((test) => test.status === "pending" || test.status === "skipped" || test.status === "todo"),
durationMs,
}
})
}
export function aggregate(runs, threshold) {
const bySpec = new Map()
for (const run of runs) {
for (const entry of run) {
if (entry.skipped) continue
const current = bySpec.get(entry.spec) ?? { spec: entry.spec, runs: 0, passes: 0, durationsMs: [] }
current.runs += 1
if (entry.passed) current.passes += 1
current.durationsMs.push(entry.durationMs)
bySpec.set(entry.spec, current)
}
}
const specs = [...bySpec.values()]
.map((entry) => {
const passRate = entry.passes / entry.runs
const meanDurationMs = entry.durationsMs.reduce((total, value) => total + value, 0) / entry.runs
return {
spec: entry.spec,
runs: entry.runs,
passes: entry.passes,
passRate,
flaky: entry.passes > 0 && entry.passes < entry.runs,
quarantine: passRate < threshold,
meanDurationMs: Math.round(meanDurationMs),
maxDurationMs: Math.round(Math.max(...entry.durationsMs)),
}
})
.sort((left, right) => left.passRate - right.passRate || right.maxDurationMs - left.maxDurationMs)
return {
threshold,
totalRuns: runs.length,
totalSpecs: specs.length,
flakySpecs: specs.filter((entry) => entry.flaky).length,
quarantineSpecs: specs.filter((entry) => entry.quarantine).length,
specs,
}
}
export function renderMarkdown(report, project) {
const lines = [
`# Flake report — ${project} lane`,
"",
`Runs aggregated: ${report.totalRuns} · Specs: ${report.totalSpecs} · Flaky: ${report.flakySpecs} · Below ${Math.round(report.threshold * 100)}% pass rate: ${report.quarantineSpecs}`,
"",
"| Spec | Runs | Pass rate | Flaky | Quarantine | Mean ms | Max ms |",
"| --- | ---: | ---: | :---: | :---: | ---: | ---: |",
]
for (const entry of report.specs) {
lines.push(
`| ${entry.spec} | ${entry.runs} | ${(entry.passRate * 100).toFixed(1)}% | ${entry.flaky ? "yes" : ""} | ${entry.quarantine ? "yes" : ""} | ${entry.meanDurationMs} | ${entry.maxDurationMs} |`,
)
}
return `${lines.join("\n")}\n`
}
function collect(project, passthrough) {
const directory = runsDirectory(project)
mkdirSync(directory, { recursive: true })
const outputFile = join(directory, `run-${new Date().toISOString().replaceAll(":", "-")}.json`)
const vitestBin = resolve(evalsRoot, "node_modules", ".bin", process.platform === "win32" ? "vitest.CMD" : "vitest")
const args = [
"run",
"--config",
"vitest.config.ts",
"--project",
project,
"--reporter=default",
"--reporter=json",
`--outputFile=${outputFile}`,
...passthrough,
]
let failed = false
try {
execFileSync(vitestBin, args, { cwd: evalsRoot, stdio: "inherit" })
} catch {
failed = true
}
console.log(`\nflake-report: saved ${relative(evalsRoot, outputFile)} (${failed ? "red" : "green"} run)`)
}
function report(project, threshold) {
const directory = runsDirectory(project)
let files = []
try {
files = readdirSync(directory).filter((name) => name.startsWith("run-") && name.endsWith(".json"))
} catch {
throw new Error(`no collected runs in ${relative(evalsRoot, directory)}; run flake:collect first`)
}
if (files.length === 0) throw new Error(`no collected runs in ${relative(evalsRoot, directory)}; run flake:collect first`)
const runs = files.map((name) => normalizeRun(JSON.parse(readFileSync(join(directory, name), "utf8")), evalsRoot))
const result = aggregate(runs, threshold)
const markdown = renderMarkdown(result, project)
writeFileSync(join(directory, "flake-report.md"), markdown)
writeFileSync(join(directory, "flake-report.json"), `${JSON.stringify(result, null, 2)}\n`)
console.log(markdown)
console.log(`flake-report: wrote ${relative(evalsRoot, join(directory, "flake-report.md"))}`)
}
const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
if (invokedDirectly) {
const { mode, project, threshold, passthrough } = parseArguments(process.argv.slice(2))
if (mode === "collect") collect(project, passthrough)
else report(project, threshold)
}