ci(frontend-next): measured coverage floor + test-discovery guard (#3318) - #3417
Merged
Merged
Conversation
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.OpenSSF ScorecardScorecard details
Scanned Files
|
Xore
enabled auto-merge (squash)
September 27, 2026 20:06
Xore
force-pushed
the
oc/3318-coverage-ratchet
branch
from
September 27, 2026 22:07
c91a4d3 to
85f13de
Compare
…overy guard (#3318) frontend-next ran vitest and Playwright in quality.yml and collected no coverage, so there was no baseline: a change could delete tested behaviour and CI stayed green, because the only thing that noticed was a reviewer noticing the test that asserted it was gone. Three things, all measured rather than chosen. 1. Measure it. @vitest/coverage-v8 (matching the vitest 4.1.11 the lockfile already resolves) and a `test:coverage` script. Line and branch coverage over src/ only -- not the tests, not the config, not the generated route tree -- which is 127 files and is the only thing a coverage number should be about. `npm test` is untouched and stays uninstrumented, so deploy.yml, the README and a developer's loop keep the cheap command. The report is uploaded from the EXISTING frontend-next job, per-(run, attempt) named like every other artifact since #3400, and mirrored into its GitHub-hosted twin because the twins are byte-identical by convention and a ratchet that only ran on the self-hosted executor would skip every degraded day. 2. Record the baseline, and hold it. coverage-baseline.json holds what the suite actually measures today: 806/7359 lines (10.95%) and 346/7250 branches (4.77%), taken on the node:22 image this job runs. That is low because the tests concentrate on src/lib server logic and most of src/routes and src/components is held by the Playwright matrix, which the v8 provider cannot see. It is recorded as measured. No threshold was imported and no number was picked to look respectable -- a 60% baseline would have been red on arrival and would have taught the gate nothing. The ratchet has two independent gates. tolerance.coveredCount is 0, so no covered line or branch may stop being covered: that is what catches a change that deletes tested behaviour, and no shrinking-denominator trick can satisfy it. tolerance.pctPoints is 1.0, which catches a change that adds a lot of untested source. At an ~11% baseline the second is coarse -- it needs several hundred new uncovered lines -- and the first is what carries the weight. Updating the baseline is `npm run coverage:baseline` and a visible diff, never an autoUpdate, so a floor cannot rewrite itself. Proved, not asserted: with one real test file temporarily moved out of the include globs, coverage fell 23 covered lines and 37 covered branches and the ratchet exited 1. That drop is 0.31pp of lines, inside the 1.0pp tolerance -- so the percentage gate alone would have passed it, which is the reason the covered-count gate exists. The file was restored and `git diff HEAD` on it is empty. 3. Test-discovery guard. A test-shaped file that no runner collects is worse than no test, because the file is the evidence. The guard asks the real runners -- `vitest list --filesOnly` and `playwright test --list` -- what they collect and fails on the difference, rather than reimplementing their globs and agreeing with them right up until it does not. Both runners are asked because two own .ts/.spec.ts here: asking only vitest would flag e2e/dashboard.spec.ts, and the fix would be a suppression list. Proved with a canary .spec.ts under src/ -- a shape the vitest include globs do not name -- which failed the guard and named the file, then was removed; the guard is green again. One thing that needed handling: Playwright's collection imports every file its testMatch reaches, including e2e/fake-backend.test.mjs, and importing that node:test module writes a TAP banner to stdout ahead of the JSON. The report is read from a file via PLAYWRIGHT_JSON_OUTPUT_NAME so nothing can interleave with it. Both proofs are pinned as cases in scripts/tests/test_3318_coverage_ratchet.py (17 tests, auto-discovered by the existing scripts/tests lane), so a later PR cannot widen a tolerance or drop a CI step without that suite going red. Not done, deliberately: e2e/fake-backend.test.mjs is a node:test file that no configured runner collects either, so the guard's own contract has a pre-existing exception. Wiring it up is a different runner in a different tier and out of scope here, so the guard is .ts/.tsx only rather than a new gate that is red on day one. zizmor reports the same single already-allowlisted advisory as before this change and nothing new; no new action was added, and the one used is the SHA already pinned in this file. actionlint clean.
The coverage ratchet pinned @vitest/coverage-v8 to ^4.1.11 because vitest was 4.1.11 when it was written. #3100 moved vitest to 5 on main while this branch was queued, and the peer dependency is major-matched: vitest 4 declares peerOptional @vitest/coverage-v8@4.1.11. Resolving the conflict by keeping the old pin would install a mismatched pair that fails at test time rather than at install time. Bump the plugin to ^5.0.1 so the pair stays same-major, and regenerate the lockfile so its root entry matches the manifest again.
Xore
force-pushed
the
oc/3318-coverage-ratchet
branch
from
September 27, 2026 22:38
85f13de to
75b8ba1
Compare
…the image `npm ci` in the dashboard-next container failed with EUSAGE, "Missing: ioredis@5.11.1 from lock file" (and @ioredis/commands, redis-parser). The Dockerfile pins node:22-alpine@sha256:c610fcd, which ships npm 10.9.8, but the lockfile had been regenerated with npm 11. Both write lockfileVersion 3, so the version number hid it: npm 11 hoists ioredis under node_modules/nitro/node_modules/ and omits the top-level entries npm 10's `npm ci` requires. Regenerated with the npm inside the pinned image rather than on a workstation, which is the only place the two resolvers agree. npm ci in that image now installs 420 packages and exits 0; package.json engines, dependencies and devDependencies still match the lockfile root entry.
Xore
force-pushed
the
oc/3318-coverage-ratchet
branch
from
September 27, 2026 22:56
5692303 to
0ed8513
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #3318.
frontend-next ran vitest and Playwright in
quality.ymland collected no coverage, so there was no baseline. A change could delete tested behaviour and CI stayed green — the only thing that noticed was a reviewer noticing the test that asserted it was gone.The measured numbers
Taken on the
node:22-alpineimage this job actually runs (node v22.23.2,npm 10.9.8, vitest 4.1.11), 24/24 files and 215/215 cases green:127 files of
src/. That is low, and it is the real number. The suite concentrates onsrc/libserver logic; most ofsrc/routesandsrc/componentsis held by the Playwright matrix, which the v8 provider cannot see. No threshold was imported and no number was picked to look respectable — a 60/60/60/60 baseline would have been red on arrival and would have taught the gate nothing.coverage-baseline.jsonrecords what the suite measures, andtoleranceis set to hold that.The ratchet
Two independent gates, both from the committed baseline and this run's own measurement:
tolerance.coveredCount= 0 — no covered line or branch may stop being covered. This is the half that catches deleted tested behaviour, and no shrinking-denominator trick can satisfy it (delete an untested file, the percentage goes up).tolerance.pctPoints= 1.0 — the overall percentage may not fall more than that. This catches a change that adds a lot of untested source.At an ~11% baseline the second gate is the coarse one: it needs several hundred new uncovered lines to move, because
covered/(pct − tol)is what the denominator has to exceed. That asymmetry is documented in the baseline, the README and the ratchet, and it is why the covered-count gate carries the weight.Updating the floor is
npm run coverage:baseline— a visible diff, never anautoUpdate, so a check cannot rewrite itself.scripts/tests/test_3318_coverage_ratchet.pypinscoveredCount == 0and boundspctPointsin both directions, so a later PR cannot widen the tolerance without going red.Proof the ratchet bites
One real test file (
src/lib/credentialState.test.ts) temporarily moved out of the include globs, re-measured, then restored (git diff HEADon it is empty — nothing insrc/is touched by this PR):Worth noting: 0.31pp of lines is inside the 1.0pp tolerance. The percentage gate alone would have passed that drop. Only the covered-count gate caught it — which is the evidence for why it exists, and why it is pinned at 0.
Proof the discovery guard bites
A canary
src/lib/__discovery_canary.spec.ts— deliberately the.specinfix, which the vitest include globs do not name, so a guard that only knew.testwould have passed it:Canary removed, guard green again (25 files). Both proofs are now pinned as cases in
scripts/tests/test_3318_coverage_ratchet.py(17 tests, auto-discovered by the existingscripts/testslane — no workflow edit).The guard asks the real runners (
vitest list --filesOnly,playwright test --list) rather than reimplementing their globs. Both, because two runners own.ts/.spec.tshere — asking only vitest would flage2e/dashboard.spec.ts, and the fix would be a suppression list. One trap needed handling: Playwright's collection imports every file itstestMatchreaches, and importinge2e/fake-backend.test.mjs(anode:testmodule) writes a TAP banner to stdout ahead of the JSON — verified in the image this gate runs on. The report is read from a file viaPLAYWRIGHT_JSON_OUTPUT_NAME.Wiring
@vitest/coverage-v8@^4.1.11(matches the vitest the lockfile resolves) andtest:coverage,test:discovery,coverage:ratchet,coverage:baselinescripts. Lockfile change is purely additive (171 insertions, 0 deletions) and re-resolved with npm 10 — the image's npm — so the existing "lockfile installs under the image's npm" check still passes.includeissrc/**only. Excluded: the tests,*.d.ts, and the generatedrouteTree.gen.ts. Verified: 127 files in the report = all 153src/.ts/.tsxminus 24 tests,novnc.d.tsandrouteTree.gen.ts.npm testis untouched and stays uninstrumented — the price is a second run, and it is 6.9s against 6.9s (the cost is transform/import, not instrumentation) in a 45-minute budget.frontend-nextjob. No new job. The artifact isfrontend-next-coverage-${{ github.run_id }}-${{ github.run_attempt }}— the per-(run, attempt) naming from ci: name every artifact per run instead of overwriting a shared name (#3314) #3400, nooverwrite, 7-day retention. Mirrored into its GitHub-hosted twin, because the twins are byte-identical by convention and a ratchet that only ran on the self-hosted executor would skip every degraded day.coverage/is gitignored; the committed record iscoverage-baseline.json.Checks
npm run typecheck,npm test(215/215),npm run build,git diff --exit-code src/routeTree.gen.ts— all clean on the image's node 22.HEAD— the single already-allowlisteddangerous-triggersadvisory. No new finding, nothing new allowlisted. actionlint clean; the workflow's own unquoted-name-with-#guard clean.scripts/testsfull suite: 296 tests, 2 failures — both intest_compose_drift_watch_sweep.py, both reproduced identically on a pristineHEADcheckout (this box cannot create the unreadable-path precondition those two need). Unrelated to this PR. My 17 pass.Left alone deliberately
e2e/fake-backend.test.mjsis anode:testfile that no configured runner collects either — a pre-existing instance of exactly what this guard looks for. Wiring it up is a different runner in a different tier, so the guard is scoped.ts/.tsxrather than being a new gate that is red on day one. Flagging it here rather than fixing it inside this PR.Not merged
Do not merge — needs review.