mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-21 16:41:56 +00:00
fix(logs): respect TZ env var for timestamp display, fix Windows timezone (#21859)
This commit is contained in:
@@ -2,7 +2,7 @@ import { setTimeout as delay } from "node:timers/promises";
|
||||
import type { Command } from "commander";
|
||||
import { buildGatewayConnectionDetails } from "../gateway/call.js";
|
||||
import { parseLogLine } from "../logging/parse-log-line.js";
|
||||
import { formatLocalIsoWithOffset } from "../logging/timestamps.js";
|
||||
import { formatLocalIsoWithOffset, isValidTimeZone } from "../logging/timestamps.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { clearActiveProgressLine } from "../terminal/progress-line.js";
|
||||
import { createSafeStreamWriter } from "../terminal/stream-writer.js";
|
||||
@@ -223,7 +223,8 @@ export function registerLogsCli(program: Command) {
|
||||
const jsonMode = Boolean(opts.json);
|
||||
const pretty = !jsonMode && Boolean(process.stdout.isTTY) && !opts.plain;
|
||||
const rich = isRich() && opts.color !== false;
|
||||
const localTime = Boolean(opts.localTime);
|
||||
const localTime =
|
||||
Boolean(opts.localTime) || (!!process.env.TZ && isValidTimeZone(process.env.TZ));
|
||||
|
||||
while (true) {
|
||||
let payload: LogsTailPayload;
|
||||
|
||||
@@ -1,58 +1,65 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatLocalIsoWithOffset } from "./timestamps.js";
|
||||
|
||||
function buildFakeDate(parts: {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
second: number;
|
||||
millisecond: number;
|
||||
timezoneOffsetMinutes: number;
|
||||
}): Date {
|
||||
return {
|
||||
getFullYear: () => parts.year,
|
||||
getMonth: () => parts.month - 1,
|
||||
getDate: () => parts.day,
|
||||
getHours: () => parts.hour,
|
||||
getMinutes: () => parts.minute,
|
||||
getSeconds: () => parts.second,
|
||||
getMilliseconds: () => parts.millisecond,
|
||||
getTimezoneOffset: () => parts.timezoneOffsetMinutes,
|
||||
} as unknown as Date;
|
||||
}
|
||||
import { formatLocalIsoWithOffset, isValidTimeZone } from "./timestamps.js";
|
||||
|
||||
describe("formatLocalIsoWithOffset", () => {
|
||||
it("formats positive offset with millisecond padding", () => {
|
||||
const value = formatLocalIsoWithOffset(
|
||||
buildFakeDate({
|
||||
year: 2026,
|
||||
month: 1,
|
||||
day: 2,
|
||||
hour: 3,
|
||||
minute: 4,
|
||||
second: 5,
|
||||
millisecond: 6,
|
||||
timezoneOffsetMinutes: -150, // UTC+02:30
|
||||
}),
|
||||
);
|
||||
expect(value).toBe("2026-01-02T03:04:05.006+02:30");
|
||||
const testDate = new Date("2025-01-01T04:00:00.000Z");
|
||||
|
||||
it("produces +00:00 offset for UTC", () => {
|
||||
const result = formatLocalIsoWithOffset(testDate, "UTC");
|
||||
expect(result).toBe("2025-01-01T04:00:00.000+00:00");
|
||||
});
|
||||
|
||||
it("formats negative offset", () => {
|
||||
const value = formatLocalIsoWithOffset(
|
||||
buildFakeDate({
|
||||
year: 2026,
|
||||
month: 12,
|
||||
day: 31,
|
||||
hour: 23,
|
||||
minute: 59,
|
||||
second: 58,
|
||||
millisecond: 321,
|
||||
timezoneOffsetMinutes: 300, // UTC-05:00
|
||||
}),
|
||||
);
|
||||
expect(value).toBe("2026-12-31T23:59:58.321-05:00");
|
||||
it("produces +08:00 offset for Asia/Shanghai", () => {
|
||||
const result = formatLocalIsoWithOffset(testDate, "Asia/Shanghai");
|
||||
expect(result).toBe("2025-01-01T12:00:00.000+08:00");
|
||||
});
|
||||
|
||||
it("produces correct offset for America/New_York", () => {
|
||||
const result = formatLocalIsoWithOffset(testDate, "America/New_York");
|
||||
// January is EST = UTC-5
|
||||
expect(result).toBe("2024-12-31T23:00:00.000-05:00");
|
||||
});
|
||||
|
||||
it("produces correct offset for America/New_York in summer (EDT)", () => {
|
||||
const summerDate = new Date("2025-07-01T12:00:00.000Z");
|
||||
const result = formatLocalIsoWithOffset(summerDate, "America/New_York");
|
||||
// July is EDT = UTC-4
|
||||
expect(result).toBe("2025-07-01T08:00:00.000-04:00");
|
||||
});
|
||||
|
||||
it("outputs a valid ISO 8601 string with offset", () => {
|
||||
const result = formatLocalIsoWithOffset(testDate, "Asia/Shanghai");
|
||||
// ISO 8601 with offset: YYYY-MM-DDTHH:MM:SS.mmm±HH:MM
|
||||
const iso8601WithOffset = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/;
|
||||
expect(result).toMatch(iso8601WithOffset);
|
||||
});
|
||||
|
||||
it("falls back gracefully for an invalid timezone", () => {
|
||||
const result = formatLocalIsoWithOffset(testDate, "not-a-tz");
|
||||
const iso8601WithOffset = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/;
|
||||
expect(result).toMatch(iso8601WithOffset);
|
||||
});
|
||||
|
||||
it("does NOT use getHours, getMinutes, getTimezoneOffset in the implementation", () => {
|
||||
const source = fs.readFileSync(path.resolve(__dirname, "timestamps.ts"), "utf-8");
|
||||
expect(source).not.toMatch(/\.getHours\s*\(/);
|
||||
expect(source).not.toMatch(/\.getMinutes\s*\(/);
|
||||
expect(source).not.toMatch(/\.getTimezoneOffset\s*\(/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidTimeZone", () => {
|
||||
it("returns true for valid IANA timezones", () => {
|
||||
expect(isValidTimeZone("UTC")).toBe(true);
|
||||
expect(isValidTimeZone("America/New_York")).toBe(true);
|
||||
expect(isValidTimeZone("Asia/Shanghai")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for invalid timezone strings", () => {
|
||||
expect(isValidTimeZone("not-a-tz")).toBe(false);
|
||||
expect(isValidTimeZone("yo agent's")).toBe(false);
|
||||
expect(isValidTimeZone("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,36 @@
|
||||
export function formatLocalIsoWithOffset(now: Date): string {
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
const h = String(now.getHours()).padStart(2, "0");
|
||||
const m = String(now.getMinutes()).padStart(2, "0");
|
||||
const s = String(now.getSeconds()).padStart(2, "0");
|
||||
const ms = String(now.getMilliseconds()).padStart(3, "0");
|
||||
const tzOffset = now.getTimezoneOffset();
|
||||
const tzSign = tzOffset <= 0 ? "+" : "-";
|
||||
const tzHours = String(Math.floor(Math.abs(tzOffset) / 60)).padStart(2, "0");
|
||||
const tzMinutes = String(Math.abs(tzOffset) % 60).padStart(2, "0");
|
||||
return `${year}-${month}-${day}T${h}:${m}:${s}.${ms}${tzSign}${tzHours}:${tzMinutes}`;
|
||||
export function isValidTimeZone(tz: string): boolean {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en", { timeZone: tz });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatLocalIsoWithOffset(now: Date, timeZone?: string): string {
|
||||
const explicit = timeZone ?? process.env.TZ;
|
||||
const tz =
|
||||
explicit && isValidTimeZone(explicit)
|
||||
? explicit
|
||||
: Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
const fmt = new Intl.DateTimeFormat("en", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
fractionalSecondDigits: 3 as 1 | 2 | 3,
|
||||
timeZoneName: "longOffset",
|
||||
});
|
||||
|
||||
const parts = Object.fromEntries(fmt.formatToParts(now).map((p) => [p.type, p.value]));
|
||||
|
||||
const offsetRaw = parts.timeZoneName ?? "GMT";
|
||||
const offset = offsetRaw === "GMT" ? "+00:00" : offsetRaw.slice(3);
|
||||
|
||||
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}.${parts.fractionalSecond}${offset}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user