diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml index ea66531864..c1ac4af1e9 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md -2026-07-31-browser-derived-initial-locale.md: 072f91b730cfc9eaeead7701d2b20d12b443acb3 -2026-07-31-browser-derived-initial-locale.zh.md: 97f0f0007474c21fb618a085b506bc919586f624 +2026-07-31-browser-derived-initial-locale.md: 94f32b136f20c7ab7fb8241a0ac9adf6249a4380 +2026-07-31-browser-derived-initial-locale.zh.md: 8d879b9b11ad42feed9ffd2ec3e5a16d1dcd9b8c diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md index 072f91b730..94f32b136f 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md @@ -8,29 +8,35 @@ English | [中文](2026-07-31-browser-derived-initial-locale.zh.md) The Settings Language row opened every first visit in Chinese: `LocaleRuntime` read `dsh.locale` from localStorage and fell straight back to `zh` when nothing was stored. The browser already states which languages its user reads — `navigator.languages` is that statement — and the app ignored it, so an English reader met a Chinese product and had to find a Chinese-labelled settings row to escape it. The fallback was doing two jobs at once: the last resort for an unresolvable locale, and the answer for every user who had simply never chosen. +Reading the browser fixed the readers whose browser names a language this app ships, but left the residual case wrong: a browser asking for neither `zh` nor `en` (`fr`, `de`) still fell back to `zh`. Those readers are the least likely to read Chinese. + ## Decision -**The provisional locale resolves through the browser, then `FALLBACK_LOCALE`; an explicit Host preference replaces it live.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and expresses the browser/fallback order. The nonblocking settings lifecycle then applies optional `locale.preference` from `$DSH_HOME/settings.yaml`; absence leaves the browser-derived value active. +**The provisional locale resolves through the browser, then `FALLBACK_LOCALE` (`en`); an explicit Host preference replaces it live.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and expresses the browser/fallback order. The nonblocking settings lifecycle then applies optional `locale.preference` from `$DSH_HOME/settings.yaml`; absence leaves the browser-derived value active. + +**One constant serves both the opening locale and the dictionary fallback, because the dictionaries are symmetric.** `FALLBACK_LOCALE` answers both "which language does the UI open in when the browser names none we ship" and "which dictionary backs a key the active locale misses". Those are different questions, and splitting them into two constants would be right if either answer had to differ — but every shipped `zh`/`en` pair declares identical key sets, so the fallback step always resolves and both answers are `en`, the source language of the copy. `scripts/locale-dictionary-parity.spec.ts` gates the symmetry the shared constant depends on: a key added to one side only fails that spec by name, instead of surfacing later as a bare key such as `list.aria` in a running UI. **Browser matching is on the primary subtag, over the ordered list.** `detectBrowserLocale()` walks `[...(navigator.languages ?? []), navigator.language]` and returns the first entry whose primary subtag names a shipped locale, so `zh-Hans-CN` and `zh-TW` both land on `zh` and `en-GB` on `en`, while a browser asking only for languages this app does not ship (`fr`, `de`) yields nothing and leaves `FALLBACK_LOCALE` in charge. `navigator.language` trails the list and covers its absence on hosts that ship a Navigator without `languages` — the DOM lib types it as always present, so that tolerance carries a narrow lint exception, the same environment-boundary distrust the `localStorage` guards already express. -**`window`, not `navigator`, is the browser test.** Node ≥ 21 exposes a global `navigator` reporting the machine's own language (`en-US` on the CI runners), so gating on `navigator` would have let a node boot of the client tree resolve to `en` instead of the documented fallback. Gating on `window` keeps every non-browser run on `FALLBACK_LOCALE`. +**`window`, not `navigator`, is the browser test.** Node ≥ 21 exposes a global `navigator` reporting the machine's own language, so gating on `navigator` would let a node boot of the client tree resolve to the machine's language instead of the documented fallback. Gating on `window` keeps every non-browser run on `FALLBACK_LOCALE`. **An explicit choice is durable.** `setLocale` writes through the Host settings API, so a user who picked a language keeps it across browser origins and system languages that share the same DSH home. Nothing writes the detected locale back: detection is re-derived every boot and stays invisible to the “has the user chosen?” question. -**The browser e2e lane pins browser language.** Scenarios asserting Chinese copy (`access-confirmation`, `models-settings`, `onboarding-deepseek-config`, `settings-chrome`) open their page with `locale: ZH_BROWSER_LOCALE` from `apps/web/tests/support.ts`; `newEnglishPage` advertises `en-US`. `settings-chrome.e2e.ts` opens a fresh Host home with no explicit locale and asserts its English browser produces an English settings surface—the assembled-app proof of this feature. +**The browser e2e lane pins browser language.** Scenarios asserting Chinese copy (`access-confirmation`, `models-settings`, `onboarding-deepseek-config`, `settings-chrome`) open their page with `locale: ZH_BROWSER_LOCALE` from `apps/web/tests/support.ts`; `newEnglishPage` advertises `en-US`. `settings-chrome.e2e.ts` opens a fresh Host home with no explicit locale twice: an `en-US` browser and an `fr-FR` one both reach an English surface. The `fr-FR` scenario is the one that pins the fallback — an `en-US` browser would land on English under detection or fallback alike, so only an unshipped language distinguishes them, and the zh scenarios prove detection still overrides the fallback. ## Alternatives considered - **`Intl.DateTimeFormat().resolvedOptions().locale` or a single `navigator.language` read**: both collapse the user's ordered preference list to one tag, so a `['de', 'en', 'zh']` reader gets zh instead of en. The list is the part of the browser statement worth reading. - **Persisting the detected locale on first boot**: it would make detection a one-time event and let a stale first visit outlive a changed browser language, and it destroys the distinction the resolution order rests on — a stored value would no longer mean "the user chose this". - **Full BCP 47 negotiation (`Intl.LocaleMatcher`-style lookup, region and script weighting)**: with exactly two shipped locales that differ in language, primary-subtag matching is the whole of the correct answer; a negotiation layer would be untestable surface with no behavior to justify it. -- **A cordis config key for the default locale**: the deployment does not vary here — the fallback is the product's answer for "no signal at all", not a knob. Repo policy reserves `Config` fields for deployment-varying choices with a current consumer. +- **A cordis config key for the fallback locale**: the deployment does not vary here — the fallback is the product's answer for "no signal at all", not a knob. Repo policy reserves `Config` fields for deployment-varying choices with a current consumer. +- **Two constants, one for the opening locale and one for the dictionary fallback**: it separates two genuinely different questions, and would be required if the answers differed. They do not: the dictionaries are symmetric, so both are `en`, and a second constant would be two names for one value plus a rule nothing enforces. The symmetry itself is worth enforcing, so it is gated directly instead. +- **Keeping `zh` as the dictionary fallback while opening in `en`**: it reads as the conservative choice, but with symmetric dictionaries it never resolves a key that `en` would not, so it buys nothing; and where it would matter — a key present only in `zh` — rendering Chinese text inside an otherwise English UI is worse than the bare key a reviewer would notice. - **Keeping the e2e lane's zh scenarios on storage pinning (`dsh.locale=zh`)**: it would keep the suite green while removing the only place the browser-derived path runs in an assembled app; pinning the browser language instead exercises the new resolution end to end. ## Consequences -- A first visit from an English browser lands in English, and the Language row still shows the same two self-described options, so the escape hatch is unchanged in either direction. -- `FALLBACK_LOCALE` narrows to its real job — the dictionary fallback and the no-signal answer — and stops standing in for "the user has not chosen". -- Tests that construct a `LocaleRuntime` under jsdom now depend on the environment's `navigator`: specs asserting localized copy declare their browser with one suite-level `usePinnedBrowserLanguages('zh-CN')` (dsh-client-test-runtime), and any future spec asserting a default must do the same. This package's own specs stub the globals directly, because they need shapes the helper deliberately cannot express (absent `languages`, a list decoupled from `language`, no `window` at all). +- A first visit from an English browser lands in English, a Chinese browser in Chinese, and a browser naming neither lands in English rather than Chinese. The Language row still shows the same two self-described options, so the escape hatch is unchanged in either direction. +- Dictionary resolution reverses direction: a key missing from the active locale now falls to `en`, not `zh`. With symmetric dictionaries no shipped key changes behavior, which is why the parity gate exists — it is the assumption that reversal rests on. +- Non-browser runs of the client tree (node boots, the non-jsdom unit lane) now open in `en`. Specs that assert shipped Chinese copy must set `setLocale('zh')` explicitly on the runtime they construct; a suite-level `usePinnedBrowserLanguages('zh-CN')` only works in files that also declare `@vitest-environment jsdom`, because without a `window` the detection path never reads `navigator` at all. Seven `*.client.spec.ts` files carried such a dead pin and were relying on the old `zh` fallback instead. - Detection cost is one array walk per service construction and no implicit settings write; an explicit Host preference may cause one live convergence after plugin activation. diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md index 97f0f00074..8d879b9b11 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md @@ -8,29 +8,35 @@ Status: implemented 设置里的语言行在每一次首访时都以中文开场:`LocaleRuntime` 从 localStorage 读取 `dsh.locale`,读不到就直接回落到 `zh`。浏览器本已声明其使用者阅读哪些语言——`navigator.languages` 就是这份声明——而应用对此视而不见,于是英文读者迎面撞上一个中文产品,还得先找到一行中文标签的设置项才能脱身。回落值当时同时承担两份职责:既是无法解析出 locale 时的最后兜底,也是所有从未做过选择的用户拿到的答案。 +读取浏览器修好了那些浏览器声明了本应用所提供语言的读者,但残余情形依然是错的:既不请求 `zh` 也不请求 `en` 的浏览器(`fr`、`de`)仍会回落到 `zh`。这些读者恰恰最不可能阅读中文。 + ## Decision -**暂定 locale 先经浏览器、再经 `FALLBACK_LOCALE` 解析;显式 Host 偏好会实时替换它。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时运行,并表达浏览器/回落顺序。随后,非阻塞 settings 生命周期会应用 `$DSH_HOME/settings.yaml` 中可选的 `locale.preference`;若该值缺失,则继续使用由浏览器派生的值。 +**暂定 locale 先经浏览器、再经 `FALLBACK_LOCALE`(`en`)解析;显式 Host 偏好会实时替换它。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时运行,并表达浏览器/回落顺序。随后,非阻塞 settings 生命周期会应用 `$DSH_HOME/settings.yaml` 中可选的 `locale.preference`;若该值缺失,则继续使用由浏览器派生的值。 + +**开场 locale 与字典回落值共用一个常量,因为两侧字典是对称的。** `FALLBACK_LOCALE` 同时回答「浏览器未声明任何本应用提供的语言时,界面以哪种语言开场」与「当前 locale 的字典缺失某个 key 时由哪本字典兜住」。这是两个不同的问题,若其中任一答案必须不同,拆成两个常量才是对的——但每一对已提供的 `zh`/`en` 字典都声明了完全相同的 key 集合,因此回落这一步总能解析成功,两个答案都是 `en`,也就是文案的源语言。`scripts/locale-dictionary-parity.spec.ts` 为这个共用常量所依赖的对称性设了门禁:只加在一侧的 key 会让该用例指名失败,而不是日后在运行中的界面里显现为形如 `list.aria` 的裸 key。 **浏览器匹配按主子标签进行,且遍历有序列表。** `detectBrowserLocale()` 遍历 `[...(navigator.languages ?? []), navigator.language]`,返回主子标签命中已提供 locale 的首个条目,因此 `zh-Hans-CN` 与 `zh-TW` 同归 `zh`、`en-GB` 归 `en`;而只请求本应用不提供的语言(`fr`、`de`)的浏览器则什么都匹配不到,交由 `FALLBACK_LOCALE` 接管。`navigator.language` 排在列表之后,并兜住那些 Navigator 上没有 `languages` 的宿主——DOM 库把它标注为必然存在,所以这份容忍带一条窄口径 lint 例外,与 `localStorage` 守卫表达的环境边界不信任同源。 -**判定浏览器用的是 `window` 而非 `navigator`。** Node ≥ 21 暴露全局 `navigator` 并报告机器自身语言(CI runner 上是 `en-US`),因此以 `navigator` 把关会让 node 启动客户端树时解析成 `en`,而非文档约定的回落值。以 `window` 把关可使所有非浏览器运行都停留在 `FALLBACK_LOCALE`。 +**判定浏览器用的是 `window` 而非 `navigator`。** Node ≥ 21 暴露全局 `navigator` 并报告机器自身语言,因此以 `navigator` 把关会让 node 启动客户端树时解析成机器语言,而非文档约定的回落值。以 `window` 把关可使所有非浏览器运行都停留在 `FALLBACK_LOCALE`。 **显式选择具有持久性。** `setLocale` 通过 Host settings API 写入,因此选过语言的用户可在共享同一 DSH home 的不同浏览器 origin 与系统语言之间保留原选择。没有任何代码把探测到的 locale 写回:探测在每次启动时重新推导,对「用户是否做过选择」这一问题始终不可见。 -**浏览器 e2e 车道固定浏览器语言。** 断言中文文案的场景(`access-confirmation`、`models-settings`、`onboarding-deepseek-config`、`settings-chrome`)以 `apps/web/tests/support.ts` 的 `locale: ZH_BROWSER_LOCALE` 打开页面;`newEnglishPage` 声明 `en-US`。`settings-chrome.e2e.ts` 使用没有显式 locale 的全新 Host home,断言其英文浏览器会生成英文 settings 界面:这是本功能在组装后应用中的证据。 +**浏览器 e2e 车道固定浏览器语言。** 断言中文文案的场景(`access-confirmation`、`models-settings`、`onboarding-deepseek-config`、`settings-chrome`)以 `apps/web/tests/support.ts` 的 `locale: ZH_BROWSER_LOCALE` 打开页面;`newEnglishPage` 声明 `en-US`。`settings-chrome.e2e.ts` 两次使用没有显式 locale 的全新 Host home:`en-US` 浏览器与 `fr-FR` 浏览器都会抵达英文界面。真正钉住回落值的是 `fr-FR` 那个场景——`en-US` 浏览器无论走探测还是走回落都会落在英文,因此只有本应用不提供的语言才能区分二者,而中文场景则证明探测仍然覆盖回落值。 ## Alternatives considered - **`Intl.DateTimeFormat().resolvedOptions().locale` 或单读 `navigator.language`**:两者都把用户的有序偏好列表塌缩成一个标签,于是 `['de', 'en', 'zh']` 的读者拿到的是 zh 而非 en。列表恰恰是浏览器这份声明里最值得读的部分。 - **首次启动即持久化探测结果**:那会把探测变成一次性事件,让一次陈旧的首访凌驾于此后改变的浏览器语言之上,也摧毁了整个解析顺序所依赖的区分——存储值将不再意味着「用户选了它」。 - **完整的 BCP 47 协商(`Intl.LocaleMatcher` 式查找、地区与文字权重)**:在只提供两个语言互异的 locale 时,主子标签匹配就是正确答案的全部;协商层只会带来无行为支撑、也无从测试的表面积。 -- **为默认 locale 增加一个 Cordis 配置键**:此处部署之间并无差异——回落值是产品对「完全没有信号」给出的答案,不是旋钮。仓库策略把 `Config` 字段留给有当前消费方、且随部署变化的选择。 +- **为回落 locale 增加一个 Cordis 配置键**:此处部署之间并无差异——回落值是产品对「完全没有信号」给出的答案,不是旋钮。仓库策略把 `Config` 字段留给有当前消费方、且随部署变化的选择。 +- **拆成两个常量,一个管开场 locale、一个管字典回落**:它区分了两个确实不同的问题,若两个答案不同也确有必要。但它们并不不同:字典是对称的,因此两者都是 `en`,第二个常量只会是同一个值的两个名字,外加一条无人强制的规则。对称性本身值得强制,所以直接为它设门禁。 +- **开场用 `en`、字典回落仍保留 `zh`**:这看起来是保守选择,但在字典对称的前提下,它能解析的 key 与 `en` 完全相同,因此毫无收益;而在它真正会起作用的情形——某个 key 只存在于 `zh`——在整体英文的界面里渲染出中文文本,比让 reviewer 一眼看见裸 key 更糟。 - **让 e2e 车道的中文场景继续钉存储项(`dsh.locale=zh`)**:那会让套件保持绿色,却抹掉浏览器推导路径在组装后应用中唯一的运行处;改钉浏览器语言才能端到端地演练新的解析过程。 ## Consequences -- 来自英文浏览器的首访落在英文界面,而语言行依然呈现同样两个以自身语言自述的选项,两个方向的脱身通道都未改变。 -- `FALLBACK_LOCALE` 收窄回它真正的职责——字典回落与无信号时的答案——不再兼职充当「用户尚未选择」。 -- 在 jsdom 下构造 `LocaleRuntime` 的测试现在依赖环境的 `navigator`:断言本地化文案的用例以一行套件级 `usePinnedBrowserLanguages('zh-CN')`(dsh-client-test-runtime)声明其浏览器,今后任何断言默认值的用例同样如此。本包自己的用例直接给全局打桩,因为它们需要该 helper 刻意不表达的形状(`languages` 缺失、列表与 `language` 解耦、完全没有 `window`)。 +- 来自英文浏览器的首访落在英文界面,中文浏览器落在中文界面,而两者皆未声明的浏览器落在英文而非中文界面。语言行依然呈现同样两个以自身语言自述的选项,两个方向的脱身通道都未改变。 +- 字典解析方向发生反转:当前 locale 缺失的 key 现在回落到 `en` 而非 `zh`。在字典对称的前提下,没有任何已提供的 key 行为发生变化——这正是那道对称性门禁存在的原因:它是这次反转所依赖的前提。 +- 客户端树的非浏览器运行(node 启动、非 jsdom 单测车道)现在以 `en` 开场。断言已提供中文文案的用例必须在其构造的 runtime 上显式调用 `setLocale('zh')`;套件级的 `usePinnedBrowserLanguages('zh-CN')` 仅在同时声明了 `@vitest-environment jsdom` 的文件中生效,因为没有 `window` 时探测路径根本不会读取 `navigator`。此前有七个 `*.client.spec.ts` 文件带着这样一条失效的固定语句,实际依赖的是旧的 `zh` 回落值。 - 探测的代价是每次服务构造遍历一次数组,且不会隐式写入 settings;插件激活后,显式 Host 偏好可能引发一次实时收敛。 diff --git a/apps/web/index.html b/apps/web/index.html index a14de72d40..1ce5ff35ff 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -1,5 +1,5 @@ - + diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 876574a009..74c47d7a40 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -455,7 +455,9 @@ describe('web e2e: settings modal and General preferences', () => { it('opens an English browser in English without any stored preference', async () => { // A fresh Host home has no locale preference, so its surface follows the - // browser rather than the product fallback. + // browser. English is also FALLBACK_LOCALE, so this scenario alone cannot + // distinguish detection from the default — the zh scenarios above supply + // the discriminating half (a Chinese browser must NOT land on the default). const fresh = await launchWebScaffold({}) const enPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' }) const enTripwire = watchConsole(enPage) @@ -478,6 +480,30 @@ describe('web e2e: settings modal and General preferences', () => { } }, 90_000) + it('opens a browser asking for no shipped language in English', async () => { + // The product default for "no usable signal": a French browser ships + // neither zh nor en, so resolution falls to FALLBACK_LOCALE (en) rather + // than to Chinese. + const fresh = await launchWebScaffold({}) + const frPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'fr-FR' }) + const frTripwire = watchConsole(frPage) + onTestFailed(() => saveFailureShot(frPage, 'web-e2e-settings-unshipped-language')) + try { + await frPage.goto(fresh.baseUrl, { waitUntil: 'load' }) + await frPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + expect(await frPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() + await frPage.getByRole('button', { name: 'Settings', exact: true }).click() + const dialog = frPage.getByRole('dialog', { name: 'Settings' }) + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 }) + expect(frTripwire.pageErrors).toEqual([]) + expect(frTripwire.warnings).toEqual([]) + } finally { + await frPage.close() + await fresh.close() + } + }, 90_000) + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md', 'plugins.expected.md']) diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index 8291187175..abea65ac9e 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -86,8 +86,14 @@ declare module '@deepseek-ai/cordis' { } } -/** Fallback locale consulted after the active locale misses (also the last-resort initial locale). */ -export const FALLBACK_LOCALE: LocaleId = 'zh' +/** + * English is both the locale the UI opens in when the browser names no shipped + * language (and for non-browser runs), and the dictionary consulted after the + * active locale misses a key. One constant serves both because the shipped + * `zh`/`en` dictionaries carry identical key sets, so neither direction can + * leave a key unresolved; English is the source language of the copy. + */ +export const FALLBACK_LOCALE: LocaleId = 'en' /** Shared namespace for shell-level texts. */ export const COMMON_NS = 'common' @@ -103,8 +109,8 @@ const LOCALES: readonly LocaleDefinition[] = Object.freeze([ /** * Dictionary registry plus locale preference. Lookup chain per key: the - * entry's namespace in the active locale -> that namespace's zh fallback -> - * the shared common namespace (active, then zh) -> the key itself (missing + * entry's namespace in the active locale -> that namespace's en fallback -> + * the shared common namespace (active, then en) -> the key itself (missing * text stays visible, fail loud in the UI rather than blank). Reads go * through {@link getLocale}; writes only through {@link setLocale}; * continuous sync through the `locale/change` event, or through the diff --git a/packages/client/locale/tests/apply.client.spec.ts b/packages/client/locale/tests/apply.client.spec.ts index dd38786073..a792bfe53a 100644 --- a/packages/client/locale/tests/apply.client.spec.ts +++ b/packages/client/locale/tests/apply.client.spec.ts @@ -2,7 +2,7 @@ * Language row registration, snapshot projection into the row store, and * recovery after an HMR collapse of the declaring entry. */ import { Context } from '@deepseek-ai/cordis' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' @@ -73,12 +73,10 @@ function faceOf(slots: SlotRegistry) { } describe('locale apply', () => { - // A fresh service opens in the browser's language, so these wiring specs - // pin one to keep their zh baseline independent of the test environment. - beforeEach(() => { - vi.stubGlobal('navigator', { languages: ['zh-CN'], language: 'zh-CN' }) - }) - + // These are wiring specs, not default-language specs: each one that reads + // localized copy sets its locale explicitly via setLocale/Host preference + // rather than leaning on FALLBACK_LOCALE. This file has no jsdom environment, + // so there is no `window` and no browser-language detection to stub. afterEach(() => { vi.unstubAllGlobals() }) @@ -95,6 +93,9 @@ describe('locale apply', () => { // Base dictionaries are registered: the (ns, locale) seats are occupied. expect(() => locale.register('common', 'zh', {})).toThrow('already has locale') expect(() => locale.register('common', 'en', {})).toThrow('already has locale') + // Both dictionaries resolve; read each under its own active locale. + expect(locale.bind(SETTINGS_NS)('language.title')).toBe('Language') + locale.setLocale('zh') expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言') const entry = before.slots.entries(SLOT).find(e => e.component === LanguageRow)! expect(entry.options).toMatchObject({ id: 'language', order: 0 }) @@ -110,9 +111,14 @@ describe('locale apply', () => { it('projects service snapshots into the row store and routes face writes back', async () => { const b = await bench() + // Open at zh so the pre-inject switch to en below is a real change: with + // FALLBACK_LOCALE = en it would otherwise be a no-op and never exercise + // the unbound-actions arm or persist. + b.setHostPreference('zh') declareItems(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const locale = b.ctx.get('locale') as LocaleRuntime + await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') }) // An event ahead of any inject hits the unbound-actions arm. locale.setLocale('en') @@ -133,17 +139,20 @@ describe('locale apply', () => { it('loads and refreshes the explicit Host preference after nonblocking activation', async () => { const b = await bench() - b.setHostPreference('en') + // Preference must differ from the provisional locale (FALLBACK_LOCALE = en + // with no window), or clearing it below would be unobservable. + b.setHostPreference('zh') declareItems(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const locale = b.ctx.get('locale') as LocaleRuntime - await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') }) + await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') }) + // Cleared preference falls back to the provisional locale. b.setHostPreference(undefined) b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0]) - await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') }) - b.setHostPreference('en') - b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0]) await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') }) + b.setHostPreference('zh') + b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0]) + await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') }) expect(b.describe).toHaveBeenCalledTimes(3) }) diff --git a/packages/client/locale/tests/locale.client.spec.ts b/packages/client/locale/tests/locale.client.spec.ts index 86f1ce2922..eb279f1295 100644 --- a/packages/client/locale/tests/locale.client.spec.ts +++ b/packages/client/locale/tests/locale.client.spec.ts @@ -3,8 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import type { LocaleSettings, LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' -import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' - +import { FALLBACK_LOCALE, LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' const make = (host?: StubSettingsScope): { ctx: Context svc: LocaleRuntime @@ -37,31 +36,34 @@ describe('LocaleRuntime', () => { vi.unstubAllGlobals() }) - it('translates through the active-locale -> zh -> key chain', () => { + it('translates through the active-locale -> en -> key chain', () => { const { svc } = make() - svc.register('ns', 'zh', { hello: '你好', onlyZh: '仅中文' }) - svc.register('ns', 'en', { hello: 'Hello' }) + svc.register('ns', 'zh', { hello: '你好' }) + svc.register('ns', 'en', { hello: 'Hello', onlyEn: 'English only' }) const t = svc.bind('ns') expect(svc.getLocale().active).toBe('zh') expect(t('hello')).toBe('你好') + // The active locale misses this key; the en fallback supplies it. + expect(t('onlyEn')).toBe('English only') svc.setLocale('en') expect(t('hello')).toBe('Hello') - expect(t('onlyZh')).toBe('仅中文') expect(t('missing.key')).toBe('missing.key') }) it('falls through to the common vocabulary after the namespace misses (production keys)', () => { const { svc } = make() // The shipped common pair is registered by apply; the bench registers it - // directly to pin the production chain: ns -> common -> zh -> key. + // directly to pin the production chain: ns -> common -> en -> key. svc.register('common', 'zh', { retry: '重试' }) svc.register('common', 'en', { retry: 'Retry' }) - svc.register('ns', 'zh', { own: '自有' }) + svc.register('ns', 'en', { own: 'Own' }) const t = svc.bind('ns') expect(t('retry')).toBe('重试') + // zh is active and `ns` has no zh dictionary at all: the en fallback answers. + expect(t('own')).toBe('Own') svc.setLocale('en') expect(t('retry')).toBe('Retry') - expect(t('own')).toBe('自有') + expect(t('own')).toBe('Own') // common itself must not recurse: a miss inside common echoes the key. // (Wide-string ns hits the untyped bind overload — the typed one rejects // unknown keys at compile time, which is the point of the typed registry contract.) @@ -206,21 +208,21 @@ describe('LocaleRuntime', () => { expect(make().svc.getLocale().active).toBe('en') vi.stubGlobal('navigator', { language: 'en-US' }) expect(make().svc.getLocale().active).toBe('en') - // No shipped language anywhere in the browser's preferences: zh remains - // the product default rather than an arbitrary near-match. + // No shipped language anywhere in the browser's preferences: en is the + // product default rather than an arbitrary near-match. stubLanguages('fr-FR', 'de') - expect(make().svc.getLocale().active).toBe('zh') + expect(make().svc.getLocale().active).toBe('en') }) - it('runs outside a browser (node boots): the fallback decides and the machine language does not', () => { + it('runs outside a browser (node boots): the default decides and the machine language does not', () => { vi.stubGlobal('window', undefined) // Node exposes its own global navigator; without a window it must not // reach the resolution at all. - stubLanguages('en-US') + stubLanguages('zh-CN') const { svc } = make() - expect(svc.getLocale().active).toBe('zh') - svc.setLocale('en') expect(svc.getLocale().active).toBe('en') + svc.setLocale('zh') + expect(svc.getLocale().active).toBe('zh') }) it('lets an explicit in-process preference replace the browser-derived value', () => { @@ -230,6 +232,28 @@ describe('LocaleRuntime', () => { expect(svc.getLocale().active).toBe('zh') }) + it('serves English as both the opening locale and the dictionary fallback', () => { + // One constant covers both jobs: the locale the UI opens in with no usable + // browser signal, and the dictionary backing a key the active locale + // misses. Safe to share only because the shipped zh/en dictionaries carry + // identical key sets (asserted below on a registered pair). + expect(FALLBACK_LOCALE).toBe('en') + vi.stubGlobal('window', undefined) + const { svc } = make() + // A key present only in en resolves for a zh reader through the fallback. + svc.register('ns', 'zh', {}) + svc.register('ns', 'en', { onlyEn: 'English only' }) + svc.setLocale('zh') + expect(svc.getLocale().active).toBe('zh') + expect(svc.bind('ns')('onlyEn')).toBe('English only') + // The reverse no longer resolves: a zh-only key is unreachable from en, so + // the key itself surfaces (fail loud) rather than silently rendering zh. + svc.register('ns2', 'zh', { onlyZh: '仅中文' }) + svc.register('ns2', 'en', {}) + svc.setLocale('en') + expect(svc.bind('ns2')('onlyZh')).toBe('onlyZh') + }) + it('exposes the two shipped locales with self-described labels', () => { const { svc } = make() expect(svc.getLocale().locales).toEqual([ diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index 7ff4741648..7d998c7e11 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -10,7 +10,7 @@ import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject } from '@deepseek-ai/dsh-client-ui-agent-preset/client' import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx' import type { AgentPresetLabelInjected } from '../src/client/AgentPresetLabel.tsx' @@ -21,9 +21,6 @@ import type { AgentPresetSectionInjected } from '../src/client/AgentPresetSectio import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx' import type { AgentPresetSeatInjected } from '../src/client/AgentPresetSeat.tsx' -// The service reads its initial locale from the browser; these specs assert -// the shipped Chinese copy, so they state the browser they assume. -usePinnedBrowserLanguages('zh-CN') const ROSTER_ONE = { rpcId: 'r', @@ -77,6 +74,10 @@ async function bench() { const moveDefault = (): void => { ROSTER = ROSTER_MOVED } await ctx.plugin(SlotRegistry).await() const locale = new LocaleRuntime(ctx) + // These specs assert the shipped Chinese copy. There is no jsdom `window` + // in this lane, so browser-language detection never runs and the locale + // comes from FALLBACK_LOCALE (en): state the asserted locale explicitly. + locale.setLocale('zh') ctx.provide('locale', locale) // The plugins inject `remote`; forwarded events reach them through the // same `$dispatch` handoff the connection sink makes. diff --git a/packages/client/ui-input-trigger/tests/apply.client.spec.ts b/packages/client/ui-input-trigger/tests/apply.client.spec.ts index fa3f141283..f5d04fcfd6 100644 --- a/packages/client/ui-input-trigger/tests/apply.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/apply.client.spec.ts @@ -7,15 +7,11 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { createScope, scopeOf, SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { apply, inject, InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { MenuViewInjected } from '@deepseek-ai/dsh-client-ui-input-trigger/client' -// The service reads its initial locale from the browser; these specs assert -// the shipped Chinese copy, so they state the browser they assume. -usePinnedBrowserLanguages('zh-CN') const sid = (k: string): SessionId => k as SessionId @@ -37,6 +33,10 @@ async function bench() { scopeOf: (c: Context) => scopeOf(c), }) const locale = new LocaleRuntime(ctx) + // These specs assert the shipped Chinese copy. There is no jsdom `window` + // in this lane, so browser-language detection never runs and the locale + // comes from FALLBACK_LOCALE (en): state the asserted locale explicitly. + locale.setLocale('zh') ctx.provide('locale', locale) return { ctx, slots, locale } } diff --git a/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts index cf5d506024..18e19d7e40 100644 --- a/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts @@ -39,6 +39,10 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType () => {} } as never) ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() + // These specs assert the shipped Chinese copy. There is no jsdom `window` in + // this lane, so browser-language detection never runs and the locale comes + // from FALLBACK_LOCALE (en): state the asserted locale explicitly. + ctx.locale.setLocale('zh') const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() return { ctx, fiber } diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index 03bb3aa535..aad5dda614 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -104,7 +104,12 @@ async function bench() { return () => { seats.delete(options.name) } }, }) - ctx.provide('locale', new LocaleRuntime(ctx)) + const localeRuntime = new LocaleRuntime(ctx) + // This spec asserts the shipped Chinese copy. There is no jsdom `window` in + // this lane, so browser-language detection never runs and the locale comes + // from FALLBACK_LOCALE (en): state the asserted locale explicitly. + localeRuntime.setLocale('zh') + ctx.provide('locale', localeRuntime) const scopes = new Map() const addressed = new Set() ctx.provide('sessions', { diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index d6c3ffff02..537bead7af 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -4,16 +4,12 @@ import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' import { GeneralSection } from '../src/client/GeneralSection.tsx' import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx' import type { SettingsDocumentActionInjected } from '../src/client/SettingsDocumentAction.tsx' -// The service reads its initial locale from the browser; these specs assert -// the shipped Chinese copy, so they state the browser they assume. -usePinnedBrowserLanguages('zh-CN') /** The seats this plugin fills for a loopback browser (slot name → expected component). */ const SEATS = [ @@ -28,6 +24,10 @@ async function bench(isLoopback = true) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() const locale = new LocaleRuntime(ctx) + // These specs assert the shipped Chinese copy. There is no jsdom `window` + // in this lane, so browser-language detection never runs and the locale + // comes from FALLBACK_LOCALE (en): state the asserted locale explicitly. + locale.setLocale('zh') ctx.provide('locale', locale) const settingsDescribe = vi.fn(() => Promise.resolve({ rpcId: 'settings-general' as never, diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index 39ba3e4b65..507c4f9aaf 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -4,20 +4,21 @@ import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-settings-models/client' import { ModelsSection } from '../src/client/ModelsSection.tsx' import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx' import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' -// The service reads its initial locale from the browser; these specs assert -// the shipped Chinese copy, so they state the browser they assume. -usePinnedBrowserLanguages('zh-CN') async function bench(isLoopback = true) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() const locale = new LocaleRuntime(ctx) + // These specs assert the shipped Chinese copy. There is no jsdom `window` + // in this lane, so browser-language detection never runs and the locale + // comes from FALLBACK_LOCALE (en): state the asserted locale explicitly. + locale.setLocale('zh') ctx.provide('locale', locale) // The plugins inject `remote`; forwarded events reach them through the // same `$dispatch` handoff the connection sink makes. diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index 2934097b94..14b82dac66 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -5,16 +5,13 @@ import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' import type { ConfigurablePluginsTabFace, PluginsSettingsSectionInjected, } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' -// The service reads its initial locale from the browser; these specs assert -// the shipped Chinese copy, so they state the browser they assume. -usePinnedBrowserLanguages('zh-CN') /** * @param served - namespaces the Host describes; omitted answers a failed read, @@ -24,6 +21,10 @@ async function bench(served?: string[]) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() const locale = new LocaleRuntime(ctx) + // These specs assert the shipped Chinese copy. There is no jsdom `window` + // in this lane, so browser-language detection never runs and the locale + // comes from FALLBACK_LOCALE (en): state the asserted locale explicitly. + locale.setLocale('zh') ctx.provide('locale', locale) const describeCredentials = vi.fn(() => Promise.resolve({ rpcId: 'c', result: { ok: false, error: {} } })) const describeSettings = vi.fn(() => Promise.resolve(served === undefined diff --git a/packages/client/ui-theme/tests/apply.client.spec.ts b/packages/client/ui-theme/tests/apply.client.spec.ts index fb84c9860d..60aa1f349b 100644 --- a/packages/client/ui-theme/tests/apply.client.spec.ts +++ b/packages/client/ui-theme/tests/apply.client.spec.ts @@ -5,7 +5,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client' import type { AppearanceRowInjected, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client' @@ -13,9 +13,6 @@ import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-sett import { AppearanceRow } from '../src/client/AppearanceRow.tsx' import type { createAppearanceRowStore } from '../src/client/settings-store.ts' -// The service reads its initial locale from the browser; these specs assert -// the shipped Chinese copy, so they state the browser they assume. -usePinnedBrowserLanguages('zh-CN') const SLOT = 'settings.general.item' @@ -29,6 +26,10 @@ async function bench(isLoopback = true) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() const locale = new LocaleRuntime(ctx) + // These specs assert the shipped Chinese copy. There is no jsdom `window` + // in this lane, so browser-language detection never runs and the locale + // comes from FALLBACK_LOCALE (en): state the asserted locale explicitly. + locale.setLocale('zh') ctx.provide('locale', locale) let preference = 'system' const namespace = () => ({ diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 016af313f8..4a819c8587 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -2,15 +2,11 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client' import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx' import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' -// The service reads its initial locale from the browser; these specs assert -// the shipped Chinese copy, so they state the browser they assume. -usePinnedBrowserLanguages('zh-CN') async function bench() { const ctx = new Context() @@ -37,6 +33,10 @@ async function bench() { } as never) ctx.provide('sessions', { open, clear, search, searchResultLimit: 20, binding, fork } as never) const locale = new LocaleRuntime(ctx) + // These specs assert the shipped Chinese copy. There is no jsdom `window` + // in this lane, so browser-language detection never runs and the locale + // comes from FALLBACK_LOCALE (en): state the asserted locale explicitly. + locale.setLocale('zh') ctx.provide('locale', locale) return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, startSession, rename, diff --git a/scripts/locale-dictionary-parity.spec.ts b/scripts/locale-dictionary-parity.spec.ts new file mode 100644 index 0000000000..d48fc3e808 --- /dev/null +++ b/scripts/locale-dictionary-parity.spec.ts @@ -0,0 +1,137 @@ +/** + * Gate for the invariant `FALLBACK_LOCALE` rests on: every shipped dictionary + * declares the same keys in `zh` and `en`. + * + * The locale runtime resolves a key through the active locale, then through + * the single fallback locale (`en`), then surfaces the key itself. With + * symmetric dictionaries that middle step always resolves, so one constant can + * serve as both the opening locale and the dictionary fallback. A key added to + * only one side breaks that: a reader of the other language sees a bare key + * such as `list.aria` instead of text. This gate fails on the asymmetry rather + * than waiting for the bare key to reach a UI. + */ + +import type { Dirent } from 'node:fs' +import { readdirSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import ts from 'typescript' +import { describe, expect, it } from 'vitest' + +const root = fileURLToPath(new URL('..', import.meta.url)) + +/** Every `locales*.ts` module under a client package's `src/`. */ +function dictionaryModules(): string[] { + const files: string[] = [] + for (const group of ['client', 'extensions']) { + const groupRoot = resolve(root, 'packages', group) + let packages: string[] + try { + packages = readdirSync(groupRoot, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) + } catch { + continue + } + for (const pkg of packages) { + const srcRoot = resolve(groupRoot, pkg, 'src') + walk(srcRoot, files) + } + } + return files.sort() +} + +function walk(dir: string, out: string[]): void { + let entries: Dirent[] + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const full = resolve(dir, entry.name) + if (entry.isDirectory()) { + walk(full, out) + } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) { + if (/^locales?(\.[\w-]+)?\.ts$/.test(entry.name) || dir.endsWith('/locales')) out.push(full) + } + } +} + +/** + * Keys of every top-level `export const ...= { ... }` object literal, + * read from the AST so the gate never executes package code. + * @param file - absolute path of the dictionary module. + * @returns exported dictionary name mapped to its declared keys. + */ +function exportedDictionaries(file: string): Map { + const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.ESNext, true) + const found = new Map() + for (const statement of source.statements) { + if (!ts.isVariableStatement(statement)) continue + const exported = statement.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) === true + if (!exported) continue + for (const decl of statement.declarationList.declarations) { + if (!ts.isIdentifier(decl.name)) continue + const initializer = unwrap(decl.initializer) + if (initializer === undefined || !ts.isObjectLiteralExpression(initializer)) continue + const keys: string[] = [] + for (const prop of initializer.properties) { + if (!ts.isPropertyAssignment(prop)) continue + if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) keys.push(prop.name.text) + } + found.set(decl.name.text, keys.sort()) + } + } + return found +} + +/** Look through `satisfies`/`as`/parenthesized wrappers to the literal. */ +function unwrap(node: ts.Expression | undefined): ts.Expression | undefined { + let current = node + while ( + current !== undefined + && (ts.isSatisfiesExpression(current) || ts.isAsExpression(current) || ts.isParenthesizedExpression(current)) + ) { + current = current.expression + } + return current +} + +/** Pair a `zh` export with the `en` export covering the same namespace. */ +function counterpart(name: string): string | undefined { + if (name === 'zh') return 'en' + if (name.startsWith('zh') && name.length > 2) return `en${name.slice(2)}` + if (name.endsWith('Zh')) return `${name.slice(0, -2)}En` + return undefined +} + +describe('shipped locale dictionaries', () => { + it('declares the same keys in zh and en, so the single fallback locale always resolves', () => { + const modules = dictionaryModules() + // Guard the discovery itself: an empty sweep would pass every assertion + // below while checking nothing. + expect(modules.length).toBeGreaterThan(20) + + const mismatches: string[] = [] + let comparedPairs = 0 + for (const file of modules) { + const dicts = exportedDictionaries(file) + for (const [name, zhKeys] of dicts) { + const enName = counterpart(name) + if (enName === undefined) continue + const enKeys = dicts.get(enName) + if (enKeys === undefined) continue + comparedPairs++ + const rel = file.slice(root.length) + const zhOnly = zhKeys.filter(key => !enKeys.includes(key)) + const enOnly = enKeys.filter(key => !zhKeys.includes(key)) + if (zhOnly.length > 0) mismatches.push(`${rel} ${name} has keys absent from ${enName}: ${zhOnly.join(', ')}`) + if (enOnly.length > 0) mismatches.push(`${rel} ${enName} has keys absent from ${name}: ${enOnly.join(', ')}`) + } + } + + expect(comparedPairs).toBeGreaterThan(20) + expect(mismatches).toEqual([]) + }) +})