Mutation baseline and CI ledger (Phase 0) - #1565
Draft
AhtishamShahid wants to merge 4 commits into
Draft
Conversation
Coverage answers 'did this line run'. Mutation testing answers 'if this line were wrong, would a test fail' -- the only objective check that converted RTL tests still assert anything, and the one metric that moves when a conversion keeps the test count while gutting its assertions. Six config settings are load-bearing, each verified against a real failure during the spike (docs/superpowers/notes/stryker-spike.md): - mutator.plugins incl. 'flow': without it the run dies on the first 'import type' line with BABEL_PARSE_ERROR. - inPlace: true: Stryker's copied sandbox breaks @babel/register, so mocha falls back to import() and the ESM loader rejects extensionless imports in global_init.js. Every test errors. - ignorePatterns: Stryker otherwise walks .claude/worktrees/, a stale worktree holding a second copy of the project. - disableTypeChecks: false: no TypeScript here, and the preprocessor otherwise tries to parse ui/templates/base.html as markup. - mochaOptions.file for global_init.js rather than folding it into spec, reproducing js_test.sh faithfully instead of relying on argument order. - No 'timeout' key: mochaOptions is additionalProperties:false and does not accept it. Top-level timeoutMS instead. NOTE: inPlace mutates the working tree and restores from a backup dir. Clean restore verified, but never run 'npm run mutation' on uncommitted work -- a crash mid-run would leave the tree dirty. Verified: 492 passing unchanged, lint clean, json reporter produces reports/mutation/mutation.json (which the ledger will read).
48.81% total / 56.80% covered -- 1349 of 2764 valid mutants detected across 81 files and 2785 mutants, in 26m25s. This is the number every RTL conversion in hq#12638 is measured against. Recorded before any bulk conversion so a regression is attributable, and committed rather than generated so it can only change via review. Score definition matches Stryker's own arithmetic exactly: detected is Killed + Timeout, and the denominator excludes Ignored, CompileError and RuntimeError. ledger.sh uses the same formula -- an earlier draft counted only Killed and excluded only two statuses, which computed 48.4% against a recorded 48.81% and would have tripped the floor on nothing. Not a grade. Its only job is to catch a conversion that keeps the test count while gutting the assertions -- the one failure mode coverage cannot see.
Six metrics that erode silently during a large test migration: passing test
count, remaining Enzyme files, data-testid sprawl in components, FlowFixMe
count, the js_test.sh stderr allowlist, and the mutation score. Each is now
a build failure rather than a discovery weeks later.
Counts mocha's reported passing total, not grep -c 'it(' -- the latter
undercounts loop-generated tests by 27% and an agent can satisfy it while
deleting tests.
The mutation check reads reports/mutation/mutation.json against the recorded
baseline and SKIPs when absent. It never runs Stryker itself: the full run
is 26 minutes, so it stays a per-phase/nightly job.
Score arithmetic matches Stryker's exactly -- detected is Killed + Timeout,
denominator excludes Ignored, CompileError and RuntimeError. Two earlier
drafts got this wrong and computed 48.4% against a recorded 48.81%, which
would have failed the gate on nothing.
data-testid is counted in production files only. A testid on an inline
fixture inside a test file is not sprawl; the thing worth capping is testids
added to real components to make an RTL query easy.
Verified the gate fails in three directions -- testid cap, allowlist growth,
and mutation floor -- and returns to PASS when restored. A gate only ever
seen passing is not a gate.
Boots the built bundle in jsdom and asserts React mounts. The unit suite renders components in isolation and cannot catch a bundle that fails to initialise -- a module-init error, a broken createRoot, an unresolvable dependency -- which is the failure mode the React bumps in R1-R3 produce. No new dependencies: jsdom is already present and webpack-stats.json already lists the entry chunks in load order, the same list the Django template uses to emit <script> tags. Two harness details worth knowing: - Appends a matching <script src> before running each chunk. Webpack's automatic publicPath runtime resolves its own URL from document.currentScript.src, falling back to the last <script> tag; vm.runInContext provides neither, so without this the bundle throws 'Automatic publicPath is not supported in this browser' before any app code runs. That was a harness limitation, not an app defect. - Skips on a dev build. webpack-hot-middleware's client opens a permanently pending EventSource, which would hang rather than fail cleanly whenever someone has docker-compose running locally. Also skips when no build exists, so a fresh clone is not red. Verified all four paths: passes on a prod build, skips with no build, skips on a dev build, and FAILS on an injected boot error. Suite: 492 -> 493 passing.
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.
Related issues
What
The verification harness that makes the bulk Enzyme→RTL conversion safe. No product code changes —
one new test file is the only addition under
static/js/.Three independent safety nets:
Why this before #12638
The dominant risk when an agent rewrites 31 Enzyme files is a test that renders a component and
asserts nothing. It passes, and it reports 100% coverage on every line it touches. Test count stays
at 493, coverage stays flat, and the suite quietly stops detecting anything.
Mutation score is the only number that moves. This PR records it before any bulk conversion, so
a regression during #12638 is attributable.
The baseline
48.81% total / 56.80% covered — 1,349 of 2,764 valid mutants detected across 81 files and 2,785
mutants, in 26m25s.
That is a reference line, not a grade to improve. Its only job is to reveal a conversion that
keeps the test count while weakening the assertions.
.stryker-baseline.jsonis committed ratherthan generated so it can only change under review, and carries a 2-point tolerance — converting
.instance()assertions to behavioural ones can legitimately kill fewer mutants while being thebetter test, because RTL cannot reach private methods Enzyme could.
Changes
f2efc5bstryker.conf.json,npm run mutatione24fb31.stryker-baseline.json— the recorded scorefc2a925scripts/test/ledger.sh+npm run ledger+ CI step271e1cfStryker config — six load-bearing settings
Each was verified against a real failure during a timeboxed spike. Do not "simplify" any of them.
mutator.pluginsincl.flowBABEL_PARSE_ERRORon the firstimport typeline — nothing runsinPlace: true@babel/registerdoesn't hook inside Stryker's copied sandbox, so mocha falls back toimport()and the ESM loader rejects extensionless importsignorePatterns.claude/worktrees/, a stale worktree holding a second copy of the projectdisableTypeChecks: falseui/templates/base.htmlas markupmochaOptions.fileglobal_init.jsbefore specs, reproducingjs_test.shrather than relying on argument ordertimeoutkeymochaOptionsisadditionalProperties: falseand rejects it — use top-leveltimeoutMSinPlacemutates the working tree and restores from a backup dir. Never runnpm run mutationon uncommitted work.
Ledger
Six metrics, each a build failure rather than a discovery weeks later:
Counts mocha's reported total, not
grep -c "it("— the latter undercounts loop-generated testsby 27% and an agent can satisfy it while deleting tests. The mutation check reads a report and SKIPs
when absent; it never runs Stryker itself, since 26 minutes makes that a nightly job.
Verification
Every gate was proven to fail, not just to pass:
All three exit 1; restoring returns to
ledger OK.throw new Error(...)into the built bundle; test fails with themessage. Also verified it skips with no build and skips on a dev build.
Suite 492 → 493 passing.
npm run lintandnpm run fmt:checkclean. No product code modified.Reviewer notes
Four bugs were caught by insisting each gate demonstrate failure:
Killed, omittingTimeout(Stryker treats a hung mutant as detected). Second excluded only
Ignored/CompileError, notRuntimeError— computing 48.4% against a recorded 48.81%. Either would have failed CI onnothing. Now matches Stryker's arithmetic exactly.
data-testidcounted test fixtures, reading 1 from an inline fixture. Now production filesonly — the thing worth capping is testids added to real components.
Automatic publicPath is not supported in this browser: webpack resolves its own URL fromdocument.currentScript.src, whichvm.runInContextdoesn't provide. Fixed by appending a matching<script src>.webpack-hot-middleware's client opens a permanentlypending EventSource. It now detects and skips them, so a running
docker-composedoesn't producea mystery timeout.
Deviation from the issue
hq#12637 specifies a
/terms/smoke test. That framing assumes a running server, which in CI meansdocker-compose orchestration or adding Playwright — disproportionate to the value. Booting the same
bundle directly in jsdom reaches the same goal (real built app, no auth) with zero new
dependencies. It is narrower: it proves the bundle initialises and React mounts, not that the page
renders end-to-end. A genuine browser-level test should be its own issue, sequenced with R3 where
createRoottiming makes it most valuable.Next
mitodl/hq#12638 (convert Tier 1 + Tier 2, 17 files). Every conversion commit should run
npm run ledger; re-check the mutation score at the end of each tier rather than per file.