fix: test adequacy and structural hardening - #7
Merged
Conversation
- Upgrade Vitest 1 -> 4 (coverage-v8, ui), jsdom -> 29, and @testing-library/react 15 -> 16 (+ @testing-library/dom 10). - Add Stryker (core + vitest-runner) and fast-check; wire `npm run test:mutation` and the `version-bump` script (used by the release flow). - Require Node >= 22 across all package manifests; bump packages to 0.3.0 for the next release (changelog in the release commit). - Vitest 4 changed the mock generic signature; migrate the affected suites (vi.fn<[Args], Return> -> vi.fn<(args) => Return>). - Drop the @changesets/cli devDependency and exclude .stryker-tmp from Vitest so sandboxed copies are not collected as tests.
Five latent bugs (100% line coverage missed them; a deep test-adequacy audit surfaced them) are fixed and pinned with red-first + property tests: - A caller timeProvider returning NaN/Infinity or moving backward (NTP/DST/sleep-wake) could leak NaN into snapshots, run the timer forever, count it up, or complete it instantly. Wrap every provider in a monotonic, finite-enforcing guard and clamp remaining to [0, initialValue]. - A non-finite/non-positive tickIntervalMs reached setInterval as NaN/Infinity (a ~0ms tight loop); sanitize to the 100ms default / 10ms minimum. - The year/week/day breakdown mixed bases (year=365d but weeks % 52 = 364d), so parts lost data (364 days rendered all-zero) and never reconstructed the total. Replace with one canonical successive-subtraction decompose() shared by the engine, formatters, and testing-utils (removing three divergent copies). - setSeconds(n) now validates like the constructor and reset(n), throwing on an invalid value instead of silently coercing it. Also corrects two existing tests that had encoded the old buggy boundary values.
Close the oracle gaps the 100%-covered suite missed: per-file kill-tests that assert observable behavior (not just execution), plus property/totality checks. This lifts the core mutation score from ~80% to ~94% (decompose.ts at 100%) and react to ~78%; the rest are documented equivalent mutants. Configure Stryker per package (vitest runner, break thresholds core 85 / react 75; react uses inPlace for the source-aliased monorepo) and ignore the .stryker-tmp / reports artifacts.
- Set the CI test matrix to Node 22 (dropping 18/20, which the >=22 engines and Stryker no longer support). - Update all actions to current majors: checkout v7, setup-node v6, configure-pages v6, deploy-pages v5, upload-pages-artifact v5.
- Document the new setSeconds/reset throw-on-invalid contract in the core and react READMEs and core-usage. - Update Node to 22 (consumer requirement in core README; contributor toolchain in CONTRIBUTING) and note Vitest 4, Stryker (`npm run test:mutation`), fast-check, and the canonical decompose.ts in CONTRIBUTING and CLAUDE.md. - Replace Changesets references with the version-bump + tag release flow, and point the CHANGELOG guidance at SemVer + Keep a Changelog.
Replace Changesets with a tag-triggered npm publish, modeled on the erli18n release pattern and npm's publishing docs: - Remove .changeset/ (the @changesets/cli devDep and changeset/version/release scripts were dropped in the toolchain commit) in favor of a manual lockstep bump, scripts/version-bump.sh (sed-based, SemVer-validated, keeps react -> core in step). - Add .github/workflows/release.yml: on a v* tag it validates the tag against BOTH package versions, runs the full gate + dry-run, publishes core then react (polling the registry between) with --provenance, and cuts a GitHub Release from the CHANGELOG. Gated behind the npm-publish environment (one-time NPM_TOKEN); OIDC trusted publishing documented as an alternative. - Reformat both CHANGELOGs to Keep a Changelog and migrate the pending changeset into the 0.3.0 section; document the flow in RELEASING.md and update the PR template to ask for a CHANGELOG entry instead of a changeset.
Adopt erli18n's single-source-of-truth gate, adapted to npm: - bin/quality-gate.sh runs build + format:check + lint + typecheck (+ test in the full lane). hooks/ (pre-commit -> fast, pre-push -> full) install via hooks/install.sh (git core.hooksPath). - CI runs the SAME script in one 'gate (Node 22)' job instead of four separate jobs, and gains least-privilege 'permissions: contents: read'. - Bring pre-existing files into Prettier compliance, now that format:check is enforced by the gate (it had never been run before). - Document the gate and hooks in CONTRIBUTING and CLAUDE.md. Branch protection required-checks are updated out-of-band (gh) to 'gate (Node 22)'.
Introduce clampSeconds() as the single source of truth for sanitizing a seconds-like input (non-finite/negative -> 0, floor, cap at MAX_SAFE_INTEGER), shared by buildSnapshot, the formatters, and the published testing-utils so every entry point sanitizes identically and the three duplicated clamp expressions collapse into one. (F09) buildSnapshot now decomposes the SAME clamped value it stores, so parts always reconstruct the stored totalSeconds — including above MAX_SAFE_INTEGER, where a raw decompose() diverged: buildSnapshot(1e16) stored the cap but reconstructed 1e16, and Number.MAX_VALUE reconstructed NaN. Both stored second counts are now non-negative integers for every caller. (F01) signalStateChange is now the sole transition notifier; the redundant trailing emits on start/pause/resume/stop/complete are removed, so each transition emits exactly one snapshot. (F11) Complete the CountdownParts JSDoc with all nine fields, the field ranges, and the reconstruction law. (F13) Tests: drive decompose's own non-finite/negative/non-integer guard directly (F22), add MAX_SAFE_INTEGER boundary regressions (1e16, MAX_VALUE), a timer-mutation case, and update the React hook test for the single-emit transition sequence.
Correct documented behavior and examples that disagreed with the code: align the changelog with the actual API surface, fix the core-usage and api-reference snippets, the examples page, FAQ, and React integration notes, and add the missing details to the core README.
Apply least privilege to the docs deploy workflow: default the workflow-level permissions to none, scope the build job to contents:read, and gate it so it only runs on workflow_dispatch or a successful push workflow_run originating from this repository (closing the pwn-request / fork-branch bypass). Isolate pages:write and id-token:write to the deploy job. (F02)
Align the @types/node devDependency with the Node 22 entry in the CI matrix. (F20)
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.
Summary
Brings the test suite from "100% line coverage" to genuine adequacy and resolves the structural issues that audit surfaced. The branch upgrades the test infrastructure (Vitest 4, mutation testing, property-based tests), hardens the core engine, tightens CI/security, and fixes documentation that disagreed with the code. Every fix is a structural solution — no workarounds.
The final layer was a multi-agent review → re-adjudicate → fix → verify pipeline: a dimension-decomposed code review produced a finding catalog, each finding was adversarially re-checked for a real/not-real verdict, the real ones were resolved, and the result was independently ground-truthed (gate + coverage re-run by hand, not trusted from agent self-reports).
What changed
Testing infrastructure
build(deps)).*-mutationtest files.Core engine hardening
clampSeconds()as the single source of truth for seconds sanitization, shared bybuildSnapshot, the formatters, and the published testing-utils (collapses three duplicated clamps into one).buildSnapshotnow decomposes the same clamped value it stores, sopartsalways reconstructtotalSeconds— including aboveMAX_SAFE_INTEGER, where a rawdecompose()previously diverged (buildSnapshot(1e16)stored the cap but reconstructed1e16;Number.MAX_VALUEreconstructedNaN). This consistency bug was caught during the ground-truth pass, after the fix agents had already reported SHIP.signalStateChangeis now the sole transition notifier — the redundant trailing emits on start/pause/resume/stop/complete are removed, so each transition emits exactly one snapshot.CI / security
bin/quality-gate.shused identically by the git hooks and CI.workflow_runabuse: default workflow-level permissions to none, scope the build job tocontents:read, gate it toworkflow_dispatchor a successful same-repository push, and isolatepages:write/id-token:writeto the deploy job.Tooling / docs
scripts/version-bump.sh, see RELEASING.md).CountdownPartsJSDoc (all nine fields, ranges, reconstruction law) and fix inaccurate examples / stale references across the docs site and the core README.Verification
bash bin/quality-gate.sh --full— green (build + format:check + lint + typecheck + test).clampSecondsconsolidation and the directdecomposeguard tests closed the remaining gaps.MAX_SAFE_INTEGERconsistency invariant is re-proven for the boundary inputs (1e16,Number.MAX_VALUE).SemVer
This branch makes observable behavior changes (single-emit transitions, stricter sanitization, hardened clock). Under the 0.x policy these ride a minor bump; the actual version bump + tag is a separate release step per
RELEASING.mdand is intentionally not included here.