From 989e8a2ad7d1d56203a0635e68a9c84684b91bf0 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:37:19 +0000 Subject: [PATCH 1/2] fix(logger): render log timestamps in local time instead of UTC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `[tag 6:28:59 AM]` log prefix (and every other Date display) is rendered by the runtime's notion of "local" time, which comes from ICU. The CLI ships as Node SEA binaries that frequently cannot resolve the OS timezone and silently fall back to UTC, so users saw timestamps hours off from their local clock even with correct OS settings. - Add `src/lib/timezone.ts`: at startup, detect the OS timezone (from /etc/timezone, the /etc/localtime symlink, or `tzutil` on Windows) and set `process.env.TZ` when the runtime fell back to UTC but the OS clock is elsewhere. Falls back to a fixed `Etc/GMT±N` offset zone when no IANA name is available. No-op when TZ is already set or the runtime resolved a real zone. - Override consola's reporter `formatDate` so the prefix derives local time from `getTimezoneOffset()` (robust against stale ICU state) and uses an unambiguous 24-hour `HH:MM:SS` format. - Wire `initTimezone()` into `startCli()` before any Date is formatted. --- src/cli.ts | 6 + src/lib/logger.ts | 42 +++++- src/lib/timezone.ts | 280 ++++++++++++++++++++++++++++++++++++++ test/lib/timezone.test.ts | 150 ++++++++++++++++++++ 4 files changed, 477 insertions(+), 1 deletion(-) create mode 100644 src/lib/timezone.ts create mode 100644 test/lib/timezone.test.ts diff --git a/src/cli.ts b/src/cli.ts index b117948b4a..2e483d663e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,6 +12,7 @@ import { getEnv } from "./lib/env.js"; import { CliError } from "./lib/errors.js"; +import { initTimezone } from "./lib/timezone.js"; /** * Preload project context: walk up from `cwd` once, finding both the @@ -687,6 +688,11 @@ export async function runCli(cliArgs: string[]): Promise { export async function startCli(): Promise { const args = process.argv.slice(2); + // Repair the timezone before anything formats a Date. SEA binaries can fall + // back to UTC when they cannot resolve the OS zone, which made log + // timestamps (and all other Date output) appear in the wrong timezone. + initTimezone(); + // Completions are a fast-path (~1ms) — skip .sentryclirc I/O. if (args[0] === "__complete") { return runCompletion(args.slice(1)).catch(() => { diff --git a/src/lib/logger.ts b/src/lib/logger.ts index fe28ba47b9..8cacd28056 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -5,7 +5,10 @@ * with log levels, tag scoping, and fancy TTY output. Two reporters are wired up: * * 1. **FancyReporter** (built-in) — writes to stderr with colors/icons for TTY, - * falls back to BasicReporter in CI/non-TTY environments. + * falls back to BasicReporter in CI/non-TTY environments. Its `formatDate` is + * overridden (see {@link patchReporterDates}) so the `[tag HH:MM:SS]` prefix + * renders in corrected local time — consola's default renders UTC in the SEA + * binaries this CLI ships. * 2. **Sentry.createConsolaReporter()** — auto-forwards all log messages to Sentry * structured logs via `_INTERNAL_captureLog`. Requires `enableLogs: true` in * `Sentry.init()` (already enabled in telemetry.ts). @@ -63,6 +66,7 @@ import { createConsola } from "consola"; const _require = createRequire(import.meta.url); import { getEnv } from "./env.js"; +import { formatLogTime } from "./timezone.js"; /** * Environment variable name for controlling CLI log verbosity. @@ -187,6 +191,42 @@ function patchWithTag(instance: ConsolaInstance): void { // propagate the new level to all descendants. patchWithTag(logger); +/** + * A consola reporter exposes a `formatDate(date, opts)` method that produces + * the time shown in the `[tag HH:MM:SS]` log prefix. Consola does not export + * its `FancyReporter`/`BasicReporter` classes, so we type just the slice we + * override. + */ +type DateFormattingReporter = { + formatDate?: (date: Date, opts: unknown) => string; +}; + +/** + * Replace each default reporter's `formatDate` with {@link formatLogTime}. + * + * Consola's built-in reporters format the prefix time via + * `date.toLocaleTimeString()` with no arguments. That call renders in whatever + * timezone the runtime resolved — which silently falls back to **UTC** in the + * SEA binaries this CLI ships, so users saw log timestamps hours off from their + * local clock. Overriding `formatDate` guarantees the prefix uses the corrected + * local time (see {@link formatLogTime}) regardless of ICU state, and switches + * to an unambiguous 24-hour format. + * + * The instances are reused from `logger.options.reporters` so all of consola's + * other formatting (icons, colors, alignment, badges) is preserved untouched. + */ +function patchReporterDates(instance: ConsolaInstance): void { + const reporters = (instance.options?.reporters ?? + []) as DateFormattingReporter[]; + for (const reporter of reporters) { + if (typeof reporter.formatDate === "function") { + reporter.formatDate = (date: Date) => formatLogTime(date); + } + } +} + +patchReporterDates(logger); + /** Whether the Sentry reporter has already been attached */ let sentryReporterAttached = false; diff --git a/src/lib/timezone.ts b/src/lib/timezone.ts new file mode 100644 index 0000000000..80bbbcc7ee --- /dev/null +++ b/src/lib/timezone.ts @@ -0,0 +1,280 @@ +/** + * System timezone detection and repair. + * + * ## Why this exists + * + * All human-facing timestamps in the CLI — the `[tag 6:28:59 AM]` log prefix + * emitted by consola, and every `toLocaleString()`/`toLocaleTimeString()` call + * in the formatters — are rendered by the JavaScript runtime using its notion + * of the "local" timezone. That notion comes from ICU/`process.env.TZ`. + * + * The CLI ships as standalone Node SEA binaries (see `script/build.ts`). SEA + * binaries, containers, and other minimal runtimes frequently lack the OS + * timezone wiring that a normal `node`/`bun` install has. When the runtime + * cannot resolve the system timezone it silently falls back to **UTC** — + * `Intl.DateTimeFormat().resolvedOptions().timeZone === "UTC"` and + * `Date.prototype.getTimezoneOffset()` returns `0`. A user in US/Pacific then + * sees UTC timestamps in the logs, "a timezone completely different than where + * they are", even though their OS clock is set correctly. + * + * ## What this does + * + * {@link initTimezone} runs once at process startup, *before* any `Date` is + * formatted. If the user has already set `TZ` we respect it and do nothing. + * Otherwise, when the runtime looks like it fell back to UTC, we ask the OS + * directly for its configured zone and, if it differs, set `process.env.TZ` + * so the runtime re-resolves against the correct zone. + * + * Detection is best-effort and never throws — a failure to detect leaves the + * runtime exactly as it was (UTC fallback), which is no worse than today. + * + * @module + */ + +import { execSync } from "node:child_process"; +import { readFileSync, readlinkSync } from "node:fs"; + +/** IANA name the runtime reports when it has no real timezone data. */ +const UTC_FALLBACK = "UTC"; + +/** + * Marker path segment used by the tz database. A resolved `/etc/localtime` + * symlink looks like `/usr/share/zoneinfo/America/Los_Angeles`; everything + * after this segment is the IANA name. + */ +const ZONEINFO_MARKER = "/zoneinfo/"; + +/** + * Read the timezone the JS runtime currently believes it is in. + * + * Returns the IANA name from `Intl` (e.g. `"America/Los_Angeles"`), or + * `"UTC"` both when the machine genuinely is UTC and when the runtime fell + * back to UTC because it could not resolve a zone. The two cases are + * indistinguishable from `Intl` alone — that is what {@link osReportsNonUtc} + * disambiguates. + */ +export function runtimeTimezone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || UTC_FALLBACK; + } catch { + return UTC_FALLBACK; + } +} + +/** + * Extract an IANA zone name from a resolved `/etc/localtime` symlink target. + * + * @param linkTarget - The path `/etc/localtime` points at + * @returns The IANA name (e.g. `"America/Los_Angeles"`) or `null` if the + * target does not contain a zoneinfo path. + */ +function ianaFromZoneinfoPath(linkTarget: string): string | null { + const idx = linkTarget.indexOf(ZONEINFO_MARKER); + if (idx === -1) { + return null; + } + const name = linkTarget.slice(idx + ZONEINFO_MARKER.length).trim(); + return name.length > 0 ? name : null; +} + +/** + * Ask the operating system for its configured IANA timezone. + * + * This bypasses the runtime's (possibly missing) ICU wiring and reads the OS + * configuration directly. Strategies are tried in order and the first success + * wins: + * + * - **Linux**: `/etc/timezone` contents, then the `/etc/localtime` symlink. + * - **macOS**: the `/etc/localtime` symlink target. + * - **Windows**: `tzutil /g` returns a Windows zone name; when that maps to a + * known IANA name we use it, otherwise we return `null` and the caller falls + * back to a fixed-offset zone. + * + * @returns An IANA timezone name, or `null` if the OS zone could not be + * determined. Never throws. + */ +export function detectOsTimezone(): string | null { + if (process.platform === "win32") { + return detectWindowsTimezone(); + } + return detectUnixTimezone(); +} + +/** + * Detect the OS timezone on Linux/macOS from the tz database configuration. + */ +function detectUnixTimezone(): string | null { + // /etc/timezone is the canonical, greppable source on Debian/Ubuntu and + // many container images. It holds a single IANA name. + try { + const contents = readFileSync("/etc/timezone", "utf-8").trim(); + if (contents.length > 0 && contents !== UTC_FALLBACK) { + return contents; + } + } catch { + // Not present on macOS and some distros — fall through to the symlink. + } + + // /etc/localtime is a symlink into the zoneinfo database on macOS and most + // Linux distros. Its target encodes the IANA name. + try { + const target = readlinkSync("/etc/localtime"); + return ianaFromZoneinfoPath(target); + } catch { + return null; + } +} + +/** + * Detect the OS timezone on Windows via `tzutil /g`, translating the returned + * Windows zone name to IANA where possible. + */ +function detectWindowsTimezone(): string | null { + try { + const windowsName = execSync("tzutil /g", { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return WINDOWS_TO_IANA[windowsName] ?? null; + } catch { + return null; + } +} + +/** + * Minimal Windows-zone → IANA map covering the common US and European zones a + * developer is most likely to hit. Unmapped zones fall back to a fixed-offset + * zone via {@link fixedOffsetZone}, so this map is deliberately small rather + * than exhaustive. + */ +const WINDOWS_TO_IANA: Record = { + "Pacific Standard Time": "America/Los_Angeles", + "Mountain Standard Time": "America/Denver", + "Central Standard Time": "America/Chicago", + "Eastern Standard Time": "America/New_York", + "US Mountain Standard Time": "America/Phoenix", + "Alaskan Standard Time": "America/Anchorage", + "Hawaiian Standard Time": "Pacific/Honolulu", + "GMT Standard Time": "Europe/London", + "W. Europe Standard Time": "Europe/Berlin", + "Central Europe Standard Time": "Europe/Budapest", + "Romance Standard Time": "Europe/Paris", + UTC: "UTC", +}; + +/** + * Build a fixed-offset IANA zone name from a UTC offset in minutes. + * + * `Date.prototype.getTimezoneOffset()` returns minutes *behind* UTC (positive + * for zones west of UTC), so US/Pacific is `+420`. The `Etc/GMT` zones invert + * the sign — `Etc/GMT+8` is UTC−8 — so this reproduces that convention. + * + * The result has no DST rules, so it is only a last-resort fallback when we + * cannot obtain a proper IANA name. It still fixes the "wrong by 7 hours" + * symptom that the bug report is about. + * + * @param offsetMinutes - Value from `getTimezoneOffset()` (minutes behind UTC) + * @returns A fixed-offset zone name like `"Etc/GMT+8"`, or `null` for UTC. + */ +export function fixedOffsetZone(offsetMinutes: number): string | null { + if (!Number.isFinite(offsetMinutes) || offsetMinutes === 0) { + return null; + } + // Whole-hour offsets only; the Etc/GMT zones don't express minutes. + const hours = Math.trunc(offsetMinutes / 60); + if (hours === 0) { + return null; + } + // getTimezoneOffset is positive west of UTC; Etc/GMT+N is also west of UTC, + // so the sign already matches. + const sign = hours > 0 ? "+" : "-"; + return `Etc/GMT${sign}${Math.abs(hours)}`; +} + +/** + * Whether the OS clock disagrees with the runtime's UTC belief. + * + * `getTimezoneOffset()` reads the process's local offset. In the broken state + * it returns `0` (matching the UTC fallback). A non-zero value means the OS + * itself is not UTC and the runtime's UTC resolution is wrong. + * + * @returns `true` when the local offset is non-zero. + */ +function osReportsNonUtc(): boolean { + return new Date().getTimezoneOffset() !== 0; +} + +/** + * Detect and repair a broken (UTC-fallback) timezone at process startup. + * + * Idempotent and safe to call multiple times. The behavior is: + * + * 1. If `TZ` is already set (by the user or a previous call), do nothing — + * an explicit choice always wins. + * 2. If the runtime did not fall back to UTC, do nothing — it resolved a real + * zone and every `toLocaleString()` will already be correct. + * 3. If the runtime is UTC but the OS clock reports a non-UTC offset, ask the + * OS for its IANA zone (falling back to a fixed-offset zone) and export it + * via `process.env.TZ` so the runtime re-resolves against it. + * + * Must run before any timestamp is formatted (i.e. very early in `bin.ts`). + * + * @returns The IANA/offset zone that was applied, or `null` when nothing + * changed (already set, already correct, or detection failed). + */ +export function initTimezone(): string | null { + // An explicit TZ — from the user's shell or a prior invocation — is + // authoritative. Never override it. + if (process.env.TZ) { + return null; + } + + // Runtime already resolved a real zone; no repair needed. + if (runtimeTimezone() !== UTC_FALLBACK) { + return null; + } + + // Runtime says UTC. If the OS agrees (offset 0), it really is UTC — leave it. + if (!osReportsNonUtc()) { + return null; + } + + // Broken state confirmed: runtime fell back to UTC but the OS is elsewhere. + const zone = + detectOsTimezone() ?? fixedOffsetZone(new Date().getTimezoneOffset()); + if (!zone || zone === UTC_FALLBACK) { + return null; + } + + process.env.TZ = zone; + return zone; +} + +/** + * Format a `Date` as a local-time clock string for the log prefix. + * + * Renders 24-hour `HH:MM:SS` in the machine's local timezone. Unlike consola's + * default `date.toLocaleTimeString()` this does **not** depend on ICU being + * able to resolve a zone: it derives the local wall-clock time from the UTC + * time and `getTimezoneOffset()`, which reflects the OS offset even in runtimes + * that cache a stale UTC formatter (observed under some SEA/Bun builds). After + * {@link initTimezone} has set `TZ`, `getTimezoneOffset()` returns the corrected + * offset, so this stays consistent with the rest of the CLI's output. + * + * The 24-hour format also removes the AM/PM ambiguity of the previous prefix, + * which is friendlier for logs shared across regions. + * + * @param date - The log record's timestamp + * @returns A `HH:MM:SS` string in local time (e.g. `"06:28:59"`) + */ +export function formatLogTime(date: Date): string { + // getTimezoneOffset() is minutes behind UTC (positive west of UTC). Shifting + // the epoch by it and reading UTC getters yields local wall-clock components + // without relying on the ICU formatter. + const localMs = date.getTime() - date.getTimezoneOffset() * 60_000; + const local = new Date(localMs); + const hh = String(local.getUTCHours()).padStart(2, "0"); + const mm = String(local.getUTCMinutes()).padStart(2, "0"); + const ss = String(local.getUTCSeconds()).padStart(2, "0"); + return `${hh}:${mm}:${ss}`; +} diff --git a/test/lib/timezone.test.ts b/test/lib/timezone.test.ts new file mode 100644 index 0000000000..132289ec65 --- /dev/null +++ b/test/lib/timezone.test.ts @@ -0,0 +1,150 @@ +/** + * Tests for src/lib/timezone.ts + * + * Covers the pure helpers (`formatLogTime`, `fixedOffsetZone`) and the + * decision logic of `initTimezone`. The bug being guarded against: SEA + * binaries fall back to UTC when they cannot resolve the OS zone, so log + * timestamps appear hours off from the user's local clock. + */ + +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + detectOsTimezone, + fixedOffsetZone, + formatLogTime, + initTimezone, + runtimeTimezone, +} from "../../src/lib/timezone.js"; + +describe("formatLogTime", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("renders 24-hour HH:MM:SS in local time", () => { + // 2024-01-15T14:28:59Z. Force a +480 (US/Pacific standard, UTC-8) offset so + // the local wall-clock is 06:28:59 regardless of the host machine's zone. + vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(480); + const date = new Date("2024-01-15T14:28:59.000Z"); + expect(formatLogTime(date)).toBe("06:28:59"); + }); + + test("UTC offset renders the UTC wall-clock", () => { + vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(0); + const date = new Date("2024-01-15T14:28:59.000Z"); + expect(formatLogTime(date)).toBe("14:28:59"); + }); + + test("east-of-UTC (negative offset) shifts forward", () => { + // Berlin winter is UTC+1 → getTimezoneOffset() === -60. + vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(-60); + const date = new Date("2024-01-15T23:30:00.000Z"); + // 23:30Z + 1h = 00:30 next day → wraps to 00:30. + expect(formatLogTime(date)).toBe("00:30:00"); + }); + + test("zero-pads single-digit components", () => { + vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(0); + const date = new Date("2024-01-15T01:02:03.000Z"); + expect(formatLogTime(date)).toBe("01:02:03"); + }); + + test("output is always 8 chars in HH:MM:SS shape", () => { + vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(0); + for (let h = 0; h < 24; h++) { + const date = new Date(Date.UTC(2024, 0, 1, h, h % 60, (h * 2) % 60)); + const out = formatLogTime(date); + expect(out).toMatch(/^\d{2}:\d{2}:\d{2}$/); + } + }); +}); + +describe("fixedOffsetZone", () => { + test("west of UTC (positive offset) → Etc/GMT+N", () => { + // US/Pacific standard is +480 min behind UTC → UTC-8. + expect(fixedOffsetZone(480)).toBe("Etc/GMT+8"); + }); + + test("east of UTC (negative offset) → Etc/GMT-N", () => { + // Berlin winter is -60 min → UTC+1. + expect(fixedOffsetZone(-60)).toBe("Etc/GMT-1"); + }); + + test("returns null for UTC / sub-hour / invalid offsets", () => { + expect(fixedOffsetZone(0)).toBeNull(); + expect(fixedOffsetZone(30)).toBeNull(); + expect(fixedOffsetZone(Number.NaN)).toBeNull(); + }); +}); + +describe("runtimeTimezone", () => { + test("returns a non-empty IANA-ish string", () => { + const tz = runtimeTimezone(); + expect(typeof tz).toBe("string"); + expect(tz.length).toBeGreaterThan(0); + }); +}); + +describe("detectOsTimezone", () => { + test("never throws and returns string or null", () => { + const result = detectOsTimezone(); + expect(result === null || typeof result === "string").toBe(true); + }); +}); + +describe("initTimezone", () => { + const originalTz = process.env.TZ; + + afterEach(() => { + vi.restoreAllMocks(); + if (originalTz === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = originalTz; + } + }); + + test("respects an already-set TZ and makes no change", () => { + process.env.TZ = "America/New_York"; + expect(initTimezone()).toBeNull(); + expect(process.env.TZ).toBe("America/New_York"); + }); + + test("does nothing when the runtime already resolved a real zone", () => { + delete process.env.TZ; + vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({ + resolvedOptions: () => ({ timeZone: "America/Los_Angeles" }), + } as unknown as Intl.DateTimeFormat); + + expect(initTimezone()).toBeNull(); + expect(process.env.TZ).toBeUndefined(); + }); + + test("leaves genuine UTC machines untouched", () => { + delete process.env.TZ; + vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({ + resolvedOptions: () => ({ timeZone: "UTC" }), + } as unknown as Intl.DateTimeFormat); + // OS offset 0 → machine really is UTC. + vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(0); + + expect(initTimezone()).toBeNull(); + expect(process.env.TZ).toBeUndefined(); + }); + + test("repairs the UTC fallback using the OS offset", () => { + delete process.env.TZ; + // Runtime fell back to UTC ... + vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({ + resolvedOptions: () => ({ timeZone: "UTC" }), + } as unknown as Intl.DateTimeFormat); + // ... but the OS clock is 8h behind UTC (US/Pacific standard). + vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(480); + + const applied = initTimezone(); + expect(applied).not.toBeNull(); + expect(process.env.TZ).toBe(applied ?? ""); + // Either a detected IANA name or the fixed-offset fallback. + expect(process.env.TZ?.length).toBeGreaterThan(0); + }); +}); From f30ed56b7ee427683c106783392c8d36742c8ec0 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Wed, 29 Jul 2026 09:05:00 +0000 Subject: [PATCH 2/2] test(timezone): cover OS detection branches to lift patch coverage Adds test/lib/timezone.mocked.test.ts mocking node:fs and node:child_process to exercise detectOsTimezone's Unix (/etc/timezone, /etc/localtime symlink) and Windows (tzutil) strategies, plus initTimezone's detected-IANA and fixed-offset repair paths. Adds runtimeTimezone catch/empty-zone cases to the existing file. Brings src/lib/timezone.ts patch coverage to 100% (was 68.63%). --- test/lib/timezone.mocked.test.ts | 221 +++++++++++++++++++++++++++++++ test/lib/timezone.test.ts | 18 +++ 2 files changed, 239 insertions(+) create mode 100644 test/lib/timezone.mocked.test.ts diff --git a/test/lib/timezone.mocked.test.ts b/test/lib/timezone.mocked.test.ts new file mode 100644 index 0000000000..ddf752d94a --- /dev/null +++ b/test/lib/timezone.mocked.test.ts @@ -0,0 +1,221 @@ +/** + * OS timezone-detection tests for src/lib/timezone.ts with node:fs and + * node:child_process mocked before import. + * + * The detection strategies (`/etc/timezone`, the `/etc/localtime` symlink, + * and `tzutil /g` on Windows) read real OS state, which is not deterministic + * in CI. Mocking the filesystem and the tzutil shell-out lets us exercise + * every branch of `detectOsTimezone` and drive `initTimezone`'s repair path + * through a detected IANA name rather than the fixed-offset fallback. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + /** Contents returned by readFileSync("/etc/timezone"), or an Error to throw. */ + etcTimezone: undefined as string | Error | undefined, + /** Target returned by readlinkSync("/etc/localtime"), or an Error to throw. */ + localtimeLink: undefined as string | Error | undefined, + /** stdout returned by execSync("tzutil /g"), or an Error to throw. */ + tzutil: undefined as string | Error | undefined, +})); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readFileSync: vi.fn((path: string, ...rest: unknown[]) => { + if (path === "/etc/timezone") { + if (mocks.etcTimezone instanceof Error) { + throw mocks.etcTimezone; + } + if (mocks.etcTimezone === undefined) { + throw new Error("ENOENT: /etc/timezone"); + } + return mocks.etcTimezone; + } + return (actual.readFileSync as (...a: unknown[]) => unknown)( + path, + ...rest + ); + }), + readlinkSync: vi.fn((path: string, ...rest: unknown[]) => { + if (path === "/etc/localtime") { + if (mocks.localtimeLink instanceof Error) { + throw mocks.localtimeLink; + } + if (mocks.localtimeLink === undefined) { + throw new Error("ENOENT: /etc/localtime"); + } + return mocks.localtimeLink; + } + return (actual.readlinkSync as (...a: unknown[]) => unknown)( + path, + ...rest + ); + }), + }; +}); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execSync: vi.fn((command: string, ...rest: unknown[]) => { + if (command === "tzutil /g") { + if (mocks.tzutil instanceof Error) { + throw mocks.tzutil; + } + if (mocks.tzutil === undefined) { + throw new Error("tzutil not found"); + } + return mocks.tzutil; + } + return (actual.execSync as (...a: unknown[]) => unknown)( + command, + ...rest + ); + }), + }; +}); + +import { detectOsTimezone, initTimezone } from "../../src/lib/timezone.js"; + +const originalPlatform = process.platform; + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value: platform, + configurable: true, + }); +} + +beforeEach(() => { + mocks.etcTimezone = undefined; + mocks.localtimeLink = undefined; + mocks.tzutil = undefined; +}); + +afterEach(() => { + vi.restoreAllMocks(); + setPlatform(originalPlatform); +}); + +describe("detectOsTimezone on Unix", () => { + beforeEach(() => { + setPlatform("linux"); + }); + + test("prefers a valid /etc/timezone IANA name", () => { + mocks.etcTimezone = "America/Los_Angeles\n"; + expect(detectOsTimezone()).toBe("America/Los_Angeles"); + }); + + test("ignores a UTC /etc/timezone and falls through to the symlink", () => { + mocks.etcTimezone = "UTC"; + mocks.localtimeLink = "/usr/share/zoneinfo/Europe/Berlin"; + expect(detectOsTimezone()).toBe("Europe/Berlin"); + }); + + test("ignores an empty /etc/timezone and falls through to the symlink", () => { + mocks.etcTimezone = " \n"; + mocks.localtimeLink = "/var/db/timezone/zoneinfo/Asia/Tokyo"; + expect(detectOsTimezone()).toBe("Asia/Tokyo"); + }); + + test("reads the IANA name from the /etc/localtime symlink target", () => { + // /etc/timezone absent (throws) → symlink path. + mocks.localtimeLink = "/usr/share/zoneinfo/America/New_York"; + expect(detectOsTimezone()).toBe("America/New_York"); + }); + + test("returns null when the symlink target has no zoneinfo segment", () => { + mocks.localtimeLink = "/some/other/path"; + expect(detectOsTimezone()).toBeNull(); + }); + + test("returns null when the zoneinfo segment is empty", () => { + mocks.localtimeLink = "/usr/share/zoneinfo/"; + expect(detectOsTimezone()).toBeNull(); + }); + + test("returns null when neither source is available", () => { + // Both /etc/timezone and /etc/localtime throw ENOENT. + expect(detectOsTimezone()).toBeNull(); + }); +}); + +describe("detectOsTimezone on Windows", () => { + beforeEach(() => { + setPlatform("win32"); + }); + + test("maps a known Windows zone name to IANA", () => { + mocks.tzutil = "Pacific Standard Time\r\n"; + expect(detectOsTimezone()).toBe("America/Los_Angeles"); + }); + + test("returns null for an unmapped Windows zone name", () => { + mocks.tzutil = "Some Obscure Standard Time"; + expect(detectOsTimezone()).toBeNull(); + }); + + test("returns null when tzutil is unavailable", () => { + mocks.tzutil = new Error("tzutil not on PATH"); + expect(detectOsTimezone()).toBeNull(); + }); +}); + +describe("initTimezone applies a detected IANA zone", () => { + const originalTz = process.env.TZ; + + beforeEach(() => { + setPlatform("linux"); + }); + + afterEach(() => { + if (originalTz === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = originalTz; + } + }); + + test("sets TZ to the OS-detected zone when the runtime fell back to UTC", () => { + delete process.env.TZ; + // Runtime reports UTC ... + vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({ + resolvedOptions: () => ({ timeZone: "UTC" }), + } as unknown as Intl.DateTimeFormat); + // ... OS clock is elsewhere ... + vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(480); + // ... and the OS reports a concrete IANA zone. + mocks.etcTimezone = "America/Los_Angeles"; + + expect(initTimezone()).toBe("America/Los_Angeles"); + expect(process.env.TZ).toBe("America/Los_Angeles"); + }); + + test("falls back to a fixed-offset zone when the OS zone is undetectable", () => { + delete process.env.TZ; + vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({ + resolvedOptions: () => ({ timeZone: "UTC" }), + } as unknown as Intl.DateTimeFormat); + vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(480); + // Neither /etc/timezone nor /etc/localtime resolves → fixed-offset path. + expect(initTimezone()).toBe("Etc/GMT+8"); + expect(process.env.TZ).toBe("Etc/GMT+8"); + }); + + test("returns null when detection yields UTC and the offset is sub-hour", () => { + delete process.env.TZ; + vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({ + resolvedOptions: () => ({ timeZone: "UTC" }), + } as unknown as Intl.DateTimeFormat); + // 30-minute offset: osReportsNonUtc() is true, but detectOsTimezone() + // returns null and fixedOffsetZone(30) is null → no repair applied. + vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(30); + expect(initTimezone()).toBeNull(); + expect(process.env.TZ).toBeUndefined(); + }); +}); diff --git a/test/lib/timezone.test.ts b/test/lib/timezone.test.ts index 132289ec65..24a468df44 100644 --- a/test/lib/timezone.test.ts +++ b/test/lib/timezone.test.ts @@ -78,11 +78,29 @@ describe("fixedOffsetZone", () => { }); describe("runtimeTimezone", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + test("returns a non-empty IANA-ish string", () => { const tz = runtimeTimezone(); expect(typeof tz).toBe("string"); expect(tz.length).toBeGreaterThan(0); }); + + test("falls back to UTC when Intl resolves an empty zone", () => { + vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({ + resolvedOptions: () => ({ timeZone: "" }), + } as unknown as Intl.DateTimeFormat); + expect(runtimeTimezone()).toBe("UTC"); + }); + + test("falls back to UTC when Intl throws", () => { + vi.spyOn(Intl, "DateTimeFormat").mockImplementation(() => { + throw new Error("ICU data missing"); + }); + expect(runtimeTimezone()).toBe("UTC"); + }); }); describe("detectOsTimezone", () => {