Skip to content

Keep the frontend mutation dry run deterministic across timezone stubs (#2943) - #3007

Merged
Chris0Jeky merged 8 commits into
mainfrom
issue-2943/mutation-dry-run-timezone
Sep 11, 2026
Merged

Chris0Jeky merged 8 commits into
mainfrom
issue-2943/mutation-dry-run-timezone

Conversation

@Chris0Jeky

@Chris0Jeky Chris0Jeky commented Sep 11, 2026 •

Copy link
Copy Markdown
Owner

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 into import.meta.env, which Vitest mirrors onto
process.env. Changing the runtime's zone is a side effect of that write: Node's real
environment store calls V8's DateTimeConfigurationChangeNotification when the key is TZ, and
that is what makes Date and Intl re-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 writes TZ = Pacific/Kiritimati and reads back:

pool process.env.TZ after the write Intl.DateTimeFormat().resolvedOptions().timeZone UTC-day shift of new Date(2026, 7, 19, 12, 0, 0)
forks (the repo default) Pacific/Kiritimati Pacific/Kiritimati -1
threads Pacific/Kiritimati host zone, unchanged 0

@stryker-mutator/vitest-runner v10 forces the threads pool for every run it drives
(dist/src/vitest-test-runner.js, #getVitestPoolConfig):

return { pool: 'threads', maxWorkers: 1 };

So the TZ stub is inert inside Stryker's dry run while process.env.TZ still reads back as the
requested 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 of
failing loudly they re-measured the runner's own zone: on a UTC runner every expected ±1 shift
collapses to 0. That is the expected -1, received 0 in
run 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

$ npx vitest --run --maxWorkers=2 src/tests/views/paper/PaperHomeView.spec.ts
  Tests  34 passed (34)

$ npx vitest --run --pool=threads --maxWorkers=1 --maxConcurrency=1 src/tests/views/paper/PaperHomeView.spec.ts
  Tests  4 failed | 30 passed (34)
  AssertionError: expected +0 to be -1     # the UTC+14 row — the exact CI flip

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 process
state. Every value is derived from Intl.DateTimeFormat(…, { timeZone }), which takes the zone as
an 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, replacing new Date(y, m, d, …) (whose local-parts
    constructor reads the host zone). Throws on a wall clock a DST gap makes non-existent rather than
    returning a silently shifted instant.
  • installTimeZone — patches Date's local accessors and Intl's default zone so code under test
    observes the zone, and returns a restore function.

The day-boundary rows now build their instant with instantAtZonedWallClock and assert the UTC
calendar day against the zone's own calendar day from zonedParts, so the load-bearing
utcDayShift column is the same number in any pool, on any host, whatever the host clock says. A
new test asserts the zone really took (resolvedOptions().timeZone, getTimezoneOffset(),
getDate() vs getUTCDate()) instead of inferring it from a new Date(...) built out of
host-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 the
threads 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 whole Intl global (vitest bundles @sinonjs/fake-timers'
IntlWithClock), so installTimeZone must be called after it, never before. A test in
timeZone.spec.ts pins that behaviour so it cannot regress unnoticed.

Regression + reproduction command

src/tests/utils/timeZone.spec.ts is the deterministic regression: 14 tests covering the zone
maths, 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 in
docs/testing/MUTATION_TESTING_POLICY.md). Deliberately not wrapped in an npm script:
frontend/taskdeck-web/package.json is a declared control path in ci/policy.v1.json, so adding
one 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)

Check Result
Full suite, Stryker's dry-run shape (--pool=threads --maxWorkers=1 --maxConcurrency=1) 431 files, 6625 passed, 3 skipped, 0 failed (before: 11 failed / 6 files)
Full suite, default forks pool deferred to hosted ci-required, which runs exactly this
npx stryker run --dryRunOnly (frontend) every timezone failure gone; stops on one unrelated blocker, now #3009 — see below
npm run typecheck pass
npm run build not run locally (typecheck is its first stage and passed; hosted CI builds)
npx eslint src/tests/ clean
node scripts/check-docs-governance.mjs pass
node scripts/check-doc-links.mjs pass (694 files, 0 broken links)

ci-required on the exact head is the authoritative gate.

NOT verified

  • The mutation lane still does not produce a report, for a reason this PR does not own. See the section below.
  • The mutation score itself. This change lets the dry run finish; it says nothing about how many
    mutants survive. The score stays a non-blocking calibration signal under the current policy.
  • Behaviour on a runner whose own zone is neither UTC nor Europe/London. The helper is host-zone
    independent by construction (explicit Intl zone arguments only) and asserted as such, but it was
    executed on exactly two host zones: this Europe/London box, and whatever CI uses.
  • Backend Stryker.NET: untouched and unrun.
  • No browser/E2E run: nothing user-facing changed.

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:

ERROR DryRunExecutor One or more tests failed in the initial test run:
	board-mutation capability parity reads the facade return block, so a restructure cannot mute the guard
		expected 0 to be greater than 15

Reproduced locally with npx stryker run --dryRunOnly, byte-identical.

boardMutationCapabilityParity.spec.ts (#1945) reads the raw text of src/store/boardStore.ts
and regex-matches the facade's return block. That file is a mutate target, so inside Stryker's
sandbox 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.UTC normalized an impossible tuple
([2026, 1, 30, …] → 2 March; years 0-99 → the 1900s) before the round-trip check, and the check
then 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 the
century 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/** and scripts/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).
  • No production source file changed: every path in the diff is src/tests/** or a doc.

Docs sync

  • docs/testing/MUTATION_TESTING_POLICY.md — the frontend section now records the dry-run pool
    override, the reproduction command, and the TZ trap with run 34518952589 as the worked example.
  • frontend/taskdeck-web/CLAUDE.md — region rule: zone-sensitive specs use
    src/tests/utils/timeZone.ts, never vi.stubEnv('TZ', …); plus the fake-timers ordering rule and
    the new proving command.
  • No docs/STATUS.md change — shipped product reality is unchanged, and the docs region rule says
    not to touch it for tooling or evidence-only work.
  • No ADR — a test-infrastructure repair, not a choice between competing approaches.

Open OUTSTANDING_TASKS.md items are unaffected by this change; none were ticked.

`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
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-11T18:35:29.442800Z ac7f826 New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Manual Mutation Testing dispatched from this branch with frontend_only=true: https://github.com/Chris0Jeky/Taskdeck/actions/runs/34632842611 (head fa5a26dcd7a615973485f989b2042b20c6f04e10). Outcome posted here when it finishes.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Mutation workflow outcome — run 34632842611 (head fa5a26dcd, frontend_only=true): failure, but not for #2943's reason.

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:

ERROR DryRunExecutor One or more tests failed in the initial test run:
	board-mutation capability parity reads the facade return block, so a restructure cannot mute the guard
		expected 0 to be greater than 15

boardMutationCapabilityParity.spec.ts (#1945) regex-matches the raw text of src/store/boardStore.ts, which is a mutate target and is therefore rewritten by Stryker's instrumenter inside the sandbox. Instrumentation, not the pool: that spec passes 8/8 on its own under --pool=threads --maxWorkers=1. Reproduced locally with npx stryker run --dryRunOnly, byte-identical.

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

The workflow was not re-dispatched at ac7f826 (the review fix): that commit touches only src/tests/utils/timeZone.*, so it cannot change #3009's outcome, and a second red run would spend CI to learn nothing.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Check state at ac7f826, for the reviewer.

Frontend Unit / Source Launcher (Linux) — the check that actually exercises this diff — passed. Docs Governance, Paper Color Audit, SAST, Gitleaks, Dependency Review, OpenAPI Guardrail, Backend Architecture all passed. Backend Unit and API Integration were still running; nothing in this diff touches backend code.

One red: Smart CI / Required Gate (run 34633859212), with

merge ref resolution failed closed after 3 attempts: base mismatch (not the live base branch tip)

That is the planner refusing to plan against a base that moved underneath it rather than guessing — working as designed against a fast-moving main (this branch was rebased onto a main that advanced 32 commits during the session, and it moved again during the run). It is not a signal about this diff: Smart CI / Plan and Smart CI / Planner Self-Test both passed in the same run.

Not touched here on purpose. smart-ci-shadow.yml is .github/workflows/**, i.e. control plane and R4, and this slice is scoped to frontend test determinism; per .claude/rules/ci-control.md the shadow gate is observation-only until CI-03 #2327 registers it, and a persistent red there is a planner concern for that lane, not a product verdict. Flagging rather than fixing or dismissing — re-running against the current base is the cheap first check if it persists.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Fresh-context independent review at exact head ac7f826073fac36a829119de24b42bda1a386d3b against base 15ee8065aa350f96ba7b37c164d99e617a305a6d: CLEAN, no CRITICAL/HIGH findings. The Codex P2 (impossible wall-clock tuples) was fixed in this head before the review and is covered below.

Verified by the reviewer from source and the bundled vitest/fake-timers implementation (read-only):

  • All 17 vi.stubEnv('TZ', ...) sites are converted, none left behind. For every converted spec the zone-observing production seam is either a Date local getter (patched by installTimeZone) or an explicit timeZone: 'UTC' format (zone-independent before and after), so no coverage of Home shows '1 carry-over from yesterday' seconds after a same-day capture on a fresh account #1768 was lost; the six-row day-shift table was verified by hand.
  • installTimeZone patches Date.prototype local getters, getTimezoneOffset, the toLocale* methods and wraps Intl.DateTimeFormat only when no timeZone is given; ClockDate extends NativeDate under fake timers, so production new Date().getHours() observes the zone; the fake-timers-before-install ordering rule is correct because useFakeTimers replaces the Intl global. Restore is present in every converted spec; no partial-patch leak on invalid zones.
  • instantAtZonedWallClock: range check precedes the round-trip and the round-trip compares against the original tuple, so impossible tuples throw; non-hour offsets (+05:45, +14:00, -09:30) converge in one correction; the spring-forward gap throws; a spurious rejection would need two tzdata transitions within one offset delta, which real zones do not have.
  • The new 19-test helper spec runs in the required frontend-unit lanes and in Stryker's forced threads pool; the helper lives under src/tests/**, type-checked by the vitest tsconfig and unreachable from the app build; it is not in Stryker's mutate list.
  • No production file changed. The docs' load-bearing claims (package.json is a control path; the vitest-runner forces pool: 'threads') check out.

Non-blocking notes, recorded here (law 2c):

  • LOW: the helper patches local getters but not the local-parts Date constructor or setX mutators; useTodayDossier.spec.ts now installs a zone around a composable using both patterns (no current assertion reads those values). Worth one sentence in frontend/taskdeck-web/CLAUDE.md.
  • LOW: the "never vi.stubEnv('TZ')" rule is convention only; a source-text guard spec in the existing src/tests/guards/ pattern would enforce it.
  • LOW: removing vi.unstubAllEnvs() leaves those files with no env cleanup net; harmless today.
  • LOW: ambiguous fall-back wall clocks resolve to the earlier occurrence west of UTC and the later one east of UTC; deterministic, but the doc comment implies a rule that does not exist.

Informational: the red Smart CI / Required Gate is the advisory shadow lane's known planner error (#2562, fix parked in #2987); ci-required is the gate. Merging with a merge commit once the hosted checks at this head are green.

@Chris0Jeky
Chris0Jeky merged commit ae7d86b into main Sep 11, 2026
37 of 38 checks passed
@Chris0Jeky
Chris0Jeky deleted the issue-2943/mutation-dry-run-timezone branch September 11, 2026 18:56
@github-project-automation github-project-automation Bot moved this from Pending to Done in Taskdeck Execution Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Keep the frontend mutation dry run deterministic across timezone stubs

1 participant