From e1641f8e621dc88af2f00987fb1d45d376d80170 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 02:06:56 +0000 Subject: [PATCH] test(runner): pin the suite's timezone to UTC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The date-face pin family asserts literal LOCAL-date faces (`Jul 4, 2024`, `7/4/2024 7:00 am`) built from fixed UTC instants through local date parts, and nothing pinned the runner's zone. Measured over the six files carrying the family at 76573a184: green at UTC, 7 failures at Europe/Paris, 7 at Asia/Shanghai, 31 at America/New_York, 33 at Etc/GMT+8 — green at exactly one offset, invisible on CI (which is UTC) and paid entirely by contributors elsewhere. `vitest.config.mts` now sets `process.env.TZ = 'UTC'` in module scope, which `apps/console/vitest.config.ts` inherits by importing it. The literal faces stay: they are deliberate anti-tautology anchors, and deriving them from the formatter under test would turn them into `formatDate(v) === formatDate(v)`. `scripts/__tests__/vitest-timezone-pin-8366.test.ts` spawns a real vitest under `TZ=Etc/GMT+8` and asserts the run still sees UTC, so deleting the pin reds CI rather than only a contributor's laptop. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr --- .changeset/8366-vitest-timezone-pin.md | 7 + .../vitest-timezone-pin-8366.test.ts | 138 ++++++++++++++++++ vitest.config.mts | 48 ++++++ 3 files changed, 193 insertions(+) create mode 100644 .changeset/8366-vitest-timezone-pin.md create mode 100644 scripts/__tests__/vitest-timezone-pin-8366.test.ts diff --git a/.changeset/8366-vitest-timezone-pin.md b/.changeset/8366-vitest-timezone-pin.md new file mode 100644 index 0000000000..1f92423c75 --- /dev/null +++ b/.changeset/8366-vitest-timezone-pin.md @@ -0,0 +1,7 @@ +--- +--- + +Pin the test runner's timezone to UTC (objectui#8366). The date-face pin family +asserts literal LOCAL-date faces built from fixed UTC instants, so the suite was +green at exactly one offset and red on a contributor's laptop anywhere else. +Test infrastructure only; no package is released by this change. diff --git a/scripts/__tests__/vitest-timezone-pin-8366.test.ts b/scripts/__tests__/vitest-timezone-pin-8366.test.ts new file mode 100644 index 0000000000..2861b444f0 --- /dev/null +++ b/scripts/__tests__/vitest-timezone-pin-8366.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +/** + * objectui#8366 — the suite's ambient timezone is UTC, pinned. + * + * ## What this guards + * + * `vitest.config.mts` sets `process.env.TZ = 'UTC'` in its module scope. A + * family of date pins asserts LITERAL local-date faces (`Jul 4, 2024`, + * `7/4/2024 7:00 am`) built from fixed UTC instants through LOCAL date parts, + * so without that line the face they render is a property of the + * contributor's laptop. Measured on `76573a184`, over the six files that carry + * the family, BEFORE the pin: + * + * TZ=UTC 184 passed TZ=Europe/Paris 7 failed + * TZ=Asia/Shanghai 7 failed TZ=America/New_York 31 failed + * TZ=Etc/GMT+8 33 failed + * + * Green at exactly one offset — not merely "west of somewhere". A `07:00 AM` + * face pinned off an `07:00Z` instant is true at UTC+00:00 and nowhere else. + * + * ## Why a spawn and not just an in-process assertion + * + * CI runs in UTC. So `getTimezoneOffset() === 0`, asserted in this process, is + * green on CI whether or not the config still pins anything — it would catch a + * deleted pin only for the contributor it was written to protect, and stay + * silent in the one place that gates merges. That is the exact shape of the + * original defect, one level up. + * + * So the fact is measured where it can fail: a real vitest is spawned with + * `TZ=Etc/GMT+8` in its environment and asked to run the ambient-zone case + * below. It can only pass if the config overrode the inherited zone. Delete the + * line in `vitest.config.mts` and this case reds on CI. + * + * `Etc/GMT+8` is UTC-08:00 — POSIX inverts the sign — chosen because it is the + * zone the reporting contributor measured in. + * + * ## The live control + * + * A spawn-based pin has one silent failure: if `TZ` never reached the child at + * all, the child would read UTC for a reason that has nothing to do with the + * config, and the assertion would pass forever. `the child environment really + * carries TZ` measures that hand-off against a plain `node -e`, so a green + * spawn case is attributable. + * + * ## Recursion + * + * The child runs THIS file, filtered by `--testNamePattern` to the ambient-zone + * case, so the spawn cases are skipped there. `OBJECTUI_TZ_PIN_CHILD` is a + * second, independent belt: even an unfiltered child cannot spawn a grandchild. + */ + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const configPath = path.join(repoRoot, 'vitest.config.mts'); +const selfPath = path.relative(repoRoot, fileURLToPath(import.meta.url)); + +/** UTC-08:00. POSIX `Etc/GMT+N` zones invert the sign; this is deliberate. */ +const WEST = 'Etc/GMT+8'; +const WEST_OFFSET_MINUTES = 480; + +/** The name the spawned child is filtered down to. Kept as one constant so the + * filter and the `it()` title cannot drift apart. */ +const AMBIENT_CASE = 'the suite runs in UTC, whatever zone the contributor is in'; + +const IS_CHILD = process.env.OBJECTUI_TZ_PIN_CHILD === '1'; + +/** The vitest CLI entry, resolved rather than assumed at a `node_modules` path. */ +const vitestCli = (() => { + const require = createRequire(path.join(repoRoot, 'noop.js')); + const pkgPath = require.resolve('vitest/package.json'); + const bin = (JSON.parse(fs.readFileSync(pkgPath, 'utf8')).bin as { vitest: string }).vitest; + return path.resolve(path.dirname(pkgPath), bin); +})(); + +describe('objectui#8366 — the runner pins the timezone', () => { + it(AMBIENT_CASE, () => { + // Read two independent surfaces: `Date`'s own offset, and the zone ICU + // resolves for a bare formatter — which is what every `toLocaleDateString` + // in the date-face family actually consults. + expect(new Date('2024-07-04T07:00:00.000Z').getTimezoneOffset()).toBe(0); + expect(new Intl.DateTimeFormat().resolvedOptions().timeZone).toBe('UTC'); + }); + + it.skipIf(IS_CHILD)('control — the child environment really carries TZ', () => { + // Non-vacuity for the case below. Without this, a `TZ` that never reached + // the child would make the spawned run read UTC for a reason that has + // nothing to do with `vitest.config.mts`, and the pin would be inert. + const probe = spawnSync( + process.execPath, + ['-e', 'process.stdout.write(String(new Date("2024-07-04T07:00:00.000Z").getTimezoneOffset()))'], + { cwd: repoRoot, encoding: 'utf8', env: { ...process.env, TZ: WEST }, timeout: 60_000 }, + ); + + expect(probe.status).toBe(0); + expect(probe.stdout.trim()).toBe(String(WEST_OFFSET_MINUTES)); + }); + + it.skipIf(IS_CHILD)( + 'a real vitest spawned under a non-UTC TZ still runs in UTC', + () => { + const env: NodeJS.ProcessEnv = { ...process.env }; + // A fresh CLI, not a nested worker of this run. + for (const key of Object.keys(env)) if (key.startsWith('VITEST')) delete env[key]; + env.TZ = WEST; + env.OBJECTUI_TZ_PIN_CHILD = '1'; + + const child = spawnSync( + process.execPath, + [vitestCli, 'run', selfPath, '--testNamePattern', AMBIENT_CASE], + { cwd: repoRoot, encoding: 'utf8', env, timeout: 300_000 }, + ); + const output = `${child.stdout ?? ''}${child.stderr ?? ''}`; + + expect(output).toContain('1 passed'); + expect(child.status).toBe(0); + }, + 360_000, + ); + + it.skipIf(IS_CHILD)('the pin is where the spawn says it is', () => { + // The cheap static half. It cannot replace the spawn — a line can be + // present and no longer take effect, which is the direction vitest could + // move under us — but it names the file and the spelling for whoever + // arrives here from a red spawn. + const source = fs.readFileSync(configPath, 'utf8'); + + expect(source).toContain("process.env.TZ = 'UTC'"); + // Not `??=` / `||=`: a contributor's zone usually comes from + // `/etc/localtime`, not from `TZ`, so a conditional assignment would leave + // the reported population unfixed. See the comment at the assignment. + expect(source).not.toMatch(/process\.env\.TZ\s*(\?\?|\|\|)=/); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts index 1bc46dd685..89896da7cd 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -7,6 +7,54 @@ import { assertCanonicalVitestInvocation, cliHasTestFilters } from './scripts/vi const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +// ── The suite runs in UTC, always (objectui#8366) ─────────────────────────── +// +// A family of pins asserts LITERAL local-date faces — `Jul 4, 2024`, +// `Jul 4, '24`, `7/4/2024 7:00 am` — built from fixed UTC instants +// (`2024-07-04T07:00:00.000Z` and friends) through LOCAL date parts. Nothing +// pinned the runner's zone, so the face those pins render was a property of +// the contributor's laptop. Measured on `76573a184` over the six files that +// carry the family: +// +// TZ=UTC 6 files passed (184 tests, 0 failed) +// TZ=Europe/Paris 3 failed | 3 passed (7 failed) +// TZ=Asia/Shanghai 3 failed | 3 passed (7 failed) +// TZ=America/New_York 4 failed | 2 passed (31 failed) +// TZ=Etc/GMT+8 4 failed | 2 passed (33 failed) +// +// So the suite was green at EXACTLY ONE offset, not merely west of some +// boundary: a `07:00 AM` face pinned off an `07:00Z` instant is true at +// UTC+00:00 and nowhere else. CI is UTC, so the class was invisible there and +// only ever cost a contributor — a wall of red that is not about the code. +// +// Why the zone and not the assertions: those literals are load-bearing ON +// PURPOSE. `date-display.optionsStyle-7745.test.ts` says so in its own words — +// its faces are "anchored by the two literals the card measured, so a redesign +// that moved both sides together could not pass silently." Deriving the +// expected face from the same formatter under test is what the two files that +// ALREADY pass do (`dataset-format.date.test.ts`, +// `DatasetWidget.dateMeasure.test.tsx`), and it is the right assertion where +// the claim is "this surface shows what a list cell shows" — but on an anchor +// it degrades to `formatDate(v) === formatDate(v)` and asserts nothing. Pinning +// the offset the anchors were written against keeps them. +// +// This deletes no coverage: no test in this repo reads the ambient zone +// (`process.env.TZ`, `getTimezoneOffset`, `resolvedOptions().timeZone` — zero +// hits across every `*.test.ts`/`*.test.tsx`), and the one zone-aware surface +// that exists takes its zone from METADATA, explicitly +// (`GanttView.tsx`'s `tzOffsetMs(schema.timeZone, …)`). Non-UTC coverage, if +// it is ever wanted, wants an explicit per-case zone — never the runner's +// ambient one, which is the thing that made this silent. +// +// Unconditional on purpose: a contributor's zone usually comes from +// `/etc/localtime` and not from `TZ`, so an `??=` would leave exactly the +// reported population unfixed while pretending to fix it. +// +// Pinned by `scripts/__tests__/vitest-timezone-pin-8366.test.ts`, which spawns +// a real vitest under a non-UTC `TZ` and asserts the run still sees UTC — so +// deleting this line reds CI, where the ambient zone would otherwise hide it. +process.env.TZ = 'UTC'; + // Refuse the two invocations that pass while running none of the tests the // caller asked for — a package-cwd run (objectui#3378) and a path filter that // never reaches Vitest (objectui#3288).