diff --git a/config/vitest.config.ts b/config/vitest.config.ts index a590717b7e6..fb2b889a7dc 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { resolve } from 'node:path' import { defaultExclude, defineConfig } from 'vitest/config' @@ -15,6 +16,20 @@ const paletteMeasurementFile = 'src/renderer/src/lib/palette-match/palette-match-performance.test.ts' const measurementFiles = [measurementFile, paletteMeasurementFile] const transforms = { + plugins: [ + { + // Unit tests have no asset server; retain real catalog parsing through fetch(data:). + name: 'locale-catalog-test-assets', + enforce: 'pre' as const, + load(id: string) { + if (/\/i18n\/locales\/[a-z]+\.json\?url$/.test(id)) { + const catalog = readFileSync(id.slice(0, -4)).toString('base64') + return `export default ${JSON.stringify(`data:application/json;base64,${catalog}`)}` + } + return null + } + } + ], define: { ORCA_FEATURE_WALL_ENABLED: 'true', // Why: tests only interpolate it; they must not depend on the checkout's HEAD. diff --git a/src/renderer/src/i18n/i18n.ts b/src/renderer/src/i18n/i18n.ts index fae597a0386..fea40f57f2d 100644 --- a/src/renderer/src/i18n/i18n.ts +++ b/src/renderer/src/i18n/i18n.ts @@ -7,6 +7,12 @@ import i18next, { import { initReactI18next } from 'react-i18next' import enRuntimeRequired from './en-runtime-required.json' +import esCatalogUrl from './locales/es.json?url' +import frCatalogUrl from './locales/fr.json?url' +import jaCatalogUrl from './locales/ja.json?url' +import koCatalogUrl from './locales/ko.json?url' +import ptCatalogUrl from './locales/pt.json?url' +import zhCatalogUrl from './locales/zh.json?url' import { isPseudoLocalizationLocale, pseudoLocalizeString } from './pseudo-localization' import { advanceTranslationResourceVersion } from './translation-resource-version' import { DEFAULT_LOCALE, resolveUiLocale } from './supported-languages' @@ -17,37 +23,41 @@ import { withTimeout } from '../../../shared/promise-timeout-fallback' export const i18n: I18nInstance = i18next.createInstance() -// Why: only the English catalog is bundled eagerly. The other four locales add -// ~2MB to the renderer's startup chunk (parsed on every launch) even though the -// app always boots in English and only switches after the persisted UI language -// loads. A lazy backend fetches each non-English catalog on demand, so any -// changeLanguage() call (UI switch or test) transparently loads its bundle -// instead of paying the parse cost at cold start. -const NON_DEFAULT_LOCALE_LOADERS: Record< - Exclude, - () => Promise<{ default: Record }> -> = { - es: () => import('./locales/es.json'), - fr: () => import('./locales/fr.json'), - ja: () => import('./locales/ja.json'), - ko: () => import('./locales/ko.json'), - pt: () => import('./locales/pt.json'), - zh: () => import('./locales/zh.json') +// Fetch catalogs as data: Chromium caches rejected module imports for the document's lifetime. +const NON_DEFAULT_LOCALE_URLS: Record, string> = { + es: esCatalogUrl, + fr: frCatalogUrl, + ja: jaCatalogUrl, + ko: koCatalogUrl, + pt: ptCatalogUrl, + zh: zhCatalogUrl +} + +function localeCatalogUrl(language: string): string | undefined { + return Object.entries(NON_DEFAULT_LOCALE_URLS).find(([name]) => name === language)?.[1] +} + +async function loadLocaleCatalog(url: string): Promise> { + const response = await fetch(url, { cache: 'no-cache' }) + if (!response.ok) { + throw new Error(`Language catalog request failed: ${response.status}`) + } + return response.json() } const lazyLocaleBackend: BackendModule = { type: 'backend', init: () => {}, read: (language: string, _namespace: string, callback: ReadCallback) => { - const loader = NON_DEFAULT_LOCALE_LOADERS[language as Exclude] - if (!loader) { + const url = localeCatalogUrl(language) + if (!url) { // English (and unknown locales) are served from bundled resources; signal // "nothing to load" so i18next falls back to the in-memory catalog. callback(null, false) return } - loader().then( - (mod) => callback(null, mod.default), + loadLocaleCatalog(url).then( + (catalog) => callback(null, catalog), (error) => callback(error instanceof Error ? error : new Error(String(error)), false) ) } @@ -101,13 +111,13 @@ export async function loadRendererUiLanguage(language: UiLanguage): Promise name === locale)?.[1] - if (loader && !i18n.hasResourceBundle(locale, 'translation')) { - const catalog = await withTimeout(loader(), 5000, null) + const url = localeCatalogUrl(locale) + if (url && !i18n.hasResourceBundle(locale, 'translation')) { + const catalog = await withTimeout(loadLocaleCatalog(url), 5000, null) if (catalog === null) { throw new Error('Language catalog unavailable') } - i18n.addResourceBundle(locale, 'translation', catalog.default, true, true) + i18n.addResourceBundle(locale, 'translation', catalog, true, true) } return locale } diff --git a/src/renderer/src/i18n/language-catalog-timeout.test.tsx b/src/renderer/src/i18n/language-catalog-timeout.test.tsx index 8a41a644edf..d0578d7c304 100644 --- a/src/renderer/src/i18n/language-catalog-timeout.test.tsx +++ b/src/renderer/src/i18n/language-catalog-timeout.test.tsx @@ -10,15 +10,6 @@ import { I18nProvider } from './I18nProvider' import { i18n } from './i18n' import { useLanguageReadiness } from './language-readiness' -const { catalog } = vi.hoisted(() => { - let finish: (value: { default: Record }) => void = () => {} - const promise = new Promise<{ default: Record }>((resolve) => { - finish = resolve - }) - return { catalog: { promise, finish } } -}) -vi.mock('./locales/fr.json', () => catalog.promise) - afterEach(() => { cleanup() useAppStore.setState(useAppStore.getInitialState(), true) @@ -30,6 +21,19 @@ it('releases onboarding in English when a catalog stalls, ignores its late resul vi.useFakeTimers() vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) Object.defineProperty(window, 'api', { configurable: true, value: {} }) + let finishCatalog: (response: Response) => void = () => {} + const catalog = new Promise((resolve) => { + finishCatalog = resolve + }) + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockReturnValueOnce(catalog) + .mockImplementation(() => + Promise.resolve(new Response(JSON.stringify({ menu: { file: 'Fichier' } }))) + ) + ) usePluginLanguagePackStore.setState({ entries: [], loaded: true }) useAppStore.setState({ settings: { ...getDefaultSettings('/tmp'), uiLanguage: 'fr' } }) useLanguageReadiness.setState({ ready: false, error: false }) @@ -53,8 +57,8 @@ it('releases onboarding in English when a catalog stalls, ignores its late resul expect(useAppStore.getState().settings?.uiLanguage).toBe('fr') expect(i18n.language).toBe('en') await act(async () => { - catalog.finish({ default: { menu: { file: 'Fichier' } } }) - await import('./locales/fr.json') + finishCatalog(new Response(JSON.stringify({ menu: { file: 'Fichier' } }))) + await catalog await vi.advanceTimersByTimeAsync(0) }) expect(i18n.language).toBe('en') diff --git a/src/renderer/src/i18n/lazy-locale.test.ts b/src/renderer/src/i18n/lazy-locale.test.ts index 494ab9aff19..a8570e0ba2d 100644 --- a/src/renderer/src/i18n/lazy-locale.test.ts +++ b/src/renderer/src/i18n/lazy-locale.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { UI_LANGUAGE_CHINESE, @@ -44,6 +44,33 @@ describe('renderer i18n lazy locale loading', () => { expect(i18n.t('menu.file', { defaultValue: 'File' })).not.toBe('File') }) + it.each(['network', 'http', 'invalid-json'] as const)( + 'retries a rejected %s catalog load with a fresh request', + async (failure) => { + i18n.removeResourceBundle('ja', 'translation') + const request = vi.spyOn(globalThis, 'fetch') + if (failure === 'network') { + request.mockRejectedValueOnce(new TypeError('Failed to fetch')) + } else { + request.mockResolvedValueOnce( + failure === 'http' ? new Response('', { status: 503 }) : new Response('{') + ) + } + try { + await expect(setRendererUiLanguage('ja')).rejects.toThrow('Language catalog unavailable') + expect(i18n.language).toBe('en') + expect(i18n.hasResourceBundle('ja', 'translation')).toBe(false) + + await setRendererUiLanguage('ja') + expect(i18n.language).toBe('ja') + expect(i18n.t('menu.file', { defaultValue: 'File' })).toBe('ファイル') + expect(request).toHaveBeenCalledTimes(2) + } finally { + request.mockRestore() + } + } + ) + it('uses the inline English default when a target catalog omits a key', async () => { await setRendererUiLanguage(UI_LANGUAGE_SPANISH) expect(i18n.t('missing.renderer.feature', { defaultValue: 'English fallback' })).toBe( diff --git a/tests/e2e/onboarding-language-catalog-retry.spec.ts b/tests/e2e/onboarding-language-catalog-retry.spec.ts new file mode 100644 index 00000000000..624ee4cab05 --- /dev/null +++ b/tests/e2e/onboarding-language-catalog-retry.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from './helpers/orca-app' + +test.use({ dismissOnboarding: false, seedTestRepo: false }) + +test('Retry recovers a rejected language catalog request without reloading', async ({ + electronApp, + orcaPage +}, testInfo) => { + await orcaPage.evaluate(async () => { + const store = window.__store + if (!store) { + throw new Error('Renderer store unavailable') + } + const settings = await window.api.settings.get() + if (!settings.telemetry) { + throw new Error('Telemetry settings unavailable') + } + await store.getState().updateSettingsOrThrow({ + uiLanguage: 'en', + telemetry: { ...settings.telemetry, onboardingLanguageCohort: 'treatment' } + }) + }) + await orcaPage.getByRole('button', { name: 'Switch language' }).click() + await expect(orcaPage.getByRole('radio', { name: '日本語', exact: true })).toBeVisible() + + await electronApp.evaluate(({ session }) => { + session.defaultSession.webRequest.onBeforeRequest((details, callback) => { + callback({ cancel: /\/ja-[^/]+\.(?:json|js)(?:\?|$)/.test(details.url) }) + }) + }) + const timeOrigin = await orcaPage.evaluate(() => performance.timeOrigin) + const rejected = orcaPage.waitForEvent('requestfailed', { + predicate: (request) => /\/ja-[^/]+\.(?:json|js)(?:\?|$)/.test(request.url()) + }) + await orcaPage.getByRole('radio', { name: '日本語', exact: true }).click() + const failedRequest = await rejected + await expect(orcaPage.getByRole('alert')).toContainText('Could not load this language') + await expect(orcaPage.getByRole('radio', { name: 'English', exact: true })).toBeChecked() + await orcaPage.screenshot({ path: testInfo.outputPath('catalog-request-rejected.png') }) + + await electronApp.evaluate(({ session }) => { + session.defaultSession.webRequest.onBeforeRequest(null) + }) + expect( + await orcaPage.evaluate(async (url) => (await fetch(url)).status, failedRequest.url()) + ).toBe(200) + await orcaPage.getByRole('button', { name: 'Retry', exact: true }).click() + + await expect(orcaPage.getByRole('radio', { name: '日本語', exact: true })).toBeChecked() + await expect(orcaPage.getByRole('radiogroup', { name: '表示言語', exact: true })).toBeVisible() + await expect(orcaPage.getByRole('alert')).toHaveCount(0) + await expect + .poll(() => orcaPage.evaluate(() => window.api.settings.get().then((s) => s.uiLanguage))) + .toBe('ja') + expect(await orcaPage.evaluate(() => performance.timeOrigin)).toBe(timeOrigin) + await orcaPage.screenshot({ path: testInfo.outputPath('catalog-retry-recovered.png') }) +})