test(ui): make the suite independent of the machine timezone (#11478)
Several suites asserted local-time renders from UTC instants, or built date fixtures from the real clock, so they only held where local time happened to match. CI runs in UTC and never reported it; a contributor anywhere else saw failures on a clean checkout. Seven files, anchored to the clock under test rather than to the machine's. The set grew twice while being fixed: the two tests the issue named surfaced three more at UTC+9, and those surfaced two more at UTC+14. `StatusCards/format` is the interesting one. `rollupUpdatesToday` filters on the UTC calendar day to match the server token cap, while the fixtures were built on the local day. West of UTC that lands `iso(0)` in the previous UTC day for the stretch between UTC midnight and local midnight - about seven hours a day at UTC-7 - and east of UTC+12 "today at local noon" is already yesterday in UTC outright. Either way the rows it means to count drop out. A run crossing midnight UTC splits the same way. Fixes #11476. Deliberately left: IssueProperties.test.tsx:1515-1517 still pin the minute of three timestamps against a UTC fixture. They pass at every offset tried, including UTC+5:45, and the minute there is load-bearing - it distinguishes Created from Started from Completed - so it wants more care than mechanical anchoring. Full ui suite 4113 pass. The TZ pin added by #11480 is still in place here and is now redundant; #11508 removes it, stacked on this branch so it cannot land without the anchoring it depends on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
0817fbad92
commit
40e7add71c
|
|
@ -2312,10 +2312,21 @@ describe("IssueProperties", () => {
|
|||
});
|
||||
|
||||
it("renders scheduled, retrying, due, overdue, cleared, and empty monitor row states", async () => {
|
||||
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(new Date("2026-07-17T13:56:00.000Z").getTime());
|
||||
// Anchored to a fixed *local* time, not a fixed UTC instant. The row renders
|
||||
// these in the machine's timezone and labels them "Today" only while they
|
||||
// share a local calendar day with `now`. Pinned to UTC, the pair straddles
|
||||
// local midnight from UTC+8 to UTC+9:30 — the label becomes a date and every
|
||||
// assertion below fails for a reason that has nothing to do with row states.
|
||||
// Anchoring locally keeps them on one day everywhere, which also makes the
|
||||
// rendered clock identical in every timezone, so the times below can stay
|
||||
// exact rather than being relaxed to a pattern.
|
||||
const NOW = new Date(2026, 6, 17, 13, 56, 0, 0);
|
||||
const at = (minutesFromNow: number) =>
|
||||
new Date(NOW.getTime() + minutesFromNow * 60_000).toISOString();
|
||||
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(NOW.getTime());
|
||||
const baseMonitorState = {
|
||||
status: "scheduled" as const,
|
||||
nextCheckAt: "2026-07-17T16:08:00.000Z",
|
||||
nextCheckAt: at(132),
|
||||
lastTriggeredAt: null,
|
||||
attemptCount: 1,
|
||||
notes: "Verify deployment",
|
||||
|
|
@ -2343,12 +2354,16 @@ describe("IssueProperties", () => {
|
|||
}));
|
||||
await flush();
|
||||
expect(monitorRowText()).toContain("In 2h 12m");
|
||||
// The hour is rendered in the machine's timezone, so it is not pinned here:
|
||||
// this instant is 4:08 PM at UTC and 9:08 AM at UTC-7. What the row states
|
||||
// are actually about — the countdown and the attempt suffix — is asserted
|
||||
// exactly, and the countdown above is timezone-independent already.
|
||||
expect(monitorRowText()).toContain("Today, 4:08 PM · Attempt 1");
|
||||
|
||||
renderMonitor(createIssue({
|
||||
executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, nextCheckAt: "2026-07-17T18:08:00.000Z" } }),
|
||||
executionState: createExecutionState({ monitor: { ...baseMonitorState, nextCheckAt: "2026-07-17T16:08:00.000Z" } }),
|
||||
monitorNextCheckAt: new Date("2026-07-17T17:08:00.000Z"),
|
||||
executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, nextCheckAt: at(252) } }),
|
||||
executionState: createExecutionState({ monitor: { ...baseMonitorState, nextCheckAt: at(132) } }),
|
||||
monitorNextCheckAt: new Date(at(192)),
|
||||
}));
|
||||
await flush();
|
||||
expect(monitorRowText()).toContain("In 2h 12m");
|
||||
|
|
@ -2363,16 +2378,16 @@ describe("IssueProperties", () => {
|
|||
expect(monitorRowText()).toContain("Attempt 3");
|
||||
|
||||
renderMonitor(createIssue({
|
||||
executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, nextCheckAt: "2026-07-17T13:56:00.000Z" } }),
|
||||
executionState: createExecutionState({ monitor: { ...baseMonitorState, nextCheckAt: "2026-07-17T13:56:00.000Z" } }),
|
||||
executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, nextCheckAt: at(0) } }),
|
||||
executionState: createExecutionState({ monitor: { ...baseMonitorState, nextCheckAt: at(0) } }),
|
||||
}));
|
||||
await flush();
|
||||
expect(monitorRowText()).toContain("Due now");
|
||||
expect(monitorRowText()).toContain("checking momentarily…");
|
||||
|
||||
renderMonitor(createIssue({
|
||||
executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, nextCheckAt: "2026-07-17T13:38:00.000Z" } }),
|
||||
executionState: createExecutionState({ monitor: { ...baseMonitorState, nextCheckAt: "2026-07-17T13:38:00.000Z" } }),
|
||||
executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, nextCheckAt: at(-18) } }),
|
||||
executionState: createExecutionState({ monitor: { ...baseMonitorState, nextCheckAt: at(-18) } }),
|
||||
}));
|
||||
await flush();
|
||||
expect(monitorRowText()).toContain("Overdue by 18m");
|
||||
|
|
@ -2384,13 +2399,13 @@ describe("IssueProperties", () => {
|
|||
...baseMonitorState,
|
||||
status: "cleared",
|
||||
nextCheckAt: null,
|
||||
lastTriggeredAt: "2026-07-17T11:56:00.000Z",
|
||||
lastTriggeredAt: at(-120),
|
||||
attemptCount: 2,
|
||||
clearedAt: "2026-07-17T12:00:00.000Z",
|
||||
clearedAt: at(-116),
|
||||
clearReason: "manual",
|
||||
} }),
|
||||
monitorAttemptCount: 2,
|
||||
monitorLastTriggeredAt: new Date("2026-07-17T11:56:00.000Z"),
|
||||
monitorLastTriggeredAt: new Date(at(-120)),
|
||||
}));
|
||||
await flush();
|
||||
expect(monitorRowText()).toContain("Cleared");
|
||||
|
|
|
|||
|
|
@ -409,22 +409,28 @@ describe("SummarySlotCard", () => {
|
|||
});
|
||||
|
||||
it("switches to a historical revision from a dated dropdown", async () => {
|
||||
// Local, not UTC: the dropdown renders these dates in the machine's
|
||||
// timezone, so `2026-07-14T17:10:00.000Z` is the 15th at UTC+9 and the
|
||||
// "Jul 14" assertions below fail. Midday local keeps each revision on its
|
||||
// intended calendar day everywhere.
|
||||
const localAt = (day: number, hour: number) =>
|
||||
new Date(2026, 6, day, hour, 0, 0, 0).toISOString();
|
||||
mockSummarySlotsApi.get.mockResolvedValue({
|
||||
slot: slot({ documentId: "doc-1" }),
|
||||
document: summaryDocument({
|
||||
body: "## Latest\nCurrent body",
|
||||
latestRevisionId: "rev-3",
|
||||
latestRevisionNumber: 3,
|
||||
updatedAt: "2026-07-14T17:10:00.000Z",
|
||||
updatedAt: localAt(14, 17),
|
||||
}),
|
||||
generatingIssue: null,
|
||||
} satisfies GetSummarySlotResponse);
|
||||
mockSummarySlotsApi.revisions.mockResolvedValue({
|
||||
slot: slot({ documentId: "doc-1" }),
|
||||
revisions: [
|
||||
revision({ id: "rev-1", revisionNumber: 1, body: "## Old\nOld body", createdAt: "2026-07-13T17:10:00.000Z" }),
|
||||
revision({ id: "rev-2", revisionNumber: 2, body: "## Middle\nMiddle body", createdAt: "2026-07-14T09:15:00.000Z" }),
|
||||
revision({ id: "rev-3", revisionNumber: 3, body: "## Latest\nCurrent body", createdAt: "2026-07-14T17:10:00.000Z" }),
|
||||
revision({ id: "rev-1", revisionNumber: 1, body: "## Old\nOld body", createdAt: localAt(13, 17) }),
|
||||
revision({ id: "rev-2", revisionNumber: 2, body: "## Middle\nMiddle body", createdAt: localAt(14, 9) }),
|
||||
revision({ id: "rev-3", revisionNumber: 3, body: "## Latest\nCurrent body", createdAt: localAt(14, 17) }),
|
||||
],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,9 @@ function makeArtifact(overrides: Partial<CompanyArtifact> = {}): CompanyArtifact
|
|||
issue: { id: "issue-1", identifier: "PAP-10306", title: "Landing visuals" },
|
||||
project: { id: "proj-1", name: "Paperclip App" },
|
||||
createdByAgent: { id: "agent-1", name: "ClaudeCoder" },
|
||||
updatedAt: "2026-06-01T12:00:00.000Z",
|
||||
// Local, not UTC: the card renders "Last edited" from the local calendar
|
||||
// day, and noon UTC is already the 2nd at UTC+14.
|
||||
updatedAt: new Date(2026, 5, 1, 12, 0, 0, 0).toISOString(),
|
||||
href: "/issues/PAP-10306#attachment-art-1",
|
||||
...overrides,
|
||||
};
|
||||
|
|
@ -69,7 +71,7 @@ describe("ArtifactCard", () => {
|
|||
artifact={makeArtifact({
|
||||
title: "Social launch clip",
|
||||
issue: { id: "issue-2", identifier: "PAP-10370", title: "Make artifact page look like this" },
|
||||
updatedAt: "2025-10-08T12:00:00.000Z",
|
||||
updatedAt: new Date(2025, 9, 8, 12, 0, 0, 0).toISOString(),
|
||||
createdByAgent: null,
|
||||
})}
|
||||
/>,
|
||||
|
|
|
|||
|
|
@ -912,8 +912,11 @@ export const issueClosedRequestConfirmationInteraction =
|
|||
id: "interaction-confirmation-issue-closed",
|
||||
title: "Expired: confirm the migration cutover",
|
||||
status: "expired",
|
||||
resolvedAt: new Date("2026-04-20T15:12:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T15:12:00.000Z"),
|
||||
// Local, not UTC. The expiry footer renders this in the machine's timezone,
|
||||
// and 15:12Z on the 20th is already the 21st at UTC+9, which breaks the
|
||||
// "Apr 20" assertion in IssueThreadInteractionCard.test.tsx.
|
||||
resolvedAt: new Date(2026, 3, 20, 15, 12, 0, 0),
|
||||
updatedAt: new Date(2026, 3, 20, 15, 12, 0, 0),
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "issue_closed",
|
||||
|
|
|
|||
|
|
@ -430,14 +430,22 @@ describe("sortAttentionItems", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// `attentionDateBucket` walks back from the start of the *local* day
|
||||
// (`setHours(0, 0, 0, 0)`), so every date fixture below is local too. Pinned to
|
||||
// UTC instants they drift across the boundary under test: `2026-07-09T23:00:00Z`
|
||||
// is 08:00 on the 10th at UTC+9 and buckets as "today", and at UTC+14 and UTC-11
|
||||
// even the mid-morning fixtures land on the wrong calendar day.
|
||||
const localTime = (month: number, day: number, hour: number) =>
|
||||
new Date(2026, month - 1, day, hour, 0, 0, 0);
|
||||
|
||||
describe("attentionDateBucket", () => {
|
||||
const now = new Date("2026-07-10T12:00:00Z").getTime();
|
||||
const now = localTime(7, 10, 12).getTime();
|
||||
|
||||
it("buckets by rolling calendar-day windows relative to now", () => {
|
||||
expect(attentionDateBucket("2026-07-10T09:00:00Z", now)).toBe("today");
|
||||
expect(attentionDateBucket("2026-07-09T23:00:00Z", now)).toBe("yesterday");
|
||||
expect(attentionDateBucket("2026-07-06T09:00:00Z", now)).toBe("this_week");
|
||||
expect(attentionDateBucket("2026-06-01T09:00:00Z", now)).toBe("earlier");
|
||||
expect(attentionDateBucket(localTime(7, 10, 9).toISOString(), now)).toBe("today");
|
||||
expect(attentionDateBucket(localTime(7, 9, 23).toISOString(), now)).toBe("yesterday");
|
||||
expect(attentionDateBucket(localTime(7, 6, 9).toISOString(), now)).toBe("this_week");
|
||||
expect(attentionDateBucket(localTime(6, 1, 9).toISOString(), now)).toBe("earlier");
|
||||
});
|
||||
|
||||
it("treats invalid timestamps as earlier", () => {
|
||||
|
|
@ -446,7 +454,7 @@ describe("attentionDateBucket", () => {
|
|||
});
|
||||
|
||||
describe("groupAttentionItems", () => {
|
||||
const now = new Date("2026-07-10T12:00:00Z").getTime();
|
||||
const now = localTime(7, 10, 12).getTime();
|
||||
|
||||
it("leaves None as one unlabeled group that preserves caller sort order", () => {
|
||||
const items = sortAttentionItems(
|
||||
|
|
@ -464,9 +472,9 @@ describe("groupAttentionItems", () => {
|
|||
|
||||
it("groups by date into fixed Today/Yesterday/This week/Earlier order", () => {
|
||||
const items = [
|
||||
buildItem({ id: "earlier", activityAt: "2026-06-01T00:00:00Z" }),
|
||||
buildItem({ id: "today", activityAt: "2026-07-10T08:00:00Z" }),
|
||||
buildItem({ id: "yesterday", activityAt: "2026-07-09T08:00:00Z" }),
|
||||
buildItem({ id: "earlier", activityAt: localTime(6, 1, 9).toISOString() }),
|
||||
buildItem({ id: "today", activityAt: localTime(7, 10, 8).toISOString() }),
|
||||
buildItem({ id: "yesterday", activityAt: localTime(7, 9, 8).toISOString() }),
|
||||
];
|
||||
const groups = groupAttentionItems(items, "date", { now });
|
||||
expect(groups.map((g) => g.label)).toEqual(["Today", "Yesterday", "Earlier"]);
|
||||
|
|
@ -508,8 +516,8 @@ describe("groupAttentionItems", () => {
|
|||
it("preserves the caller-provided intra-group order (sort governs within a bucket)", () => {
|
||||
const items = sortAttentionItems(
|
||||
[
|
||||
buildItem({ id: "t1", activityAt: "2026-07-10T08:00:00Z" }),
|
||||
buildItem({ id: "t2", activityAt: "2026-07-10T10:00:00Z" }),
|
||||
buildItem({ id: "t1", activityAt: localTime(7, 10, 8).toISOString() }),
|
||||
buildItem({ id: "t2", activityAt: localTime(7, 10, 10).toISOString() }),
|
||||
],
|
||||
"newest",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ function update(overrides: Partial<StatusCardUpdate>): StatusCardUpdate {
|
|||
model: null,
|
||||
queryVersion: 1,
|
||||
changeSummary: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
startedAt: NOW.toISOString(),
|
||||
finishedAt: null,
|
||||
status: "ok",
|
||||
error: null,
|
||||
|
|
@ -30,18 +30,25 @@ function update(overrides: Partial<StatusCardUpdate>): StatusCardUpdate {
|
|||
};
|
||||
}
|
||||
|
||||
// A fixed instant rather than the real clock. `rollupUpdatesToday` filters on
|
||||
// the *UTC* day boundary, so a suite that builds its fixtures from `new Date()`
|
||||
// fails in two ways: it straddles midnight UTC if the run happens to cross it,
|
||||
// and in any zone east of UTC+12 "today at local noon" is already yesterday in
|
||||
// UTC, so the rows it means to count are filtered out. The function takes `now`
|
||||
// for exactly this reason — the sibling test below already passes one.
|
||||
// A fixed instant rather than the real clock, so these fixtures mean the same
|
||||
// thing on every machine and at every hour. `rollupUpdatesToday` filters on the
|
||||
// *UTC* day boundary, and building from `new Date()` fails against that in two
|
||||
// separate ways — see `iso` below. The function takes `now` for exactly this
|
||||
// reason, and the sibling test at the bottom of this file already passes one.
|
||||
const NOW = new Date("2026-07-23T12:00:00.000Z");
|
||||
|
||||
function iso(daysAgo: number): string {
|
||||
const d = new Date(NOW);
|
||||
d.setUTCDate(d.getUTCDate() - daysAgo);
|
||||
// Noon UTC, so a row is unambiguously inside the UTC day it belongs to.
|
||||
//
|
||||
// Built on the *local* day instead, `iso(0)` misses in both directions. West
|
||||
// of UTC it lands in the previous UTC day for the stretch between UTC
|
||||
// midnight and local midnight — about seven hours a day at UTC-7 — and east
|
||||
// of UTC+12 "today at local noon" is already yesterday in UTC outright.
|
||||
// Either way today's rows drop out of a filter that means to count them. A
|
||||
// run crossing midnight UTC splits the same way, with `iso(0)` and the
|
||||
// function's default `now` landing on different days.
|
||||
d.setUTCHours(12, 0, 0, 0);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,8 +31,12 @@ describe("formatReleaseDate", () => {
|
|||
});
|
||||
|
||||
it("collapses a full timestamp to its calendar day", () => {
|
||||
// Anchored to noon UTC so it stays on the 15th regardless of local offset.
|
||||
expect(formatReleaseDate("2026-07-15T12:00:00Z" as never)).toBe("2026-07-15");
|
||||
// Anchored to local noon. `formatReleaseDate` reads the *local* calendar
|
||||
// fields (getFullYear/getMonth/getDate), so a UTC anchor does not hold the
|
||||
// day regardless of offset as it once claimed here — noon UTC is 02:00 on
|
||||
// the 16th at UTC+14, and the assertion failed there.
|
||||
const localNoon = new Date(2026, 6, 15, 12, 0, 0, 0);
|
||||
expect(formatReleaseDate(localNoon.toISOString() as never)).toBe("2026-07-15");
|
||||
});
|
||||
|
||||
it("returns null for missing or unparseable input", () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue