Skip to content

Commit 64e06f4

Browse files
henrylove0claude
andauthored
fix(test): make day-bucketing tests timezone-hermetic (bug c0f5e80d) (#7)
exe-watcher buckets usage by the user's LOCAL calendar day (day-aggregator formatDate / plan-usage dayKey use getFullYear/ getMonth/getDate). Four tests hardcoded UTC "Z" timestamps and asserted fixed date strings, so a timestamp that is midday in UTC lands on the other side of local midnight in far timezones — the assertions read the runner's timezone, not the code. They passed on UTC CI runners and broke for anyone at UTC+14 (Pacific/Kiritimati) or UTC-12 (Etc/GMT+12): tests/day-aggregator.test.ts "buckets api calls by calendar date..." tests/day-aggregator.test.ts "counts a session under its firstTimestamp local date" tests/e2e-pipeline.test.ts "aggregates project sessions into daily entries..." tests/plan-usage.test.ts "projects using median daily spend (not mean)" The production code is correct; the assertions were stale. Fixes, test-only: - day-aggregator/e2e: derive expected dates from the production dateKey() formatter, and choose timestamps (same instant, or exactly 24h apart at noon) so the day structure is identical in every timezone. - plan-usage: build the fixture timestamps and `now` from local-noon Date(year, monthIndex, day, 12) so day-of-month is deterministic. No src/ change. Full suite: 598 passed under TZ=UTC, Pacific/Kiritimati (UTC+14) and Etc/GMT+12 (UTC-12); tsc --noEmit and tsup build clean. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7261d74 commit 64e06f4

3 files changed

Lines changed: 17 additions & 17 deletions

File tree

tests/day-aggregator.test.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from 'vitest'
22

3-
import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from '../src/day-aggregator.js'
3+
import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from '../src/day-aggregator.js'
44
import type { ProjectSummary } from '../src/types.js'
55

66
function makeProject(overrides: Partial<ProjectSummary> & { sessions: ProjectSummary['sessions'] }): ProjectSummary {
@@ -45,8 +45,8 @@ describe('aggregateProjectsIntoDays', () => {
4545
sessions: [{
4646
sessionId: 's1',
4747
project: 'p',
48-
firstTimestamp: '2026-04-09T10:00:00Z',
49-
lastTimestamp: '2026-04-10T08:00:00Z',
48+
firstTimestamp: '2026-04-09T12:00:00Z',
49+
lastTimestamp: '2026-04-10T12:00:00Z',
5050
totalCostUSD: 10,
5151
totalInputTokens: 0,
5252
totalOutputTokens: 0,
@@ -56,14 +56,14 @@ describe('aggregateProjectsIntoDays', () => {
5656
turns: [
5757
{
5858
userMessage: 'hi',
59-
timestamp: '2026-04-09T10:00:00Z',
59+
timestamp: '2026-04-09T12:00:00Z',
6060
sessionId: 's1',
6161
category: 'coding',
6262
retries: 0,
6363
hasEdits: true,
6464
assistantCalls: [
65-
makeCall('2026-04-09T10:00:00Z', 4),
66-
makeCall('2026-04-10T08:00:00Z', 6),
65+
makeCall('2026-04-09T12:00:00Z', 4),
66+
makeCall('2026-04-10T12:00:00Z', 6),
6767
],
6868
},
6969
],
@@ -77,7 +77,7 @@ describe('aggregateProjectsIntoDays', () => {
7777
]
7878

7979
const days = aggregateProjectsIntoDays(projects)
80-
expect(days.map(d => d.date)).toEqual(['2026-04-09', '2026-04-10'])
80+
expect(days.map(d => d.date)).toEqual([dateKey('2026-04-09T12:00:00Z'), dateKey('2026-04-10T12:00:00Z')])
8181
expect(days[0]!.cost).toBe(4)
8282
expect(days[0]!.calls).toBe(1)
8383
expect(days[1]!.cost).toBe(6)
@@ -130,7 +130,7 @@ describe('aggregateProjectsIntoDays', () => {
130130
})
131131

132132
it('counts a session under its firstTimestamp local date', () => {
133-
// Use a midday timestamp so the local date is the same regardless of timezone
133+
// Derive the expected date from the production formatter (dateKey) so this holds in any timezone
134134
const projects: ProjectSummary[] = [
135135
makeProject({
136136
sessions: [{
@@ -148,7 +148,7 @@ describe('aggregateProjectsIntoDays', () => {
148148
}),
149149
]
150150
const days = aggregateProjectsIntoDays(projects)
151-
expect(days[0]!.date).toBe('2026-04-09')
151+
expect(days[0]!.date).toBe(dateKey('2026-04-09T12:00:00Z'))
152152
expect(days[0]!.sessions).toBe(1)
153153
})
154154

tests/e2e-pipeline.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { mkdtemp, readFile, readdir, rm } from 'fs/promises'
33
import { join } from 'path'
44
import { tmpdir } from 'os'
55

6-
import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from '../src/day-aggregator.js'
6+
import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from '../src/day-aggregator.js'
77
import { exportCsv, exportJson, type PeriodExport } from '../src/export.js'
88
import { buildMenubarPayload, type PeriodData, type ProviderCost } from '../src/menubar-json.js'
99
import {
@@ -178,11 +178,11 @@ afterEach(async () => {
178178
describe('Parse -> Aggregate -> PeriodData pipeline', () => {
179179
it('aggregates project sessions into daily entries and builds correct PeriodData', () => {
180180
const turns1 = [
181-
makeTurn('2026-04-20T10:00:00Z', 5.0, { category: 'building', hasEdits: true }),
182-
makeTurn('2026-04-20T14:00:00Z', 3.0, { category: 'debugging', hasEdits: true, retries: 1 }),
181+
makeTurn('2026-04-20T12:00:00Z', 5.0, { category: 'building', hasEdits: true }),
182+
makeTurn('2026-04-20T12:00:00Z', 3.0, { category: 'debugging', hasEdits: true, retries: 1 }),
183183
]
184184
const turns2 = [
185-
makeTurn('2026-04-21T09:00:00Z', 7.0, { category: 'building', model: 'gpt-5', provider: 'codex' }),
185+
makeTurn('2026-04-21T12:00:00Z', 7.0, { category: 'building', model: 'gpt-5', provider: 'codex' }),
186186
]
187187

188188
const sess1 = makeSession('sess-1', '/proj/alpha', turns1)
@@ -192,8 +192,8 @@ describe('Parse -> Aggregate -> PeriodData pipeline', () => {
192192
const days = aggregateProjectsIntoDays([project])
193193

194194
expect(days).toHaveLength(2)
195-
expect(days[0]!.date).toBe('2026-04-20')
196-
expect(days[1]!.date).toBe('2026-04-21')
195+
expect(days[0]!.date).toBe(dateKey('2026-04-20T12:00:00Z'))
196+
expect(days[1]!.date).toBe(dateKey('2026-04-21T12:00:00Z'))
197197

198198
expect(days[0]!.cost).toBe(8)
199199
expect(days[0]!.calls).toBe(2)

tests/plan-usage.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ describe('getPlanUsage', () => {
6767
it('projects using median daily spend (not mean)', async () => {
6868
const dailyCosts = [1, 100, 1, 100, 1, 100, 1]
6969
const turns = dailyCosts.map((cost, idx) => ({
70-
timestamp: `2026-04-${String(idx + 1).padStart(2, '0')}T12:00:00.000Z`,
70+
timestamp: new Date(2026, 3, idx + 1, 12, 0, 0).toISOString(),
7171
assistantCalls: [{ costUSD: cost }],
7272
}))
7373

@@ -84,7 +84,7 @@ describe('getPlanUsage', () => {
8484
provider: 'all',
8585
resetDay: 1,
8686
setAt: '2026-04-01T00:00:00.000Z',
87-
}, new Date('2026-04-07T12:00:00.000Z'))
87+
}, new Date(2026, 3, 7, 12, 0, 0))
8888

8989
// Median(1,100,1,100,1,100,1) = 1, so remaining 23 days adds 23.
9090
expect(Math.round(usage.projectedMonthUsd)).toBe(327)

0 commit comments

Comments
 (0)