diff --git a/src/cli.ts b/src/cli.ts index b117948b4..2e483d663 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 fe28ba47b..8cacd2805 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 000000000..80bbbcc7e --- /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.mocked.test.ts b/test/lib/timezone.mocked.test.ts new file mode 100644 index 000000000..ddf752d94 --- /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 new file mode 100644 index 000000000..24a468df4 --- /dev/null +++ b/test/lib/timezone.test.ts @@ -0,0 +1,168 @@ +/** + * 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", () => { + 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", () => { + 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); + }); +});