From 88e14d262257c45fd5500972714446d4dacd9848 Mon Sep 17 00:00:00 2001 From: Evgenii Date: Sun, 2 Aug 2026 20:30:54 +0700 Subject: [PATCH 1/3] fix(i18n): format the stats date with the selected UI language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `formatTrackingSince()` builds its date with `toLocaleDateString(undefined, …)`, which follows the OS locale rather than the language chosen in Settings. On a machine set to en-US the "Tracking since …" header stays English even when the rest of the pane is translated. Added `getIntlLocale()` and used it for that call. The helper handles the case that makes this non-trivial: plugin language packs register under the synthetic `plugin` resource language from `pluginLanguageResourceId()`, which `Intl` rejects outright, so it resolves to the BCP-47 tag the pack declares and falls back to the default locale when `Intl` has no data for the tag. This was reviewed and confirmed in #11826 but did not survive the merge, so it is re-sent on its own. --- .../src/components/stats/StatsPane.tsx | 4 ++-- src/renderer/src/i18n/i18n.ts | 18 +++++++++++++++ src/renderer/src/i18n/intl-locale.test.ts | 23 +++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/i18n/intl-locale.test.ts diff --git a/src/renderer/src/components/stats/StatsPane.tsx b/src/renderer/src/components/stats/StatsPane.tsx index 002a2dca4ae7..4d62ad89bb59 100644 --- a/src/renderer/src/components/stats/StatsPane.tsx +++ b/src/renderer/src/components/stats/StatsPane.tsx @@ -15,7 +15,7 @@ import { DropdownMenuTrigger } from '../ui/dropdown-menu' import { AgentIcon } from '@/lib/agent-catalog' -import { translate } from '@/i18n/i18n' +import { getIntlLocale, translate } from '@/i18n/i18n' export { getStatsPaneSearchEntries } from './stats-search' function formatDuration(ms: number): string { @@ -44,7 +44,7 @@ function formatTrackingSince(timestamp: number | null): string { } const date = new Date(timestamp) return translate('auto.components.stats.StatsPane.trackingSince', 'Tracking since {{value0}}', { - value0: date.toLocaleDateString(undefined, { + value0: date.toLocaleDateString(getIntlLocale(), { month: 'short', day: 'numeric', year: 'numeric' diff --git a/src/renderer/src/i18n/i18n.ts b/src/renderer/src/i18n/i18n.ts index 3f35e2ec2244..0d5cbad54cfa 100644 --- a/src/renderer/src/i18n/i18n.ts +++ b/src/renderer/src/i18n/i18n.ts @@ -95,6 +95,24 @@ export async function setRendererUiLanguage(language: UiLanguage): Promise const registeredPluginLanguages = new Set() let pluginLanguagePacks: readonly PluginLanguagePackRegistration[] = [] +/** + * BCP-47 tag for `Intl` formatting. Plugin catalogs register under a synthetic + * `plugin` resource language that `Intl` rejects, so fall back to the tag + * the pack declares (for example `ru-RU`) and finally to the default locale. + */ +export function getIntlLocale(): string { + const active = i18n.language + const pack = pluginLanguagePacks.find((entry) => entry.resourceLanguage === active) + const candidate = pack?.locale ?? active + try { + // Why: an empty result means Intl has no data for the tag, so fall through + // to the default locale instead of letting Intl pick the runtime one. + return Intl.DateTimeFormat.supportedLocalesOf(candidate)[0] ?? DEFAULT_LOCALE + } catch { + return DEFAULT_LOCALE + } +} + export function resolveRendererResourceLanguage(language: string): string { return pluginLanguagePacks.find((pack) => pack.id === language)?.resourceLanguage ?? language } diff --git a/src/renderer/src/i18n/intl-locale.test.ts b/src/renderer/src/i18n/intl-locale.test.ts new file mode 100644 index 000000000000..a02707e349e2 --- /dev/null +++ b/src/renderer/src/i18n/intl-locale.test.ts @@ -0,0 +1,23 @@ +/** + * `getIntlLocale()` exists because plugin catalogs register under a synthetic + * `plugin` resource language. Passing that straight to `Intl` throws, and + * passing `undefined` silently falls back to the OS locale rather than the + * language the user selected. + */ +import { describe, expect, it } from 'vitest' +import { DEFAULT_LOCALE } from './supported-languages' + +describe('Intl locale resolution', () => { + it('rejects the synthetic plugin resource language', () => { + // Guards the reason the helper exists: Intl cannot consume the tag directly. + expect(() => Intl.DateTimeFormat.supportedLocalesOf('plugin00720075')).toThrow(RangeError) + }) + + it('resolves a declared pack locale that Intl understands', () => { + expect(Intl.DateTimeFormat.supportedLocalesOf('ru-RU')[0]).toBe('ru-RU') + }) + + it('has a default locale Intl can format with', () => { + expect(Intl.DateTimeFormat.supportedLocalesOf(DEFAULT_LOCALE)[0]).toBe(DEFAULT_LOCALE) + }) +}) From e1efc59f1995046aaec2a711e005320c5e83d966 Mon Sep 17 00:00:00 2001 From: Evgenii Date: Sun, 2 Aug 2026 21:15:28 +0700 Subject: [PATCH 2/3] test(i18n): exercise getIntlLocale directly instead of only its preconditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite pinned the Intl behaviour that motivates the helper but never called it, so a regression in the pack lookup or the supportedLocalesOf guard would have gone unnoticed. Drives the function through the public API — registering a pack and switching languages — rather than mocking module internals, so the plugin path is covered end to end. --- src/renderer/src/i18n/intl-locale.test.ts | 59 +++++++++++++++++++---- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/i18n/intl-locale.test.ts b/src/renderer/src/i18n/intl-locale.test.ts index a02707e349e2..54d531c1cf82 100644 --- a/src/renderer/src/i18n/intl-locale.test.ts +++ b/src/renderer/src/i18n/intl-locale.test.ts @@ -4,20 +4,61 @@ * passing `undefined` silently falls back to the OS locale rather than the * language the user selected. */ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' +import { getIntlLocale, i18n, setRendererPluginLanguagePacks } from './i18n' +import { pluginLanguageResourceId } from '../../../shared/plugins/plugin-language-pack-artifact' import { DEFAULT_LOCALE } from './supported-languages' -describe('Intl locale resolution', () => { - it('rejects the synthetic plugin resource language', () => { - // Guards the reason the helper exists: Intl cannot consume the tag directly. - expect(() => Intl.DateTimeFormat.supportedLocalesOf('plugin00720075')).toThrow(RangeError) +const PACK_ID = 'plugin:smwbev.russian/ru-RU' as const +const PACK_RESOURCE = pluginLanguageResourceId(PACK_ID) + +async function activate( + language: string, + packs: Parameters[0] = [] +) { + setRendererPluginLanguagePacks(packs) + await i18n.changeLanguage(language) +} + +afterEach(async () => { + setRendererPluginLanguagePacks([]) + await i18n.changeLanguage(DEFAULT_LOCALE) +}) + +describe('getIntlLocale', () => { + it('passes a built-in locale straight through', async () => { + await activate('es') + expect(getIntlLocale()).toBe('es') + }) + + it('resolves a plugin resource language to the locale the pack declares', async () => { + await activate(PACK_RESOURCE, [ + { + id: PACK_ID, + resourceLanguage: PACK_RESOURCE, + pluginKey: 'smwbev.russian', + locale: 'ru-RU', + catalog: {} + } + ]) + // Without the lookup this would reach Intl as `plugin` and throw. + expect(getIntlLocale()).toBe('ru-RU') + }) + + it('falls back to the default locale when Intl has no data for the tag', async () => { + // A syntactically valid tag Intl does not carry data for: the guard must not + // return it, otherwise Intl silently formats with the runtime locale. + await activate('qaa') + expect(getIntlLocale()).toBe(DEFAULT_LOCALE) }) - it('resolves a declared pack locale that Intl understands', () => { - expect(Intl.DateTimeFormat.supportedLocalesOf('ru-RU')[0]).toBe('ru-RU') + it('falls back to the default locale for a tag Intl rejects outright', async () => { + await activate(PACK_RESOURCE) + expect(getIntlLocale()).toBe(DEFAULT_LOCALE) }) - it('has a default locale Intl can format with', () => { - expect(Intl.DateTimeFormat.supportedLocalesOf(DEFAULT_LOCALE)[0]).toBe(DEFAULT_LOCALE) + it('keeps the synthetic resource language unusable for Intl directly', () => { + // Guards the reason the helper exists at all. + expect(() => Intl.DateTimeFormat.supportedLocalesOf(PACK_RESOURCE)).toThrow(RangeError) }) }) From 2b57d2660dfaa358a7c1a4ccdc62d37792543f32 Mon Sep 17 00:00:00 2001 From: Evgenii Date: Sun, 2 Aug 2026 21:37:10 +0700 Subject: [PATCH 3/3] test(i18n): stub ICU lookup so the locale branches are deterministic supportedLocalesOf() answers from whatever ICU data the runtime carries, so asserting on real tags made the branch coverage depend on the build: a small-icu Node would fail the passthrough case for reasons unrelated to the helper. The branch assertions now stub the lookup and describe the logic instead. The RangeError case keeps its unstubbed assertion, since rejecting the synthetic tag is a property of the tag itself rather than of ICU data. --- src/renderer/src/i18n/intl-locale.test.ts | 59 ++++++++++++++--------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/src/renderer/src/i18n/intl-locale.test.ts b/src/renderer/src/i18n/intl-locale.test.ts index 54d531c1cf82..69f0de084555 100644 --- a/src/renderer/src/i18n/intl-locale.test.ts +++ b/src/renderer/src/i18n/intl-locale.test.ts @@ -3,62 +3,77 @@ * `plugin` resource language. Passing that straight to `Intl` throws, and * passing `undefined` silently falls back to the OS locale rather than the * language the user selected. + * + * The branch assertions stub `supportedLocalesOf` so they describe the helper's + * logic rather than whichever locales the runtime's ICU build happens to carry. */ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { getIntlLocale, i18n, setRendererPluginLanguagePacks } from './i18n' import { pluginLanguageResourceId } from '../../../shared/plugins/plugin-language-pack-artifact' import { DEFAULT_LOCALE } from './supported-languages' const PACK_ID = 'plugin:smwbev.russian/ru-RU' as const const PACK_RESOURCE = pluginLanguageResourceId(PACK_ID) +const PACK = { + id: PACK_ID, + resourceLanguage: PACK_RESOURCE, + pluginKey: 'smwbev.russian', + locale: 'ru-RU', + catalog: {} +} + +type Packs = Parameters[0] -async function activate( - language: string, - packs: Parameters[0] = [] -) { +async function activate(language: string, packs: Packs = []): Promise { setRendererPluginLanguagePacks(packs) await i18n.changeLanguage(language) } +/** Stubs ICU lookup so a locale counts as supported only when listed. */ +function withSupportedLocales(supported: readonly string[]): void { + vi.spyOn(Intl.DateTimeFormat, 'supportedLocalesOf').mockImplementation((requested) => { + const tags = Array.isArray(requested) ? requested : [requested as string] + return tags.filter((tag) => supported.includes(tag)) as string[] + }) +} + afterEach(async () => { + vi.restoreAllMocks() setRendererPluginLanguagePacks([]) await i18n.changeLanguage(DEFAULT_LOCALE) }) describe('getIntlLocale', () => { - it('passes a built-in locale straight through', async () => { + it('passes a supported built-in locale straight through', async () => { + withSupportedLocales(['es']) await activate('es') expect(getIntlLocale()).toBe('es') }) it('resolves a plugin resource language to the locale the pack declares', async () => { - await activate(PACK_RESOURCE, [ - { - id: PACK_ID, - resourceLanguage: PACK_RESOURCE, - pluginKey: 'smwbev.russian', - locale: 'ru-RU', - catalog: {} - } - ]) - // Without the lookup this would reach Intl as `plugin` and throw. + withSupportedLocales(['ru-RU']) + await activate(PACK_RESOURCE, [PACK]) + // Without the pack lookup this would reach Intl as `plugin`. expect(getIntlLocale()).toBe('ru-RU') }) - it('falls back to the default locale when Intl has no data for the tag', async () => { - // A syntactically valid tag Intl does not carry data for: the guard must not - // return it, otherwise Intl silently formats with the runtime locale. - await activate('qaa') + it('falls back to the default locale when ICU has no data for the tag', async () => { + withSupportedLocales([]) + await activate('es') + // Returning the tag here would let Intl silently format with the runtime locale. expect(getIntlLocale()).toBe(DEFAULT_LOCALE) }) - it('falls back to the default locale for a tag Intl rejects outright', async () => { + it('falls back to the default locale when Intl rejects the tag', async () => { + vi.spyOn(Intl.DateTimeFormat, 'supportedLocalesOf').mockImplementation(() => { + throw new RangeError('invalid language tag') + }) await activate(PACK_RESOURCE) expect(getIntlLocale()).toBe(DEFAULT_LOCALE) }) it('keeps the synthetic resource language unusable for Intl directly', () => { - // Guards the reason the helper exists at all. + // Unstubbed on purpose: this is a property of the tag, not of ICU data. expect(() => Intl.DateTimeFormat.supportedLocalesOf(PACK_RESOURCE)).toThrow(RangeError) }) })