Skip to content

Mutation baseline and CI ledger (Phase 0) - #1565

Draft
AhtishamShahid wants to merge 4 commits into
chore/rtl-harness-and-react-prepfrom
chore/mutation-baseline-and-ledger
Draft

Mutation baseline and CI ledger (Phase 0)#1565
AhtishamShahid wants to merge 4 commits into
chore/rtl-harness-and-react-prepfrom
chore/mutation-baseline-and-ledger

Conversation

@AhtishamShahid

Copy link
Copy Markdown
Contributor

Related issues

  • Implements mitodl/hq#12637OVS React 18: Phase 0 — Stryker mutation baseline and CI ledger
  • Part of epic mitodl/hq#7958React 18 upgrade (sub-issue 2 of 8)

⚠️ Stacked on #1564. Base is chore/rtl-harness-and-react-prep, not master — GitHub will
retarget automatically when #1564 merges. Review #1564 first.

Closing keywords don't work across repositories, so mitodl/hq#12637 needs closing manually.


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:

Answers
Stryker mutation testing Do these tests actually detect bugs? — the question coverage cannot answer
CI ledger Did anything quietly shrink?
Bundle smoke test Does the app still start?

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.json is committed rather
than 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 the
better test, because RTL cannot reach private methods Enzyme could.

Changes

Commit Change
f2efc5b Stryker 9.6.1 + mocha-runner, stryker.conf.json, npm run mutation
e24fb31 .stryker-baseline.json — the recorded score
fc2a925 scripts/test/ledger.sh + npm run ledger + CI step
271e1cf Production bundle smoke test

Stryker config — six load-bearing settings

Each was verified against a real failure during a timeboxed spike. Do not "simplify" any of them.

Setting Without it
mutator.plugins incl. flow BABEL_PARSE_ERROR on the first import type line — nothing runs
inPlace: true Every test errors: @babel/register doesn't hook inside Stryker's copied sandbox, so mocha falls back to import() and the ESM loader rejects extensionless imports
ignorePatterns Stryker walks .claude/worktrees/, a stale worktree holding a second copy of the project
disableTypeChecks: false Preprocessor tries to parse ui/templates/base.html as markup
mochaOptions.file Loads global_init.js before specs, reproducing js_test.sh rather than relying on argument order
no timeout key mochaOptions is additionalProperties: false and rejects it — use top-level timeoutMS

⚠️ inPlace mutates the working tree and restores from a backup dir. Never run npm run mutation
on uncommitted work.

Ledger

Six metrics, each a build failure rather than a discovery weeks later:

PASS  passing tests                    493  (need ge 492)
PASS  enzyme test files                 31  (need le 31)
PASS  data-testid in components          0  (need le 15)
PASS  FlowFixMe occurrences             40  (need le 40)
PASS  js_test.sh allowlist lines         7  (need le 7)
PASS  mutation score                   48.8 (floor 46.8)

Counts mocha's reported 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 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:

  • Ledger — broke the testid cap, added an 8th allowlist line, and raised the recorded baseline.
    All three exit 1; restoring returns to ledger OK.
  • Smoke test — injected throw new Error(...) into the built bundle; test fails with the
    message. Also verified it skips with no build and skips on a dev build.

Suite 492 → 493 passing. npm run lint and npm run fmt:check clean. No product code modified.

Reviewer notes

Four bugs were caught by insisting each gate demonstrate failure:

  1. Ledger score arithmetic, twice. First draft counted only Killed, omitting Timeout
    (Stryker treats a hung mutant as detected). Second excluded only Ignored/CompileError, not
    RuntimeError — computing 48.4% against a recorded 48.81%. Either would have failed CI on
    nothing. Now matches Stryker's arithmetic exactly.
  2. data-testid counted test fixtures, reading 1 from an inline fixture. Now production files
    only — the thing worth capping is testids added to real components.
  3. Smoke test hit a harness limitation, not an app bug. Automatic publicPath is not supported in this browser: webpack resolves its own URL from document.currentScript.src, which
    vm.runInContext doesn't provide. Fixed by appending a matching <script src>.
  4. Dev builds would hang the smoke testwebpack-hot-middleware's client opens a permanently
    pending EventSource. It now detects and skips them, so a running docker-compose doesn't produce
    a mystery timeout.

Deviation from the issue

hq#12637 specifies a /terms/ smoke test. That framing assumes a running server, which in CI means
docker-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
createRoot timing 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.

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.
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.

1 participant