mirror of https://github.com/openclaw/openclaw.git
fix: harden format time fallback handling
This commit is contained in:
parent
b4a3e5324b
commit
f155d8febc
|
|
@ -59,36 +59,40 @@ export function formatZonedTimestamp(
|
||||||
date: Date,
|
date: Date,
|
||||||
options?: FormatZonedTimestampOptions,
|
options?: FormatZonedTimestampOptions,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const intlOptions: Intl.DateTimeFormatOptions = {
|
try {
|
||||||
timeZone: options?.timeZone,
|
const intlOptions: Intl.DateTimeFormatOptions = {
|
||||||
year: "numeric",
|
timeZone: options?.timeZone,
|
||||||
month: "2-digit",
|
year: "numeric",
|
||||||
day: "2-digit",
|
month: "2-digit",
|
||||||
hour: "2-digit",
|
day: "2-digit",
|
||||||
minute: "2-digit",
|
hour: "2-digit",
|
||||||
hourCycle: "h23",
|
minute: "2-digit",
|
||||||
timeZoneName: "short",
|
hourCycle: "h23",
|
||||||
};
|
timeZoneName: "short",
|
||||||
if (options?.displaySeconds) {
|
};
|
||||||
intlOptions.second = "2-digit";
|
if (options?.displaySeconds) {
|
||||||
}
|
intlOptions.second = "2-digit";
|
||||||
const parts = new Intl.DateTimeFormat("en-US", intlOptions).formatToParts(date);
|
}
|
||||||
const pick = (type: string) => parts.find((part) => part.type === type)?.value;
|
const parts = new Intl.DateTimeFormat("en-US", intlOptions).formatToParts(date);
|
||||||
const yyyy = pick("year");
|
const pick = (type: string) => parts.find((part) => part.type === type)?.value;
|
||||||
const mm = pick("month");
|
const yyyy = pick("year");
|
||||||
const dd = pick("day");
|
const mm = pick("month");
|
||||||
const hh = pick("hour");
|
const dd = pick("day");
|
||||||
const min = pick("minute");
|
const hh = pick("hour");
|
||||||
const sec = options?.displaySeconds ? pick("second") : undefined;
|
const min = pick("minute");
|
||||||
const tz = [...parts]
|
const sec = options?.displaySeconds ? pick("second") : undefined;
|
||||||
.toReversed()
|
const tz = [...parts]
|
||||||
.find((part) => part.type === "timeZoneName")
|
.toReversed()
|
||||||
?.value?.trim();
|
.find((part) => part.type === "timeZoneName")
|
||||||
if (!yyyy || !mm || !dd || !hh || !min) {
|
?.value?.trim();
|
||||||
|
if (!yyyy || !mm || !dd || !hh || !min) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (options?.displaySeconds && sec) {
|
||||||
|
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${sec}${tz ? ` ${tz}` : ""}`;
|
||||||
|
}
|
||||||
|
return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`;
|
||||||
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (options?.displaySeconds && sec) {
|
|
||||||
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${sec}${tz ? ` ${tz}` : ""}`;
|
|
||||||
}
|
|
||||||
return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,12 @@ import {
|
||||||
} from "./format-duration.js";
|
} from "./format-duration.js";
|
||||||
import { formatTimeAgo, formatRelativeTimestamp } from "./format-relative.js";
|
import { formatTimeAgo, formatRelativeTimestamp } from "./format-relative.js";
|
||||||
|
|
||||||
|
const invalidDurationInputs = [null, undefined, -100] as const;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
describe("format-duration", () => {
|
describe("format-duration", () => {
|
||||||
describe("formatDurationCompact", () => {
|
describe("formatDurationCompact", () => {
|
||||||
it("returns undefined for null/undefined/non-positive", () => {
|
it("returns undefined for null/undefined/non-positive", () => {
|
||||||
|
|
@ -55,7 +61,7 @@ describe("format-duration", () => {
|
||||||
|
|
||||||
describe("formatDurationHuman", () => {
|
describe("formatDurationHuman", () => {
|
||||||
it("returns fallback for invalid duration input", () => {
|
it("returns fallback for invalid duration input", () => {
|
||||||
for (const value of [null, undefined, -100]) {
|
for (const value of invalidDurationInputs) {
|
||||||
expect(formatDurationHuman(value)).toBe("n/a");
|
expect(formatDurationHuman(value)).toBe("n/a");
|
||||||
}
|
}
|
||||||
expect(formatDurationHuman(null, "unknown")).toBe("unknown");
|
expect(formatDurationHuman(null, "unknown")).toBe("unknown");
|
||||||
|
|
@ -106,6 +112,12 @@ describe("format-duration", () => {
|
||||||
it("supports seconds unit", () => {
|
it("supports seconds unit", () => {
|
||||||
expect(formatDurationSeconds(2000, { unit: "seconds" })).toBe("2 seconds");
|
expect(formatDurationSeconds(2000, { unit: "seconds" })).toBe("2 seconds");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clamps negative values and rejects non-finite input", () => {
|
||||||
|
expect(formatDurationSeconds(-1500, { decimals: 1 })).toBe("0s");
|
||||||
|
expect(formatDurationSeconds(NaN)).toBe("unknown");
|
||||||
|
expect(formatDurationSeconds(Infinity)).toBe("unknown");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -152,13 +164,52 @@ describe("format-datetime", () => {
|
||||||
const result = formatZonedTimestamp(date, options);
|
const result = formatZonedTimestamp(date, options);
|
||||||
expect(result).toMatch(expected);
|
expect(result).toMatch(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns undefined when required Intl parts are missing", () => {
|
||||||
|
function MissingPartsDateTimeFormat() {
|
||||||
|
return {
|
||||||
|
formatToParts: () => [
|
||||||
|
{ type: "month", value: "01" },
|
||||||
|
{ type: "day", value: "15" },
|
||||||
|
{ type: "hour", value: "14" },
|
||||||
|
{ type: "minute", value: "30" },
|
||||||
|
],
|
||||||
|
} as Intl.DateTimeFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.spyOn(Intl, "DateTimeFormat").mockImplementation(
|
||||||
|
MissingPartsDateTimeFormat as unknown as typeof Intl.DateTimeFormat,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(formatZonedTimestamp(new Date("2024-01-15T14:30:00.000Z"), { timeZone: "UTC" })).toBe(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when Intl formatting throws", () => {
|
||||||
|
function ThrowingDateTimeFormat() {
|
||||||
|
return {
|
||||||
|
formatToParts: () => {
|
||||||
|
throw new Error("boom");
|
||||||
|
},
|
||||||
|
} as Intl.DateTimeFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.spyOn(Intl, "DateTimeFormat").mockImplementation(
|
||||||
|
ThrowingDateTimeFormat as unknown as typeof Intl.DateTimeFormat,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(formatZonedTimestamp(new Date("2024-01-15T14:30:00.000Z"), { timeZone: "UTC" })).toBe(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("format-relative", () => {
|
describe("format-relative", () => {
|
||||||
describe("formatTimeAgo", () => {
|
describe("formatTimeAgo", () => {
|
||||||
it("returns fallback for invalid elapsed input", () => {
|
it("returns fallback for invalid elapsed input", () => {
|
||||||
for (const value of [null, undefined, -100]) {
|
for (const value of invalidDurationInputs) {
|
||||||
expect(formatTimeAgo(value)).toBe("unknown");
|
expect(formatTimeAgo(value)).toBe("unknown");
|
||||||
}
|
}
|
||||||
expect(formatTimeAgo(null, { fallback: "n/a" })).toBe("n/a");
|
expect(formatTimeAgo(null, { fallback: "n/a" })).toBe("n/a");
|
||||||
|
|
@ -240,5 +291,14 @@ describe("format-relative", () => {
|
||||||
])("$name", ({ offsetMs, options, expected }) => {
|
])("$name", ({ offsetMs, options, expected }) => {
|
||||||
expect(formatRelativeTimestamp(Date.now() + offsetMs, options)).toBe(expected);
|
expect(formatRelativeTimestamp(Date.now() + offsetMs, options)).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to relative days when date formatting throws", () => {
|
||||||
|
expect(
|
||||||
|
formatRelativeTimestamp(Date.now() - 8 * 24 * 3600000, {
|
||||||
|
dateFallback: true,
|
||||||
|
timezone: "Invalid/Timezone",
|
||||||
|
}),
|
||||||
|
).toBe("8d ago");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue