Skip to content

ci(frontend-next): measured coverage floor + test-discovery guard (#3318) - #3417

Merged
Xore merged 3 commits into
mainfrom
oc/3318-coverage-ratchet
Sep 27, 2026
Merged

Xore merged 3 commits into
mainfrom
oc/3318-coverage-ratchet

Conversation

@Xore

@Xore Xore commented Sep 27, 2026

Copy link
Copy Markdown
Owner

Closes #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 — the only thing that noticed was a reviewer noticing the test that asserted it was gone.

The measured numbers

Taken on the node:22-alpine image this job actually runs (node v22.23.2, npm 10.9.8, vitest 4.1.11), 24/24 files and 215/215 cases green:

metric covered total pct
lines 806 7359 10.95%
branches 346 7250 4.77%
statements 859 8452 10.16% (recorded, not gated)
functions 107 2666 4.01% (recorded, not gated)

127 files of src/. That is low, and it is the real number. The suite concentrates on src/lib server logic; most of src/routes and src/components is 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.json records what the suite measures, and tolerance is 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 an autoUpdate, so a check cannot rewrite itself. scripts/tests/test_3318_coverage_ratchet.py pins coveredCount == 0 and bounds pctPoints in 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 HEAD on it is empty — nothing in src/ is touched by this PR):

coverage-ratchet: 127 files under src/ (baseline 127)
  FAIL lines     10.64% (783/7359)  baseline 10.9526% (806/7359)  drop 0.3126pp / 23 covered
  FAIL branches  4.2621% (309/7250)  baseline 4.7724% (346/7250)  drop 0.5103pp / 37 covered
::error::coverage regression (#3318) ...
exit 1

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 .spec infix, which the vitest include globs do not name, so a guard that only knew .test would have passed it:

test-discovery: vitest collects 24 file(s)   ← unchanged; the canary is not in it
test-discovery: 26 test-shaped .ts/.tsx file(s) under ./
::error::1 test file(s) exist that no configured runner collects (#3318):
  - src/lib/__discovery_canary.spec.ts
exit 1

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 existing scripts/tests lane — 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.ts here — asking only vitest would flag e2e/dashboard.spec.ts, and the fix would be a suppression list. One trap needed handling: Playwright's collection imports every file its testMatch reaches, and importing e2e/fake-backend.test.mjs (a node:test module) 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 via PLAYWRIGHT_JSON_OUTPUT_NAME.

Wiring

  • @vitest/coverage-v8@^4.1.11 (matches the vitest the lockfile resolves) and test:coverage, test:discovery, coverage:ratchet, coverage:baseline scripts. 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.
  • Coverage include is src/** only. Excluded: the tests, *.d.ts, and the generated routeTree.gen.ts. Verified: 127 files in the report = all 153 src/ .ts/.tsx minus 24 tests, novnc.d.ts and routeTree.gen.ts.
  • npm test is 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.
  • New steps in the existing frontend-next job. No new job. The artifact is frontend-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, no overwrite, 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 is coverage-baseline.json.
  • No new action — the upload reuses the SHA already pinned in this file.

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.
  • zizmor: identical finding set to pristine HEAD — the single already-allowlisted dangerous-triggers advisory. No new finding, nothing new allowlisted. actionlint clean; the workflow's own unquoted-name-with-# guard clean.
  • scripts/tests full suite: 296 tests, 2 failures — both in test_compose_drift_watch_sweep.py, both reproduced identically on a pristine HEAD checkout (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.mjs is a node:test file 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/.tsx rather 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.

@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

OpenSSF Scorecard

Scorecard details
PackageVersionScoreDetails
npm/@bcoe/v8-coverage 1.0.2 UnknownUnknown
npm/@vitest/coverage-v8 5.0.2 UnknownUnknown
npm/@vitest/istanbul-lib-coverage 1.0.2 UnknownUnknown
npm/@vitest/istanbul-lib-report 1.0.2 UnknownUnknown
npm/@vitest/mocker 5.0.2 UnknownUnknown
npm/@vitest/spy 5.0.2 UnknownUnknown
npm/ast-v8-to-istanbul 1.0.7 UnknownUnknown
npm/magicast 0.5.5 UnknownUnknown
npm/tinybench 6.2.0 UnknownUnknown
npm/tinyrainbow 3.1.1 UnknownUnknown
npm/vitest 5.0.2 UnknownUnknown
npm/why-is-node-running 3.2.2 🟢 3.8
Details
CheckScoreReason
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Code-Review⚠️ 1Found 4/30 approved changesets -- score normalized to 1
Pinned-Dependencies⚠️ 0dependency not pinned by hash detected -- score normalized to 0
Binary-Artifacts🟢 10no binaries found in the repo
Token-Permissions🟢 9detected GitHub workflow tokens with excessive permissions
Maintained⚠️ 00 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 0
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Fuzzing⚠️ 0project is not fuzzed
License🟢 10license file detected
Packaging⚠️ -1packaging workflow not detected
Signed-Releases⚠️ -1no releases found
Branch-Protection⚠️ 0branch protection not enabled on development/release branches
Security-Policy⚠️ 0security policy file not detected
SAST⚠️ 0SAST tool is not run on all commits -- score normalized to 0

Scanned Files

  • arcane/home/honeypot-dashboard/frontend-next/package-lock.json

@Xore
Xore enabled auto-merge (squash) September 27, 2026 20:06
@Xore
Xore force-pushed the oc/3318-coverage-ratchet branch from c91a4d3 to 85f13de Compare September 27, 2026 22:07
Xore and others added 2 commits September 28, 2026 00:34
…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
Xore force-pushed the oc/3318-coverage-ratchet branch from 85f13de to 75b8ba1 Compare September 27, 2026 22:38
…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
Xore force-pushed the oc/3318-coverage-ratchet branch from 5692303 to 0ed8513 Compare September 27, 2026 22:56
@Xore
Xore merged commit e259bf0 into main Sep 27, 2026
116 of 117 checks passed
@Xore
Xore deleted the oc/3318-coverage-ratchet branch September 27, 2026 23:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

frontend-next: measure real test coverage and add a non-regression ratchet (no coverage is collected today)

1 participant