Keep the frontend mutation dry run deterministic across timezone stubs (#2943) - #3007
Conversation
`vi.stubEnv('TZ', zone)` changes the runtime zone only as a side effect of
Node's real environment store notifying V8. That notification does not happen
under `pool: 'threads'`: `process.env.TZ` reads back as the requested zone while
`Date` and `Intl` keep the host zone.
`@stryker-mutator/vitest-runner` v10 forces `pool: 'threads', maxWorkers: 1` for
its Vitest dry run, so every zone-sensitive assertion silently measured the CI
runner's own zone there.
`src/tests/utils/timeZone.ts` replaces the env stub. It never mutates process
state: every value is derived from `Intl.DateTimeFormat(..., { timeZone })`,
which takes the zone as an explicit argument, so it behaves identically in both
pools and on a host in any zone.
- `zonedParts` / `offsetMinutesAt` read a zone's wall clock for a real instant
- `instantAtZonedWallClock` inverts that, replacing `new Date(y, m, d, ...)`
(whose local-parts constructor reads the host zone), and throws on a wall
clock that a DST gap makes non-existent
- `installTimeZone` patches `Date`'s local accessors and `Intl`'s default zone
and returns a restore function
Refs #2943
…zone The PaperHomeView day-boundary rows (#1768) built their fixture with `new Date(y, m, d, ...)` after `vi.stubEnv('TZ', zone)` and asserted `getUTCDate() - getDate()`. Both halves read the *process* zone, so when the stub failed to take — which is exactly what happens in Stryker's Vitest dry run (`pool: 'threads'`) — the row re-measured the host zone instead of failing loudly. On the UTC CI runner the expected -1/+1 shifts collapsed to 0 and the mutation lane died before executing a single mutant (run 34518952589). The rows now build their instant with `instantAtZonedWallClock` and compare the UTC calendar day against the zone's own calendar day from `zonedParts` — both computed from explicit `Intl` zone arguments, so the shift is the same number in any pool and on any host. `installTimeZone` puts the view itself under the zone, and a new test asserts that directly rather than inferring it. #1768's product semantics are unchanged: the same six boundary rows, the same same-day and cross-timezone copy assertions, the same date-neutral contract. Refs #2943
A whole-suite run under Stryker's dry-run pool shape (`--pool=threads --maxWorkers=1`) failed 11 tests across 6 files, not just the PaperHomeView rows from the reported run. All of them stubbed `TZ` and then asserted on a zone-derived value. Converted to `installTimeZone`, which is pool-independent: - `utils/dueDates.spec.ts` — calendar-key projection rows - `utils/demoData.spec.ts` — Today demo bucket rows - `store/board/cardFilterStore.spec.ts` — due-today local-day case - `store/savedViewStore.spec.ts` — due-today local-day case - `views/CalendarView.spec.ts` — local-month boundary case The remaining `TZ` stubs happened to pass in both pools on a UTC-adjacent host, which means they were proving nothing about the zone they named. Converted too, so no live example of the broken pattern is left to copy: - `components/CardItem.spec.ts` - `composables/useCardModal.spec.ts` - `composables/useTodayDossier.spec.ts` - `views/paper/PaperBoardCard.spec.ts` - `views/SavedViewsView.spec.ts` - `views/TodayView.spec.ts` Refs #2943
Stryker runs the whole Vitest suite once before executing any mutant, and it does not use the repository's default pool. Nothing said so, which is why a green `ci-required` frontend job was read as evidence that the mutation lane would start. - `npm run test:stryker-pool` reproduces the dry run's exact Vitest shape (`--pool=threads --maxWorkers=1 --maxConcurrency=1`) in seconds, with or without a spec argument. - MUTATION_TESTING_POLICY.md records the pool override, the `TZ` trap, and run 34518952589 as the worked example. - frontend/taskdeck-web/CLAUDE.md points zone-sensitive specs at `src/tests/utils/timeZone.ts` and states the fake-timers ordering rule. Refs #2943
frontend/taskdeck-web/package.json is a declared control path in ci/policy.v1.json, so adding one convenience script would reclassify an otherwise ordinary test change as R4 and park it behind the control-plane review gate. Not worth it for two flags: the docs carry the raw npx vitest --pool=threads command instead. Refs #2943
…ry-run-timezone # Conflicts: # frontend/taskdeck-web/src/tests/composables/useCardModal.spec.ts
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Manual Mutation Testing dispatched from this branch with |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fa5a26dcd7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| instant = new NativeDate(naiveMs + offsetMinutesAt(instant, timeZone) * 60_000) | ||
|
|
||
| const reached = zonedParts(instant, timeZone) | ||
| if (wallClockAsUtcMs(reached) !== naiveMs) { |
There was a problem hiding this comment.
Reject invalid wall-clock tuples before normalization
When a spec accidentally supplies an impossible tuple, wallClockAsUtcMs(target) normalizes it before this comparison, and the reached value is converted through the same normalization. For example, [2026, 1, 30, 12, 0, 0] silently becomes March 2, while years 0–99 become 1900–1999, so a timezone test can validate a different instant without failing. Validate the original tuple or compare the reached parts directly with it before returning.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Valid, and it cut against the helper's own stated contract (fail loudly rather than return a silently shifted instant). Fixed in ac7f826.
Two changes: a range check on every field before any normalization can happen (year >= 100, so Date.UTC's 0-99 -> 1900-1999 remap cannot bite), and the round-trip check now compares zonedParts(instant, timeZone) field by field against the ORIGINAL tuple instead of against wallClockAsUtcMs(target) — which was the actual bug, since that value had already absorbed the normalization it was supposed to catch.
[2026, 1, 30, 12, 0, 0], [26, 7, 19, ...], month 12, hour 24 and a fractional second now all throw with the offending field named; five assertions in timeZone.spec.ts cover them.
…izing them Codex review on PR #3007 (P2): `Date.UTC` normalizes an invalid tuple before the round-trip check saw it, and the check then compared against the already normalized milliseconds — so `[2026, 1, 30, …]` became 2 March and years 0-99 became the 1900s, both silently. A spec asserting on a day boundary would have been measuring some other day. Two changes: a range check on every field up front (year >= 100 so the Date.UTC century remap cannot bite), and the round-trip now compares field by field against the ORIGINAL tuple rather than against the normalized value it would have absorbed. Refs #2943
|
Mutation workflow outcome — run 34632842611 (head Every timezone failure is gone. The branch started from 11 dry-run failures across 6 files; zero remain. The dry run now stops on one unrelated test:
Tracked separately as #3009 — a different defect class from the timezone half this PR owns, and one that affects any text-scanning guard over a The workflow was not re-dispatched at |
|
Check state at
One red: That is the planner refusing to plan against a base that moved underneath it rather than guessing — working as designed against a fast-moving Not touched here on purpose. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ac7f826073
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| vi.useFakeTimers() | ||
|
|
||
| expect(Intl.DateTimeFormat().resolvedOptions().timeZone).not.toBe('Pacific/Kiritimati') |
There was a problem hiding this comment.
Compare against the captured host timezone
When this spec runs on a host configured for Pacific/Kiritimati, fake timers correctly discard the installed Intl wrapper but expose the same timezone string, so this not.toBe assertion fails despite correct behavior. Capture the original host timezone and compare with it, or choose an installed timezone known to differ from the host, to preserve the helper's claimed host-independent regression coverage.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Triaged once: LOW, non-blocking (CI hosts run UTC, so the assertion is discriminating where it runs). Tracked as #3013 item 4 with the other helper follow-ups.
|
Fresh-context independent review at exact head Verified by the reviewer from source and the bundled vitest/fake-timers implementation (read-only):
Non-blocking notes, recorded here (law 2c):
Informational: the red |
What
Makes the frontend unit suite give the same answer under Stryker's Vitest dry run as it does under
the ordinary unit jobs, so the mutation lane reaches mutant execution instead of dying in the dry
run. No production code changes — test infrastructure plus two docs.
Closes #2943
Root cause
vi.stubEnv('TZ', zone)writes the string intoimport.meta.env, which Vitest mirrors ontoprocess.env. Changing the runtime's zone is a side effect of that write: Node's realenvironment store calls V8's
DateTimeConfigurationChangeNotificationwhen the key isTZ, andthat is what makes
DateandIntlre-detect the zone.That side effect does not happen in every pool. Measured locally (Node 24.19.0, Vitest 5.0.0,
environment: 'happy-dom') with a probe spec that writesTZ = Pacific/Kiritimatiand reads back:process.env.TZafter the writeIntl.DateTimeFormat().resolvedOptions().timeZonenew Date(2026, 7, 19, 12, 0, 0)forks(the repo default)Pacific/KiritimatiPacific/Kiritimati-1threadsPacific/Kiritimati0@stryker-mutator/vitest-runnerv10 forces the threads pool for every run it drives(
dist/src/vitest-test-runner.js,#getVitestPoolConfig):So the TZ stub is inert inside Stryker's dry run while
process.env.TZstill reads back as therequested zone — it fails silently rather than throwing. The PaperHomeView day-boundary rows (#1768)
then compared
getUTCDate() - getDate(), where both halves read the process zone, so instead offailing loudly they re-measured the runner's own zone: on a UTC runner every expected ±1 shift
collapses to
0. That is theexpected -1, received 0inrun 34518952589.
Both halves of the issue's either/or are true: the runner does bypass the stub, and the test
depended on mutable process timezone state.
Local reproduction
A whole-suite run in that shape showed the damage was wider than the reported run:
11 tests across 6 files, all TZ-stub dependent.
The fix
frontend/taskdeck-web/src/tests/utils/timeZone.ts— a timezone helper that never mutates processstate. Every value is derived from
Intl.DateTimeFormat(…, { timeZone }), which takes the zone asan explicit argument, so it is identical in both pools and on a host in any zone.
zonedParts/offsetMinutesAt— read a zone's wall clock for a real instant.instantAtZonedWallClock— the inverse, replacingnew Date(y, m, d, …)(whose local-partsconstructor reads the host zone). Throws on a wall clock a DST gap makes non-existent rather than
returning a silently shifted instant.
installTimeZone— patchesDate's local accessors andIntl's default zone so code under testobserves the zone, and returns a restore function.
The day-boundary rows now build their instant with
instantAtZonedWallClockand assert the UTCcalendar day against the zone's own calendar day from
zonedParts, so the load-bearingutcDayShiftcolumn is the same number in any pool, on any host, whatever the host clock says. Anew test asserts the zone really took (
resolvedOptions().timeZone,getTimezoneOffset(),getDate()vsgetUTCDate()) instead of inferring it from anew Date(...)built out ofhost-local parts.
#1768's product semantics are unchanged: the same six boundary rows, the same same-day case, the
same cross-timezone copy assertions, the same date-neutral contract on both the lede and the queue
card.
Every other
vi.stubEnv('TZ', …)in the suite is converted too. Five files were red under thethreads pool; the remaining six passed in both pools on a UTC-adjacent host, which means they were
proving nothing about the zone they named — and leaving live examples of the broken pattern invites
the next copy-paste.
Ordering gotcha, pinned by a test
vi.useFakeTimers()replaces the wholeIntlglobal (vitest bundles @sinonjs/fake-timers'IntlWithClock), soinstallTimeZonemust be called after it, never before. A test intimeZone.spec.tspins that behaviour so it cannot regress unnoticed.Regression + reproduction command
src/tests/utils/timeZone.spec.tsis the deterministic regression: 14 tests covering the zonemaths, both DST edges, the fake-timer interaction, and the "a TZ env stub changes nothing here"
property — green under both pools, which is the point.
Reproduce the dry-run environment with
npx vitest --run --pool=threads --maxWorkers=1 --maxConcurrency=1(documented indocs/testing/MUTATION_TESTING_POLICY.md). Deliberately not wrapped in an npm script:frontend/taskdeck-web/package.jsonis a declared control path inci/policy.v1.json, so addingone would reclassify this ordinary test change as R4 and park it behind the control-plane review
gate — not worth it for two flags.
Checks run (local, at the merged head)
--pool=threads --maxWorkers=1 --maxConcurrency=1)forkspoolci-required, which runs exactly thisnpx stryker run --dryRunOnly(frontend)npm run typechecknpm run buildtypecheckis its first stage and passed; hosted CI builds)npx eslint src/tests/node scripts/check-docs-governance.mjsnode scripts/check-doc-links.mjsci-requiredon the exact head is the authoritative gate.NOT verified
mutants survive. The score stays a non-blocking calibration signal under the current policy.
independent by construction (explicit
Intlzone arguments only) and asserted as such, but it wasexecuted on exactly two host zones: this Europe/London box, and whatever CI uses.
The lane after this PR: one blocker fixed, one exposed (#3009)
Manual frontend-only Mutation Testing dispatched from this branch at head
fa5a26dcd:run 34632842611.
Every timezone failure is gone. Of the 11 dry-run failures across 6 files that this branch
started from, zero remain. What the dry run stops on now is a different test entirely:
Reproduced locally with
npx stryker run --dryRunOnly, byte-identical.boardMutationCapabilityParity.spec.ts(#1945) reads the raw text ofsrc/store/boardStore.tsand regex-matches the facade's return block. That file is a
mutatetarget, so inside Stryker'ssandbox it has been rewritten by the instrumenter, the regex matches nothing, and the spec's own
guards-the-guard assertion fires — correctly. It is instrumentation, not the pool: the same spec
passes on its own under Stryker's pool shape (
--pool=threads --maxWorkers=1, 8/8).That is a different defect class from #2943 (which is specifically the timezone half), so it is
tracked as #3009 rather than folded in here. The acceptance box "re-run the manual workflow and
record the head/run" is therefore recorded-but-not-green: head and run are above, the run is red for
#3009's reason.
Review
Codex raised one P2 on
instantAtZonedWallClock:Date.UTCnormalized an impossible tuple(
[2026, 1, 30, …]→ 2 March; years 0-99 → the 1900s) before the round-trip check, and the checkthen compared against the already-normalized milliseconds — so a spec could have silently measured a
different instant. Fixed in
ac7f826: a range check on every field first (year >= 100 so thecentury remap cannot bite), and the round-trip now compares field by field against the original
tuple. Five new assertions cover it.
Out of scope / not touched
.github/workflows/**andscripts/ci/**(control plane) — read only.frontend/taskdeck-web/stryker.smoke.config.mjs— leased by PR test(mutation): add frontend activation smoke guard #2931.frontend/taskdeck-web/package.json— deliberately left alone (control path, see above).src/tests/**or a doc.Docs sync
docs/testing/MUTATION_TESTING_POLICY.md— the frontend section now records the dry-run pooloverride, the reproduction command, and the
TZtrap with run 34518952589 as the worked example.frontend/taskdeck-web/CLAUDE.md— region rule: zone-sensitive specs usesrc/tests/utils/timeZone.ts, nevervi.stubEnv('TZ', …); plus the fake-timers ordering rule andthe new proving command.
docs/STATUS.mdchange — shipped product reality is unchanged, and the docs region rule saysnot to touch it for tooling or evidence-only work.
Open
OUTSTANDING_TASKS.mditems are unaffected by this change; none were ticked.