Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/8366-vitest-timezone-pin.md
Original file line number Diff line number Diff line change
@@ -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.
138 changes: 138 additions & 0 deletions scripts/__tests__/vitest-timezone-pin-8366.test.ts
Original file line number Diff line number Diff line change
@@ -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*(\?\?|\|\|)=/);
});
});
48 changes: 48 additions & 0 deletions vitest.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading