Files
GeoLibre/scripts/coverage-check.mjs
Qiusheng Wu 1b6f373104 ci(coverage): ratchet large modules that no test loads; lower lint cap to 242 (#2880)
* ci(coverage): ratchet large modules that no test loads

The frontend coverage floor only counts files a test imports, so a big
module with no test is invisible to it. The coverage run now also writes
an lcov report, and scripts/check-untested-modules.mjs compares its file
list with every source file under apps/*/src and packages/*/src. Any
untested file over 500 lines must sit in a committed baseline; a new one,
or a baseline entry growing more than 50 lines, fails ci:frontend.
Entries that gain a test can be pruned with --prune. Backend sidecar
modules with no test_<module>.py are reported, not gated.

Also lower the ESLint --max-warnings cap from 263 to the current 242.

Part of #2858.

* Address review feedback

- Credit each backend test file to the longest module name it matches,
  so test_vector_io.py no longer marks vector.py as tested (Claude).
- Pass module paths into backendModulesWithoutTests and return paths,
  documenting that same-basename modules match together (Claude).
- Report a malformed baseline file by path instead of throwing a raw
  SyntaxError (CodeRabbit).
- Add JSDoc to the remaining helpers.
2026-10-04 01:56:16 -04:00

199 lines
8.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// CI frontend coverage gate: the `node --test` coverage floors, with one retry
// reserved for the line metric.
// Run with: node scripts/coverage-check.mjs (or `npm run test:frontend:coverage`)
//
// Node enforces all three floors itself; this wrapper only decides whether a
// failure is worth a second opinion. Line coverage is the one metric observed
// to be nondeterministic on CI: two runs over byte-identical sources reported
// 81.82% and 76.47%, with 114 of 444 files differing on lines while *zero*
// differed on branches or functions, and the same 5935 tests passing in both.
// The low run failed a floor that the identical tree cleared minutes earlier.
// See https://github.com/opengeos/GeoLibre/issues/1889.
//
// So: a line-only shortfall is re-measured once, and only a second shortfall
// fails the build. Branch and function shortfalls fail immediately, as does any
// actual test failure. That keeps every floor at full strength against a real
// regression (which reproduces on the re-run) while not reddening a PR over an
// accounting artifact nobody can act on.
//
// If the retry ever starts firing regularly, that is the signal to stop
// mitigating and fix the measurement: the retry logs both numbers precisely so
// #1889 can accumulate evidence.
import { spawn } from "node:child_process";
import { mkdirSync, readdirSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
// Kept in sync with the floors documented in docs/maintenance.md. Raise them when
// coverage rises comfortably above, which is what makes this a ratchet.
const LINES = 78;
const BRANCHES = 78;
const FUNCTIONS = 63;
// Where the lcov report lands. `scripts/check-untested-modules.mjs` reads its
// file list to find large modules that no test loads.
const LCOV_PATH = "coverage/frontend.lcov";
/** The `node --test` invocation this gate wraps. */
function testRunnerArgs() {
// Expanded here rather than left to a shell glob, so the gate behaves the
// same under zsh, bash, and cmd.exe.
const testFiles = readdirSync("tests")
.filter((name) => name.endsWith(".test.ts"))
.sort()
.map((name) => path.posix.join("tests", name));
if (testFiles.length === 0) throw new Error("no tests/*.test.ts files found");
return [
"--import",
"tsx",
"--test",
"--experimental-test-coverage",
// Spec to stdout (which `classify` parses) plus an lcov file for
// `npm run check:untested-modules`.
"--test-reporter=spec",
"--test-reporter-destination=stdout",
"--test-reporter=lcov",
`--test-reporter-destination=${LCOV_PATH}`,
`--test-coverage-lines=${LINES}`,
`--test-coverage-branches=${BRANCHES}`,
`--test-coverage-functions=${FUNCTIONS}`,
"--test-coverage-exclude=tests/**",
"--test-coverage-exclude=e2e/**",
"--test-coverage-exclude=**/*.config.*",
...testFiles,
];
}
/**
* Run the suite once, streaming its output through as it arrives while also
* accumulating it for {@link classify}.
*
* Deliberately streamed rather than buffered and written at the end.
* `process.stdout` is asynchronous when it is a pipe, which is what it is under
* a CI runner (but not when redirected to a file locally, which is how this hid
* at first). Writing a multi-megabyte string and then exiting drops whatever had
* not flushed: the run that caught this lost ~42,000 lines of test output and
* the entire coverage summary, cut off mid-line, while still exiting 0.
*/
function runSuite(args) {
return new Promise((resolve) => {
const child = spawn(process.execPath, args, { stdio: ["inherit", "pipe", "pipe"] });
let output = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
output += chunk;
process.stdout.write(chunk);
});
child.stderr.on("data", (chunk) => {
output += chunk;
process.stderr.write(chunk);
});
child.on("error", (error) => {
console.error(`coverage-check: could not start the test runner: ${error.message}`);
resolve({ status: 1, output });
});
child.on("close", (code) => resolve({ status: code ?? 1, output }));
});
}
/**
* Classify a run. The reporter prefixes summary lines with `#` (tap) or `ℹ`
* (spec) depending on whether stdout is a TTY, so both are accepted.
*
* Exported for `tests/coverage-check.test.ts`: deciding when to re-measure is
* the part of this gate that can silently swallow a real regression, so it is
* pinned by tests rather than trusted.
*/
export function classify({ status, output }) {
const failedTests = Number(/(?:^|[#ℹ]\s*)fail\s+(\d+)/m.exec(output)?.[1] ?? 0);
const thresholds = [
...output.matchAll(
/([\d.]+)% (line|branch|function) coverage does not meet threshold of ([\d.]+)%/g,
),
].map((match) => ({ actual: Number(match[1]), metric: match[2], floor: Number(match[3]) }));
return {
ok: status === 0,
failedTests,
thresholds,
// The one case worth a second measurement: every test passed, and lines are
// the only floor that came up short.
lineOnly:
status !== 0 &&
failedTests === 0 &&
thresholds.length > 0 &&
thresholds.every((entry) => entry.metric === "line"),
};
}
/**
* Returns the process exit code. Never calls `process.exit`, which would cut
* off streamed output that has not flushed yet.
*/
async function main() {
const args = testRunnerArgs();
mkdirSync(path.dirname(LCOV_PATH), { recursive: true });
const first = classify(await runSuite(args));
if (first.ok) return 0;
if (!first.lineOnly) {
if (first.failedTests > 0) {
console.error(`\ncoverage-check: ${first.failedTests} test(s) failed. Not retrying.`);
} else if (first.thresholds.length > 0) {
const stable = first.thresholds
.filter((entry) => entry.metric !== "line")
.map((entry) => `${entry.metric} ${entry.actual}% < ${entry.floor}%`)
.join(", ");
console.error(
`\ncoverage-check: ${stable || "coverage"} below floor. Branch and function coverage are ` +
"reproducible run to run, so this is a real regression and is not retried.",
);
}
return 1;
}
const firstLine = first.thresholds[0];
console.error(
`\ncoverage-check: line coverage came in at ${firstLine.actual}%, under the ${firstLine.floor}% floor, ` +
"with every test passing and branch and function coverage fine.\n" +
"coverage-check: that is the signature of the nondeterministic line accounting in " +
"https://github.com/opengeos/GeoLibre/issues/1889, so re-measuring once before failing.\n",
);
const second = classify(await runSuite(args));
if (second.ok) {
console.error(
`\ncoverage-check: the re-run cleared the floor, so the ${firstLine.actual}% reading was a ` +
"measurement artifact rather than a coverage regression.\n" +
`coverage-check: please add the pair (${firstLine.actual}% then passing) to issue #1889. ` +
"If this is happening often, the gate needs fixing rather than retrying.",
);
return 0;
}
const secondLine = second.thresholds.find((entry) => entry.metric === "line");
console.error(
`\ncoverage-check: line coverage was short twice in a row (${firstLine.actual}% then ` +
`${secondLine ? `${secondLine.actual}%` : "short again"}, floor ${firstLine.floor}%). ` +
"A real regression reproduces; this is one. Add tests or, if the drop is a module newly " +
"pulled into the report rather than code getting less tested, see the coverage notes in docs/maintenance.md.",
);
return 1;
}
// Only run the gate when invoked directly, so the test file can import
// `classify` without spawning the whole suite.
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().then(
(code) => {
// Set exitCode rather than calling process.exit, so Node exits once the
// streamed output has drained instead of truncating it.
process.exitCode = code;
},
(error) => {
console.error(`coverage-check: ${error.message}`);
process.exitCode = 1;
},
);
}