From ce09ca6190700ac7f2ab9be9106bfce806ddf10d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 3 Aug 2026 13:55:51 +0300 Subject: [PATCH] fix(walkthrough): offer German, and catch the next locale that is only half added MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit German was added to the interface but not to the walkthrough's own language list, and nothing failed: the picker offered Deutsch because it is built from the interface locales, the server resolved the tag to English, and a German reader paid for a walkthrough written in English while the picker still said Deutsch. The two lists cannot be one — the server cannot import from packages/ui — so a test reads i18n/runtime.ts and compares them, in both directions and through normalizeLanguage. Drift this quiet needs a test rather than vigilance. --- .../server/lib/walkthrough/DOCUMENTATION.md | 7 ++- .../web/server/lib/walkthrough/languages.js | 7 +++ .../server/lib/walkthrough/languages.test.js | 55 +++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 packages/web/server/lib/walkthrough/languages.test.js diff --git a/packages/web/server/lib/walkthrough/DOCUMENTATION.md b/packages/web/server/lib/walkthrough/DOCUMENTATION.md index 892d403d..43d59d16 100644 --- a/packages/web/server/lib/walkthrough/DOCUMENTATION.md +++ b/packages/web/server/lib/walkthrough/DOCUMENTATION.md @@ -136,7 +136,12 @@ silently. The prompt says so explicitly. `languages.js` owns the accepted tags; they match the UI's `Locale` union, and anything else — unknown, malformed, absent — resolves to English rather than -failing the request. The default language adds no instruction at all, since the +failing the request. The two lists cannot be one, because the server cannot +import from `packages/ui`, so `languages.test.js` reads `i18n/runtime.ts` and +compares them. That test exists because a locale added to the interface alone +fails silently in the worst way: the picker offers the language, the tag +resolves to English, and the reader pays for a walkthrough written in the wrong +one while the picker still names theirs. The default language adds no instruction at all, since the system prompt is already English. The language is part of the cache key. Without that, asking for a translation diff --git a/packages/web/server/lib/walkthrough/languages.js b/packages/web/server/lib/walkthrough/languages.js index 787dfd36..fba6b723 100644 --- a/packages/web/server/lib/walkthrough/languages.js +++ b/packages/web/server/lib/walkthrough/languages.js @@ -17,6 +17,7 @@ export const DEFAULT_LANGUAGE = 'en'; // which every model handles more reliably than a switch mid-sentence. const LANGUAGE_NAMES = { en: 'English', + de: 'German', fr: 'French', 'zh-CN': 'Simplified Chinese', 'zh-TW': 'Traditional Chinese', @@ -57,3 +58,9 @@ export function normalizeLanguage(value) { export function languageName(language) { return LANGUAGE_NAMES[language] ?? LANGUAGE_NAMES[DEFAULT_LANGUAGE]; } + +// The tags this list must agree with live in `packages/ui/src/lib/i18n`, which +// the server cannot import. `languages.test.js` compares the two by reading +// that file, because a locale added on one side only fails silently: the picker +// offers the language and the walkthrough comes back in English. +export const __testing = { LANGUAGE_NAMES }; diff --git a/packages/web/server/lib/walkthrough/languages.test.js b/packages/web/server/lib/walkthrough/languages.test.js new file mode 100644 index 00000000..b3c392eb --- /dev/null +++ b/packages/web/server/lib/walkthrough/languages.test.js @@ -0,0 +1,55 @@ +import fs from 'fs'; +import { fileURLToPath } from 'url'; +import { describe, expect, it } from 'vitest'; +import { normalizeLanguage, __testing } from './languages.js'; + +// The languages a walkthrough may be written in have to agree with the locales +// the interface ships, because the picker offers exactly those and the server +// decides what the prompt asks for. The two lists cannot be one list — the +// server cannot import from `packages/ui` — so they are compared here instead. +// +// This exists because German was added to the interface and not here. Nothing +// broke loudly: the picker offered Deutsch, `normalizeLanguage` quietly resolved +// it to English, and a German user paid for a walkthrough written in English +// while the picker still said Deutsch. A drift this quiet needs a test, not +// vigilance. +const RUNTIME_TS = fileURLToPath(new URL('../../../../ui/src/lib/i18n/runtime.ts', import.meta.url)); + +const interfaceLocales = () => { + const source = fs.readFileSync(RUNTIME_TS, 'utf8'); + const match = source.match(/export const LOCALES = \[([^\]]*)\]/); + if (!match) throw new Error(`Could not find LOCALES in ${RUNTIME_TS}`); + return match[1] + .split(',') + .map((entry) => entry.trim().replace(/^['"]|['"]$/g, '')) + .filter(Boolean); +}; + +describe('supported languages', () => { + it('covers every locale the interface offers', () => { + const missing = interfaceLocales().filter((locale) => !Object.hasOwn(__testing.LANGUAGE_NAMES, locale)); + + expect(missing, `add these to LANGUAGE_NAMES in languages.js: ${missing.join(', ')}`).toEqual([]); + }); + + it('offers nothing the interface cannot label', () => { + const locales = new Set(interfaceLocales()); + const extra = Object.keys(__testing.LANGUAGE_NAMES).filter((tag) => !locales.has(tag)); + + // A language here that the interface does not know is not harmful, but it + // is unreachable: the picker is built from the interface list. + expect(extra, `unreachable from the picker: ${extra.join(', ')}`).toEqual([]); + }); + + it('resolves every interface locale to itself rather than to the default', () => { + for (const locale of interfaceLocales()) { + expect(normalizeLanguage(locale)).toBe(locale); + } + }); + + it('names every supported language in English, for the prompt', () => { + for (const [tag, name] of Object.entries(__testing.LANGUAGE_NAMES)) { + expect(name, tag).toMatch(/^[A-Z][A-Za-z ]+$/); + } + }); +});