From a7a14ea77fddf14d5cae3653bcc5038b1abe62d7 Mon Sep 17 00:00:00 2001 From: Mia Date: Sat, 19 Sep 2026 04:25:55 -0400 Subject: [PATCH] test: keep the client suite independent of the host locale Two client tests assert English output from code that formats in the host's default locale, which is right for the app but fails the suite on a developer machine set to another language. `npm run test:client` on a clean checkout: LC_ALL=fr_FR.UTF-8 3 failed LC_ALL=de_DE.UTF-8 4 failed LC_ALL= (empty) 2 failed Intl resolves `und` LC_ALL=en_US.UTF-8 0 failed - SessionPicker: `relativeTime` uses `Intl.RelativeTimeFormat(undefined, ...)` and its tests match /day/, /second/. Under fr_FR two days ago is 'avant-hier', under de_DE 'vorgestern', under `und` '-2 d'. The function already takes `now` so its tests are deterministic; it now takes an optional `locale` for the same reason, and the tests pass 'en'. The app passes none. - AgentChatView: the context and token titles use `toLocaleString()`, and the tests expected '50,000 of 200,000' and '12,500 tokens'. Under de_DE those are '50.000' and '12.500'; under fr_FR the groups are separated by U+202F. The expected text is now built with `toLocaleString()`, as AnnotationMarker's test already does. With both, the client suite passes 803/803 under all four settings above. --- src/components/AgentChatView.client.test.tsx | 11 +++++++---- src/components/SessionPicker.client.test.tsx | 10 ++++++---- src/components/SessionPicker.tsx | 7 ++++--- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/components/AgentChatView.client.test.tsx b/src/components/AgentChatView.client.test.tsx index f9f3bde9..bb782a8f 100644 --- a/src/components/AgentChatView.client.test.tsx +++ b/src/components/AgentChatView.client.test.tsx @@ -123,6 +123,9 @@ afterEach(() => { container.remove(); }); +// Exact counts are formatted in the host's locale, so the expected text is too. +const n = (value: number) => value.toLocaleString(); + describe('Codex chat view', () => { it('shows context capacity, remaining tokens, and over-limit usage independently of session totals', async () => { dispose = render(() => , container); @@ -132,7 +135,7 @@ describe('Codex chat view', () => { expect(container.querySelector('.codex-chat-context')?.textContent).toContain('Context —'); mocks.channel?.onmessage?.(state({ contextUsage: { usedTokens: 50000, maxTokens: 200000 } })); expect(meter()?.textContent).toContain('Context 25% · 150K left'); - expect(meter()?.title).toContain('50,000 of 200,000'); + expect(meter()?.title).toContain(`${n(50_000)} of ${n(200_000)}`); expect(meter()?.getAttribute('aria-valuenow')).toBe('50000'); mocks.channel?.onmessage?.(state({ contextUsage: { usedTokens: 190000, maxTokens: 200000 } })); expect(meter()?.dataset.level).toBe('high'); @@ -140,7 +143,7 @@ describe('Codex chat view', () => { expect(meter()?.textContent).toContain('Context 105% · 0 left'); expect(meter()?.dataset.level).toBe('full'); expect(meter()?.getAttribute('aria-valuenow')).toBe('200000'); - expect(meter()?.getAttribute('aria-valuetext')).toContain('210,000 of 200,000'); + expect(meter()?.getAttribute('aria-valuetext')).toContain(`${n(210_000)} of ${n(200_000)}`); mocks.channel?.onmessage?.(state({ contextUsage: { usedTokens: 0, maxTokens: 200000 } })); expect(meter()?.textContent).toContain('Context 0% · 200K left'); mocks.channel?.onmessage?.(state({ threadId: 'new-session' })); @@ -163,8 +166,8 @@ describe('Codex chat view', () => { }), ); expect(counter()?.textContent).toContain('12.5K tokens'); - expect(counter()?.title).toContain('12,500 tokens'); - expect(counter()?.title).toContain('12,000 input'); + expect(counter()?.title).toContain(`${n(12_500)} tokens`); + expect(counter()?.title).toContain(`${n(12_000)} input`); mocks.channel?.onmessage?.( state({ tokenUsage: { totalTokens: 0, inputTokens: 0, outputTokens: 0, scope: 'connection' }, diff --git a/src/components/SessionPicker.client.test.tsx b/src/components/SessionPicker.client.test.tsx index c6cba8f6..e58fc56b 100644 --- a/src/components/SessionPicker.client.test.tsx +++ b/src/components/SessionPicker.client.test.tsx @@ -151,14 +151,16 @@ describe('SessionPicker', () => { describe('relativeTime', () => { const now = Date.parse('2026-09-17T12:00:00Z'); + // The assertions match English wording. + const ago = (iso: string) => relativeTime(Date.parse(iso), now, 'en'); it('picks the largest fitting unit', () => { - expect(relativeTime(Date.parse('2026-09-15T12:00:00Z'), now)).toMatch(/day/); - expect(relativeTime(Date.parse('2026-09-17T09:00:00Z'), now)).toMatch(/hour/); - expect(relativeTime(Date.parse('2026-09-17T11:30:00Z'), now)).toMatch(/minute/); + expect(ago('2026-09-15T12:00:00Z')).toMatch(/day/); + expect(ago('2026-09-17T09:00:00Z')).toMatch(/hour/); + expect(ago('2026-09-17T11:30:00Z')).toMatch(/minute/); }); it('falls through to seconds for a very recent session', () => { - expect(relativeTime(Date.parse('2026-09-17T11:59:50Z'), now)).toMatch(/second/); + expect(ago('2026-09-17T11:59:50Z')).toMatch(/second/); }); }); diff --git a/src/components/SessionPicker.tsx b/src/components/SessionPicker.tsx index ffb350a4..3968a235 100644 --- a/src/components/SessionPicker.tsx +++ b/src/components/SessionPicker.tsx @@ -6,9 +6,10 @@ import { canResumeSessionId } from '../../electron/shared/session-resume'; import type { SessionRecord } from '../../electron/shared/session-record'; /** Relative age of a session, via the platform formatter rather than a table - * of thresholds. Exported for its test. */ -export function relativeTime(epochMs: number, now = Date.now()): string { - const format = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }); + * of thresholds. Exported for its test, which pins `locale` so the wording it + * checks does not depend on the host's default. */ +export function relativeTime(epochMs: number, now = Date.now(), locale?: string): string { + const format = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); const seconds = Math.round((epochMs - now) / 1000); const units: [Intl.RelativeTimeFormatUnit, number][] = [ ['day', 86_400],