From cb754a0319fd9d0cbc363c73617b70cc652a2ac0 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 15:26:46 +0800 Subject: [PATCH] feat(locale): derive the initial Settings language from the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A first visit resolved to Chinese regardless of the browser: LocaleService read `dsh.locale` and fell straight back to `zh` when nothing was stored, ignoring the languages the browser already states it reads. The initial locale now resolves through three ordered sources — the persisted preference, then `navigator` (first entry of the ordered language list whose primary subtag names a shipped locale, so `zh-Hans-CN` -> zh and `en-GB` -> en), then `FALLBACK_LOCALE`. An explicit choice still wins and nothing writes the detected locale back to storage, so "has the user chosen?" stays a question only the stored value answers. Specs asserting the shipped Chinese copy now state the browser they assume: the web e2e scenarios open their page with `locale: ZH_BROWSER_LOCALE`, and package specs pin it through the new `pinBrowserLanguages` test helper. `settings-chrome.e2e.ts` gains an English-browser scenario as the assembled-app proof. --- ...07-30-client-locale-full-rollout.i18n.yaml | 4 +- .../2026-07-30-client-locale-full-rollout.md | 2 +- ...026-07-30-client-locale-full-rollout.zh.md | 2 +- ...1-browser-derived-initial-locale.i18n.yaml | 6 ++ ...26-07-31-browser-derived-initial-locale.md | 34 ++++++++++++ ...07-31-browser-derived-initial-locale.zh.md | 34 ++++++++++++ ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 +- .../2026-07-24-web-gui-browser-e2e-lane.md | 2 +- .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 2 +- apps/web/tests/access-confirmation.e2e.ts | 4 +- apps/web/tests/models-settings.e2e.ts | 5 +- .../tests/onboarding-deepseek-config.e2e.ts | 5 +- apps/web/tests/settings-chrome.e2e.ts | 27 ++++++++- apps/web/tests/support.ts | 11 +++- packages/client/locale/README.i18n.yaml | 4 +- packages/client/locale/README.md | 2 +- packages/client/locale/README.zh.md | 2 +- packages/client/locale/src/client/index.ts | 42 +++++++++++--- packages/client/locale/tests/apply.spec.ts | 12 +++- packages/client/locale/tests/locale.spec.ts | 55 +++++++++++++++---- packages/client/test-runtime/src/index.ts | 1 + .../client/test-runtime/src/locale-env.ts | 25 +++++++++ packages/client/ui-conversation/package.json | 1 + .../tests/apply-inject.spec.tsx | 10 +++- .../tests/assembly-surfaces.spec.tsx | 8 ++- .../ui-conversation/tests/chat-apply.spec.tsx | 10 +++- packages/client/ui-models/package.json | 1 + packages/client/ui-models/tests/apply.spec.ts | 9 ++- .../client/ui-settings-general/package.json | 1 + .../ui-settings-general/tests/apply.spec.ts | 9 ++- .../tests/sidebar-snapshot.spec.tsx | 10 +++- packages/client/ui-slash/tests/apply.spec.ts | 9 ++- packages/client/ui-theme/package.json | 1 + packages/client/ui-theme/tests/apply.spec.ts | 9 ++- .../client/ui-workspace/tests/apply.spec.ts | 9 ++- .../tests/rename-assembly.spec.tsx | 8 ++- .../host/directory-picker-browse/package.json | 1 + .../tests/client-flow.spec.tsx | 9 ++- pnpm-lock.yaml | 15 +++++ 39 files changed, 350 insertions(+), 55 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md create mode 100644 .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md create mode 100644 packages/client/test-runtime/src/locale-env.ts diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml index a56a91c980..bca3fb39ad 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.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/architecture/2026-07-30-client-locale-full-rollout.md -2026-07-30-client-locale-full-rollout.md: c080d9f240d4533ecd9694ceecfada8662c46425 -2026-07-30-client-locale-full-rollout.zh.md: 062d982e3d7ea62f3ca4c8fedb842e8336f0852c +2026-07-30-client-locale-full-rollout.md: a357f20734d1aa8df60efbf28fd5b8a1a814d63e +2026-07-30-client-locale-full-rollout.zh.md: d22b743f0597405e7f42374ec2caed5523e85595 diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md index c080d9f240..a357f20734 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md @@ -25,7 +25,7 @@ After the typed locale standard seat landed (`locale:` on register → framework **Derivation layers stay pure; localization happens at render.** ui-workspace's `relativeTime` returns structured `{unit, n}` composed with dictionary templates by the renderer; blank sessions and the Ungrouped bucket keep their stored titles, with the renderer substituting localized copy off the `blank` flag / absent `workspaceId`; **blank rows are excluded from search entirely** (a bilingual display title cannot match a single-language query stably). Dates use no Intl: format templates live in the dictionaries (message clock `clock.md`/`clock.ymd`, workspace hover `date.ymd`) and the formatters take `t` as a parameter, staying pure. -**Test and e2e doctrine**: `makeTranslate(...dicts)` (dsh-client-test-runtime) mirrors the service lookup chain (first-dict-wins, key fallback, `{name}` interpolation); component specs stub the `t` seat with it, typed against real props seats. Web e2e uniformly opens through `newEnglishPage` (pins `dsh.locale=en` before boot) and the built-boot snapshot pins the same — goldens are immune to localization migrations; the settings language-switch scenario deliberately bypasses the helper to cover the zh default. +**Test and e2e doctrine**: `makeTranslate(...dicts)` (dsh-client-test-runtime) mirrors the service lookup chain (first-dict-wins, key fallback, `{name}` interpolation); component specs stub the `t` seat with it, typed against real props seats. Web e2e uniformly opens through `newEnglishPage` (pins `dsh.locale=en` before boot) and the built-boot snapshot pins the same — goldens are immune to localization migrations; the settings language-switch scenario bypasses the helper and opens a `zh-CN` browser, since the initial locale follows `navigator` ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)). The "apply layer subscribes to `locale/change` and re-registers for fresh labels" mechanism in the [settings/locale/theme layering note](../../proposed/architecture/2026-07-25-client-settings-locale-theme.md) is superseded by this decision (thunk + revision lifecycle). diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md index 062d982e3d..d22b743f05 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md @@ -25,7 +25,7 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t` **派生层保持纯函数,本地化只在渲染层**:ui-workspace 的 `relativeTime` 返回结构化 `{unit, n}` 由渲染组合字典模板;blank 会话/未分组桶的存储标题不变,渲染按 `blank` 标志/`workspaceId` 缺席替换本地化文案;**搜索态 blank 行一律排除**(双语标题无法与单语查询稳定匹配)。日期不引 Intl:格式模板进字典(消息时钟 `clock.md`/`clock.ymd`,workspace hover `date.ymd`),格式化函数吃 `t` 参数保持纯。 -**测试与 e2e 口径**:`makeTranslate(...dicts)`(dsh-client-test-runtime)镜像服务查找链(首个命中字典胜出、key 兜底、`{name}` 插值),组件测试的 `t` 桩统一用它并以真实 props 席位定型。web e2e 统一 `newEnglishPage`(boot 前钉 `dsh.locale=en`),built-boot snapshot 同样钉 en——golden 对语言迁移免疫;settings 语言切换用例刻意绕开该 helper 覆盖 zh 默认态。 +**测试与 e2e 口径**:`makeTranslate(...dicts)`(dsh-client-test-runtime)镜像服务查找链(首个命中字典胜出、key 兜底、`{name}` 插值),组件测试的 `t` 桩统一用它并以真实 props 席位定型。web e2e 统一 `newEnglishPage`(boot 前钉 `dsh.locale=en`),built-boot snapshot 同样钉 en——golden 对语言迁移免疫;settings 语言切换用例绕开该 helper 并开启 `zh-CN` 浏览器,因为初始 locale 跟随 `navigator`([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.md))。 [settings/locale/theme 分层 Note](../../proposed/architecture/2026-07-25-client-settings-locale-theme.md) 中"apply 层订阅 `locale/change` 重注册刷新 label"的机制已被本决定取代(thunk + revision 生命周期)。 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 new file mode 100644 index 0000000000..65fdff8572 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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: 7668171f32642248cc0be92741eb3dc90e0669cc +2026-07-31-browser-derived-initial-locale.zh.md: 568737285e3c00d6c9792a0e6e79e38428db18c3 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 new file mode 100644 index 0000000000..7668171f32 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md @@ -0,0 +1,34 @@ +# Agent Note: The Settings language a fresh browser opens in comes from the browser + +Status: implemented + +English | [中文](2026-07-31-browser-derived-initial-locale.zh.md) + +## Problem + +The Settings Language row opened every first visit in Chinese: `LocaleService` 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. + +## Decision + +**The initial locale resolves through three ordered sources: the persisted preference, then the browser, then `FALLBACK_LOCALE`.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and is the only place the order is expressed; `restorePreference()` now returns `LocaleId | undefined` (an absent, unparseable, or unreachable store reads as *no preference*) so the next source can speak. + +**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 because a browser may expose only the former; a runtime without `navigator` at all (node booting the client tree) resolves to the fallback like any unresolvable case. + +**An explicit choice is permanent.** `setLocale` persistence is untouched, and the persisted value is consulted first, so a user who picked a language keeps it even when travelling between browser profiles or system languages. Nothing writes the detected locale back to storage: detection is re-derived every boot and stays invisible to the "has the user chosen?" question. + +**The browser e2e lane now pins the browser language, not just storage.** 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` keeps pinning `dsh.locale=en`, which still wins over any browser language. `settings-chrome.e2e.ts` gained a scenario opening a second `en-US` page with empty storage and asserting the settings surface comes up English — the assembled-app proof of this feature. + +## 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. +- **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 `LocaleService` under jsdom now depend on the environment's `navigator`: the package specs stub it (`zh-CN` as the baseline; the detection spec varies it), and any future spec asserting a default must pin one too. +- Detection cost is one array walk per service construction, and no storage write, so boot behavior and the persisted-state surface are unchanged. 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 new file mode 100644 index 0000000000..568737285e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md @@ -0,0 +1,34 @@ +# Agent Note: 全新浏览器打开的设置语言由浏览器决定 + +Status: implemented + +[English](2026-07-31-browser-derived-initial-locale.md) | 中文 + +## Problem + +设置里的语言行在每一次首访时都以中文开场:`LocaleService` 从 localStorage 读取 `dsh.locale`,读不到就直接回落到 `zh`。浏览器本已声明其使用者阅读哪些语言——`navigator.languages` 就是这份声明——而应用对此视而不见,于是英文读者迎面撞上一个中文产品,还得先找到一行中文标签的设置项才能脱身。回落值当时同时承担两份职责:既是无法解析出 locale 时的最后兜底,也是所有从未做过选择的用户拿到的答案。 + +## Decision + +**初始 locale 依次经三个来源解析:已持久化的偏好、浏览器、`FALLBACK_LOCALE`。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时运行,是这一顺序的唯一表达处;`restorePreference()` 现在返回 `LocaleId | undefined`(存储项缺失、无法解析或不可访问,一律读作*没有偏好*),后一个来源才有开口的机会。 + +**浏览器匹配按主子标签进行,且遍历有序列表。** `detectBrowserLocale()` 遍历 `[...navigator.languages, navigator.language]`,返回主子标签命中已提供 locale 的首个条目,因此 `zh-Hans-CN` 与 `zh-TW` 同归 `zh`、`en-GB` 归 `en`;而只请求本应用不提供的语言(`fr`、`de`)的浏览器则什么都匹配不到,交由 `FALLBACK_LOCALE` 接管。`navigator.language` 排在列表之后,因为有的浏览器只暴露前者;完全没有 `navigator` 的运行环境(node 启动客户端树)与任何无法解析的情形一样落到回落值。 + +**显式选择是永久的。** `setLocale` 的持久化未作改动,且持久化值最先被查询,因此选过语言的用户即便在不同浏览器配置或系统语言之间辗转也保留原选择。没有任何代码把探测到的 locale 写回存储:探测在每次启动时重新推导,对"用户是否做过选择"这一问题始终不可见。 + +**浏览器 e2e 车道现在钉住浏览器语言,而不只是存储项。** 断言中文文案的场景(`access-confirmation`、`models-settings`、`onboarding-deepseek-config`、`settings-chrome`)以 `apps/web/tests/support.ts` 的 `locale: ZH_BROWSER_LOCALE` 打开页面;`newEnglishPage` 仍然钉 `dsh.locale=en`,它依旧压过任何浏览器语言。`settings-chrome.e2e.ts` 新增一个场景:另开一个存储项为空的 `en-US` 页面,断言设置界面以英文呈现——这是本功能在组装后应用中的证据。 + +## Alternatives considered + +- **`Intl.DateTimeFormat().resolvedOptions().locale` 或单读 `navigator.language`**:两者都把用户的有序偏好列表塌缩成一个标签,于是 `['de', 'en', 'zh']` 的读者拿到的是 zh 而非 en。列表恰恰是浏览器这份声明里最值得读的部分。 +- **首次启动即持久化探测结果**:那会把探测变成一次性事件,让一次陈旧的首访凌驾于此后改变的浏览器语言之上,也摧毁了整个解析顺序所依赖的区分——存储值将不再意味着"用户选了它"。 +- **完整的 BCP 47 协商(`Intl.LocaleMatcher` 式查找、地区与文字权重)**:在只提供两个语言互异的 locale 时,主子标签匹配就是正确答案的全部;协商层只会带来无行为支撑、也无从测试的表面积。 +- **为默认 locale 增加一个 cordis config key**:此处部署之间并无差异——回落值是产品对"完全没有信号"给出的答案,不是旋钮。仓库策略把 `Config` 字段留给有当前消费者、且随部署变化的选择。 +- **让 e2e 车道的中文场景继续钉存储项(`dsh.locale=zh`)**:那会让套件保持绿色,却抹掉浏览器推导路径在组装后应用中唯一的运行处;改钉浏览器语言才能端到端地演练新的解析过程。 + +## Consequences + +- 来自英文浏览器的首访落在英文界面,而语言行依然呈现同样两个以自身语言自述的选项,两个方向的脱身通道都未改变。 +- `FALLBACK_LOCALE` 收窄回它真正的职责——字典回落与无信号时的答案——不再兼职充当"用户尚未选择"。 +- 在 jsdom 下构造 `LocaleService` 的测试现在依赖环境的 `navigator`:包内测试对其打桩(以 `zh-CN` 为基线,探测用例则逐个变换),今后任何断言默认值的用例同样必须钉住它。 +- 探测的代价是每次服务构造遍历一次数组,且不写存储,因此启动行为与持久化状态面均无变化。 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index ada8ff7a3e..91d77fb803 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.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/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: 107dbddbfde8ad29e22d9cba04ce2b83c1d01383 -2026-07-24-web-gui-browser-e2e-lane.zh.md: e4132b2ebb3f30a9d540f47cf9416a13bc4aa9f3 +2026-07-24-web-gui-browser-e2e-lane.md: cdb7de52c50733d6650202ee2117916319940738 +2026-07-24-web-gui-browser-e2e-lane.zh.md: b850acf026502d054a9d8b2168f0b4f47f58f39b diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index 107dbddbfd..cdb7de52c5 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -28,7 +28,7 @@ The barrier stack for replay-mode browser assertions is, in order: (1) host-side No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted from the persisted `assistant/chunk` events (model-visible ⟺ logged makes the log the authoritative proof). `dsh-llm-replay`'s opt-in `paceMs` (default absent = burst) is a realism knob so the browser observes genuinely incremental SSE; correctness never leans on it, and abort during a pace wait cancels promptly. -Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios set `dsh.locale=en` before client boot so localized role locators and goldens use one explicit language; `settings-chrome.e2e.ts` alone leaves storage unset to cover the default Chinese state and both switch directions. +Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios set `dsh.locale=en` before client boot so localized role locators and goldens use one explicit language; the scenarios asserting Chinese copy leave storage unset and open a `zh-CN` browser instead, because the client derives its initial locale from `navigator` ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)), and `settings-chrome.e2e.ts` additionally covers both switch directions and the English-browser default. ### Expected outputs diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index e4132b2ebb..b850acf026 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -28,7 +28,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu 不做单次瞬态 DOM 断言:从回放产出到 React 提交的每一跳都可能合并分片,采样 `[data-streaming]` 天然就是竞态。流式输出的增量性由持久化的 `assistant/chunk` 事件断言(模型可见 ⟺ 已记录,使日志成为权威证据)。`dsh-llm-replay` 的可选 `paceMs`(默认缺省 = 突发)只是让浏览器观察到真正增量 SSE 的真实感旋钮;正确性绝不依赖它,且节奏等待期间中止会即时取消。 -每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。常规场景在客户端启动前设置 `dsh.locale=en`,使本地化的 role 定位器和预期输出统一采用明确指定的语言;只有 `settings-chrome.e2e.ts` 不预设该存储项,以覆盖默认中文状态及双向切换。 +每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。常规场景在客户端启动前设置 `dsh.locale=en`,使本地化的 role 定位器和预期输出统一采用明确指定的语言;断言中文文案的场景则不预设该存储项,改为开启 `zh-CN` 浏览器,因为客户端的初始 locale 由 `navigator` 推导([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.md)),而 `settings-chrome.e2e.ts` 还额外覆盖双向切换与英文浏览器默认态。 ### 预期输出 diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts index 89b383da85..d4669bc142 100644 --- a/apps/web/tests/access-confirmation.e2e.ts +++ b/apps/web/tests/access-confirmation.e2e.ts @@ -11,7 +11,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { saveFailureShot } from './support.ts' +import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' /** * connectFreshWorkspace twin over the product default Chinese locale (the @@ -49,7 +49,7 @@ describe('web e2e: Full access confirmation', () => { browser = await chromium.launch(executablePath === undefined ? {} : { executablePath }) // Keep the product default Chinese locale: the golden pins the actual // registered dictionary rather than a test-local translation callback. - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 9c2215ed7f..c46127c9db 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -20,7 +20,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { saveFailureShot } from './support.ts' +import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url)) const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md') @@ -37,7 +37,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => { beforeAll(async () => { scaffold = await launchWebScaffold({}) browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + // The scenario asserts the shipped Chinese copy, so the browser asks for it. + page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 62dd129982..f1cbc77581 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -12,7 +12,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { saveFailureShot } from './support.ts' +import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-deepseek-config', import.meta.url)) const MISSING_EXPECTED = join(SNAPSHOT_DIR, 'missing.expected.md') @@ -28,7 +28,8 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup beforeAll(async () => { scaffold = await launchWebScaffold({ deepSeekMissingCredential: true }) browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1440, height: 960 } }) + // The scenario asserts the shipped Chinese copy, so the browser asks for it. + page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE }) tripwire = watchConsole(page) page.on('console', message => browserConsole.push(message.text())) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index a5aced7de2..c9d6bc3367 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -18,7 +18,7 @@ import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { saveFailureShot } from './support.ts' +import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import.meta.url)) const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md') @@ -33,7 +33,9 @@ describe('web e2e: settings modal and General preferences', () => { beforeAll(async () => { scaffold = await launchWebScaffold({}) browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + // Chinese browser: the shared page asserts the localized settings surface + // the client derives from it (the English default has its own spec below). + page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) @@ -215,6 +217,27 @@ describe('web e2e: settings modal and General preferences', () => { expect(tripwire.pageErrors).toEqual([]) }, 90_000) + it('opens an English browser in English without any stored preference', async () => { + // A second page under a different browser language: nothing is persisted + // for it, so the settings surface must follow the browser rather than the + // product fallback the shared zh page shows. + const enPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' }) + const enTripwire = watchConsole(enPage) + onTestFailed(() => saveFailureShot(enPage, 'web-e2e-settings-browser-language')) + try { + await enPage.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await enPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + expect(await enPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() + await enPage.getByRole('button', { name: 'Settings', exact: true }).click() + const dialog = enPage.getByRole('dialog', { name: 'Settings' }) + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 }) + expect(enTripwire.pageErrors).toEqual([]) + } finally { + await enPage.close() + } + }, 90_000) + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md']) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index aded1aa406..d03915f851 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -9,11 +9,18 @@ export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.met export const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) +/** + * Browser language a page must advertise to boot into the product's Chinese + * surface: with no stored preference the client derives its initial locale + * from the browser, and Playwright's default browser asks for English. + */ +export const ZH_BROWSER_LOCALE = 'zh-CN' + /** * Open the standard browser-test page with English selected before client * boot. This keeps role locators and goldens deterministic across localized - * component migrations; the settings locale scenario deliberately bypasses - * this helper to cover the product's default Chinese state. + * component migrations; the scenarios asserting the Chinese surface bypass + * this helper and advertise {@link ZH_BROWSER_LOCALE} instead. * @param browser - Playwright browser owning the page. * @param height - Viewport height; width is fixed to the lane baseline. * @returns the initialized page. diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index 05f332a9a1..0f4dd20268 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/README.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 packages/client/locale/README.md -README.md: c2adbcabc77def740094288da4643032873aa5b8 -README.zh.md: c6ecb31e21d7513a4e7d579b17588107ccd7ea59 +README.md: 7f780092af9bc7079cc5080c06e986bef2dfdbce +README.zh.md: 62c037977115d33b834fe60b042431e44d208524 diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index c2adbcabc7..7f780092af 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`; `locale/change` fires on switches only) plus the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS`; lookup chain ns → common → zh → key). The service implements the slot system's `LocaleFace` and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). +Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`; with nothing persisted a fresh browser opens in the language `navigator` asks for — matched on the primary subtag, `zh` when it asks for none this app ships; `locale/change` fires on switches only) plus the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS`; lookup chain ns → common → zh → key). The service implements the slot system's `LocaleFace` and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). ## Model Experience diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index c6ecb31e21..62c0379771 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -locale 插件:LocaleService——浏览器 locale 偏好(`zh`/`en`,以 `dsh.locale` 持久化;`locale/change` 仅在切换语言时触发),加上 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS`;查找链 ns → common → zh → key)。该服务实现 slot 系统的 `LocaleFace` 并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。 +locale 插件:LocaleService——浏览器 locale 偏好(`zh`/`en`,以 `dsh.locale` 持久化;未持久化偏好时,全新浏览器以 `navigator` 请求的语言开场——按主子标签匹配,若其请求的语言本应用都不提供则为 `zh`;`locale/change` 仅在切换语言时触发),加上 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS`;查找链 ns → common → zh → key)。该服务实现 slot 系统的 `LocaleFace` 并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。 ## 模型体验 diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index 65eeddb9e2..83fa3a48a9 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -83,7 +83,7 @@ declare module 'cordis' { } } -/** Fallback locale consulted after the active locale misses (also the default). */ +/** Fallback locale consulted after the active locale misses (also the last-resort initial locale). */ export const FALLBACK_LOCALE: LocaleId = 'zh' /** Shared namespace for shell-level texts. */ @@ -123,7 +123,7 @@ export class LocaleService { */ constructor(ctx: Context) { this.ctx = ctx - this.snapshot = Object.freeze({ active: restorePreference(), locales: LOCALES, revision: 0 }) + this.snapshot = Object.freeze({ active: resolveInitialLocale(), locales: LOCALES, revision: 0 }) } /** @@ -288,17 +288,45 @@ export class LocaleService { } } -/** Read the persisted locale id; unknown or unreadable values fall back to zh. */ -function restorePreference(): LocaleId { +/** + * The locale a fresh service opens with: an explicit preference the user + * already chose wins over the browser's own language, which in turn wins over + * {@link FALLBACK_LOCALE} (non-browser boots and browsers set to a language + * this app does not ship). + */ +function resolveInitialLocale(): LocaleId { + return restorePreference() ?? detectBrowserLocale() ?? FALLBACK_LOCALE +} + +/** Read the persisted locale id; unknown or unreadable values read as no preference. */ +function restorePreference(): LocaleId | undefined { // Non-browser runs (node e2e booting the client tree) have no localStorage. - if (typeof localStorage === 'undefined') return FALLBACK_LOCALE + if (typeof localStorage === 'undefined') return undefined try { const stored = localStorage.getItem(STORAGE_KEY) if (stored === 'zh' || stored === 'en') return stored } catch { - // Storage access can throw (privacy mode); the default below covers it. + // Storage access can throw (privacy mode); an unreadable store simply + // records no preference, and the browser language decides instead. } - return FALLBACK_LOCALE + return undefined +} + +/** + * The first shipped locale the browser asks for, matched on the primary + * subtag so every regional variant lands on its language (`zh-Hans-CN` -> zh, + * `en-GB` -> en). `navigator.language` trails the ordered `languages` list + * because a browser may expose only the former. + */ +function detectBrowserLocale(): LocaleId | undefined { + // Non-browser runs (node e2e booting the client tree) have no navigator. + if (typeof navigator === 'undefined') return undefined + for (const tag of [...navigator.languages, navigator.language]) { + const primary = tag.toLowerCase().split('-')[0] + const match = LOCALES.find(locale => locale.id === primary) + if (match) return match.id + } + return undefined } /** Persist the locale id; storage failures are non-fatal (preference resets next boot). */ diff --git a/packages/client/locale/tests/apply.spec.ts b/packages/client/locale/tests/apply.spec.ts index c603cbc5f0..a3007f8c78 100644 --- a/packages/client/locale/tests/apply.spec.ts +++ b/packages/client/locale/tests/apply.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 'cordis' -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-locale/client' import type { LanguageRowInjected, LocaleService } from '@deepseek-ai/dsh-client-locale/client' @@ -36,6 +36,16 @@ function faceOf(slots: SlotsService) { } 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' }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + it('declares the slot service', () => { expect(inject).toEqual(['slots']) }) diff --git a/packages/client/locale/tests/locale.spec.ts b/packages/client/locale/tests/locale.spec.ts index 80fe699e34..fcab3683bc 100644 --- a/packages/client/locale/tests/locale.spec.ts +++ b/packages/client/locale/tests/locale.spec.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' import { LocaleService, STORAGE_KEY } from '@deepseek-ai/dsh-client-locale/client' @@ -11,9 +11,20 @@ const make = (): { ctx: Context; svc: LocaleService; events: LocaleSnapshot[] } return { ctx, svc: new LocaleService(ctx), events } } +/** Pin the browser environment a fresh service reads its initial locale from. */ +const stubLanguages = (...tags: string[]): void => { + vi.stubGlobal('navigator', { languages: tags, language: tags[0] ?? '' }) +} + describe('LocaleService', () => { beforeEach(() => { localStorage.clear() + // A Chinese browser is the baseline these specs assert their zh state on. + stubLanguages('zh-CN') + }) + + afterEach(() => { + vi.unstubAllGlobals() }) it('translates through the active-locale -> zh -> key chain', () => { @@ -132,23 +143,45 @@ describe('LocaleService', () => { expect(() => { svc.setLocale('fr') }).toThrow('not registered') }) - it('restores a persisted locale and falls back to zh on garbage', () => { + it('restores a persisted locale over the browser language, and garbage reads as no preference', () => { localStorage.setItem(STORAGE_KEY, 'en') expect(make().svc.getLocale().active).toBe('en') localStorage.setItem(STORAGE_KEY, 'fr') expect(make().svc.getLocale().active).toBe('zh') }) - it('runs without localStorage (node boots): defaults on read, no-op on write', () => { + it('opens in the browser language when nothing is persisted, matching regional variants on their primary subtag', () => { + stubLanguages('en-GB', 'zh-CN') + expect(make().svc.getLocale().active).toBe('en') + stubLanguages('zh-Hant-TW') + expect(make().svc.getLocale().active).toBe('zh') + // An unshipped language walks the list to the first one this app ships. + stubLanguages('fr-FR', 'en-US') + expect(make().svc.getLocale().active).toBe('en') + // Only `language` populated (browsers that expose no ordered list). + vi.stubGlobal('navigator', { languages: [], 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. + stubLanguages('fr-FR', 'de') + expect(make().svc.getLocale().active).toBe('zh') + }) + + it('runs without localStorage or navigator (node boots): defaults on read, no-op on write', () => { vi.stubGlobal('localStorage', undefined) - try { - const { svc } = make() - expect(svc.getLocale().active).toBe('zh') - svc.setLocale('en') - expect(svc.getLocale().active).toBe('en') - } finally { - vi.unstubAllGlobals() - } + vi.stubGlobal('navigator', undefined) + const { svc } = make() + expect(svc.getLocale().active).toBe('zh') + svc.setLocale('en') + expect(svc.getLocale().active).toBe('en') + }) + + it('keeps the browser language out of the way once a preference exists', () => { + stubLanguages('en-US') + const { svc } = make() + svc.setLocale('zh') + expect(localStorage.getItem(STORAGE_KEY)).toBe('zh') + expect(make().svc.getLocale().active).toBe('zh') }) it('exposes the two shipped locales with self-described labels', () => { diff --git a/packages/client/test-runtime/src/index.ts b/packages/client/test-runtime/src/index.ts index 7100e1595e..6e227539cd 100644 --- a/packages/client/test-runtime/src/index.ts +++ b/packages/client/test-runtime/src/index.ts @@ -38,6 +38,7 @@ export { TestWorkspaces } from './workspaces.ts' export { conversationSnapshot, workspaceListState } from './fixtures.ts' export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts' export { makeTranslate } from './translate.ts' +export { pinBrowserLanguages } from './locale-env.ts' /** Erased register face for the internal root call (the public declare seam holds the typing). */ type ErasedRegister = (options: object, component: unknown) => () => void diff --git a/packages/client/test-runtime/src/locale-env.ts b/packages/client/test-runtime/src/locale-env.ts new file mode 100644 index 0000000000..ff961e3888 --- /dev/null +++ b/packages/client/test-runtime/src/locale-env.ts @@ -0,0 +1,25 @@ +/** + * Browser-language pin for specs that assert localized copy. A fresh + * LocaleService with no stored preference opens in the language `navigator` + * asks for, and jsdom reports the runner's own (`en-US`) — so a spec asserting + * the product's Chinese copy states the browser it assumes instead of + * inheriting the machine's. + */ + +/** + * Override `navigator.languages`/`navigator.language` for the current spec. + * @param primary - most preferred BCP 47 tag; also becomes `navigator.language`. + * @param rest - further tags in preference order. + * @returns restore function handing the properties back to the environment. + */ +export function pinBrowserLanguages(primary: string, ...rest: string[]): () => void { + Object.defineProperty(navigator, 'languages', { value: [primary, ...rest], configurable: true }) + Object.defineProperty(navigator, 'language', { value: primary, configurable: true }) + return () => { + // Deleting the own properties uncovers the environment's own accessors + // again (Navigator declares both readonly, hence the erased receiver). + const own = navigator as unknown as Record + delete own.languages + delete own.language + } +} diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 8a9173982b..22da06fb9c 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -51,6 +51,7 @@ "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 9806850db2..bb0b1ce6f3 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -14,8 +14,8 @@ // guards would mask. Rendering-path acceptance lives in // chat-toolview-slot.spec.tsx. -import { describe, expect, it, vi } from 'vitest' -import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SlotTestRuntime, pinBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client' @@ -25,6 +25,12 @@ import type { } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { createChatStore } from '../src/client/stores.ts' +// The service reads its initial locale from the browser; these specs assert +// the shipped Chinese copy, so they state the browser they assume. +let restoreLanguages: () => void +beforeEach(() => { restoreLanguages = pinBrowserLanguages('zh-CN') }) +afterEach(() => { restoreLanguages() }) + const ROOT = 'root-1' as SessionId type ChatInstance = ReturnType['create']> diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx index a6c48e5ab8..9c00621809 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx @@ -24,9 +24,15 @@ import { cleanup, fireEvent, waitFor, within } from '@testing-library/react' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import type { ISession, SessionId, TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { SlotTestRuntime, pinBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' +// The service reads its initial locale from the browser; these specs assert +// the shipped Chinese copy, so they state the browser they assume. +let restoreLanguages: () => void +beforeEach(() => { restoreLanguages = pinBrowserLanguages('zh-CN') }) +afterEach(() => { restoreLanguages() }) + const SID = 's1' as SessionId /** jsdom has no ResizeObserver; the composer seat publishes its height through one. */ diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 239f690998..d17c6a022a 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -8,13 +8,19 @@ // machinery spec (chat-toolview-slot.spec.tsx) and the shell e2e; this spec // stops at the assembly surface. -import { describe, expect, it, vi } from 'vitest' -import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SlotTestRuntime, pinBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' +// The service reads its initial locale from the browser; these specs assert +// the shipped Chinese copy, so they state the browser they assume. +let restoreLanguages: () => void +beforeEach(() => { restoreLanguages = pinBrowserLanguages('zh-CN') }) +afterEach(() => { restoreLanguages() }) + const ROOT = 'root-1' as SessionId const CHILD = 'child-1' as SessionId diff --git a/packages/client/ui-models/package.json b/packages/client/ui-models/package.json index a649e1308f..025cc4c440 100644 --- a/packages/client/ui-models/package.json +++ b/packages/client/ui-models/package.json @@ -51,6 +51,7 @@ "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-schema-form": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", diff --git a/packages/client/ui-models/tests/apply.spec.ts b/packages/client/ui-models/tests/apply.spec.ts index 6c64a93058..e4e7287502 100644 --- a/packages/client/ui-models/tests/apply.spec.ts +++ b/packages/client/ui-models/tests/apply.spec.ts @@ -1,13 +1,20 @@ /** Models section registration: declaration-aware deferral, the locale-following label thunk, and HMR recovery. */ import { Context } from 'cordis' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { pinBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-models/client' import { ModelsSection } from '../src/client/ModelsSection.tsx' import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx' +// The service reads its initial locale from the browser; these specs assert +// the shipped Chinese copy, so they state the browser they assume. +let restoreLanguages: () => void +beforeEach(() => { restoreLanguages = pinBrowserLanguages('zh-CN') }) +afterEach(() => { restoreLanguages() }) + async function bench() { const ctx = new Context() await ctx.plugin(SlotsService).await() diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index af85a9954f..a5f78c9256 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -48,6 +48,7 @@ "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts index 506a69699a..0089bc3467 100644 --- a/packages/client/ui-settings-general/tests/apply.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -1,13 +1,20 @@ /** Ownerless-copy registrations: the four seats, the dictionaries, thunked labels, and HMR recovery. */ import { Context } from 'cordis' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { pinBrowserLanguages } 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' +// The service reads its initial locale from the browser; these specs assert +// the shipped Chinese copy, so they state the browser they assume. +let restoreLanguages: () => void +beforeEach(() => { restoreLanguages = pinBrowserLanguages('zh-CN') }) +afterEach(() => { restoreLanguages() }) + /** The four seats this plugin fills (slot name → expected component). */ const SEATS = [ ['settings.trigger', TriggerContent], diff --git a/packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx index 5755882071..dece40302b 100644 --- a/packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx @@ -8,12 +8,18 @@ * holes (sidebar.workspaces / sidebar.settings) have no registrant here, so * the snapshots pin the shell chrome itself. */ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, waitFor } from '@testing-library/react' -import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { SlotTestRuntime, pinBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client' +// The service reads its initial locale from the browser; these specs assert +// the shipped Chinese copy, so they state the browser they assume. +let restoreLanguages: () => void +beforeEach(() => { restoreLanguages = pinBrowserLanguages('zh-CN') }) +afterEach(() => { restoreLanguages() }) + afterEach(cleanup) /** diff --git a/packages/client/ui-slash/tests/apply.spec.ts b/packages/client/ui-slash/tests/apply.spec.ts index a0f42c6a22..eb15feaa54 100644 --- a/packages/client/ui-slash/tests/apply.spec.ts +++ b/packages/client/ui-slash/tests/apply.spec.ts @@ -6,13 +6,20 @@ * sessionId, and unregisters on fiber teardown. */ import { Context } from 'cordis' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { pinBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { createScope, scopeOf, SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { apply, inject, SlashService } from '@deepseek-ai/dsh-client-ui-slash/client' import type { MenuViewInjected } from '@deepseek-ai/dsh-client-ui-slash/client' +// The service reads its initial locale from the browser; these specs assert +// the shipped Chinese copy, so they state the browser they assume. +let restoreLanguages: () => void +beforeEach(() => { restoreLanguages = pinBrowserLanguages('zh-CN') }) +afterEach(() => { restoreLanguages() }) + const sid = (k: string): SessionId => k as SessionId async function bench() { diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index e60289b52d..7046f3391b 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -44,6 +44,7 @@ "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/client/ui-theme/tests/apply.spec.ts b/packages/client/ui-theme/tests/apply.spec.ts index ea9da5cfde..59e6e8924e 100644 --- a/packages/client/ui-theme/tests/apply.spec.ts +++ b/packages/client/ui-theme/tests/apply.spec.ts @@ -2,14 +2,21 @@ * locale service, declaration-aware Appearance row registration, snapshot * projection into the row store, and HMR collapse recovery. */ import { Context } from 'cordis' -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { pinBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client' import type { AppearanceRowInjected, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' 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. +let restoreLanguages: () => void +beforeEach(() => { restoreLanguages = pinBrowserLanguages('zh-CN') }) +afterEach(() => { restoreLanguages() }) + const SLOT = 'settings.general.item' async function bench() { diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts index 976bff655f..28153e607d 100644 --- a/packages/client/ui-workspace/tests/apply.spec.ts +++ b/packages/client/ui-workspace/tests/apply.spec.ts @@ -1,12 +1,19 @@ import { Context } from 'cordis' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { pinBrowserLanguages } 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. +let restoreLanguages: () => void +beforeEach(() => { restoreLanguages = pinBrowserLanguages('zh-CN') }) +afterEach(() => { restoreLanguages() }) + async function bench() { const ctx = new Context() await ctx.plugin(SlotsService).await() diff --git a/packages/client/ui-workspace/tests/rename-assembly.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.spec.tsx index 200e3bbd21..9138b88a6c 100644 --- a/packages/client/ui-workspace/tests/rename-assembly.spec.tsx +++ b/packages/client/ui-workspace/tests/rename-assembly.spec.tsx @@ -14,10 +14,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, waitFor, within } from '@testing-library/react' import type { ISession, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { SlotTestRuntime, pinBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' +// The service reads its initial locale from the browser; these specs assert +// the shipped Chinese copy, so they state the browser they assume. +let restoreLanguages: () => void +beforeEach(() => { restoreLanguages = pinBrowserLanguages('zh-CN') }) +afterEach(() => { restoreLanguages() }) + const SID = 's1' as SessionId afterEach(cleanup) diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index a9f3fc2090..ab0cb593dc 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -49,6 +49,7 @@ "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", diff --git a/packages/host/directory-picker-browse/tests/client-flow.spec.tsx b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx index 31ec5a4927..57e8c009cb 100644 --- a/packages/host/directory-picker-browse/tests/client-flow.spec.tsx +++ b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx @@ -1,14 +1,21 @@ // @vitest-environment jsdom import { Context } from 'cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, render, screen } from '@testing-library/react' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { pinBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' import { apply, inject } from '../src/client/index.ts' import { BrowseDirectoryFlow } from '../src/client/flow.ts' +// The service reads its initial locale from the browser; these specs assert +// the shipped Chinese copy, so they state the browser they assume. +let restoreLanguages: () => void +beforeEach(() => { restoreLanguages = pinBrowserLanguages('zh-CN') }) +afterEach(() => { restoreLanguages() }) + afterEach(cleanup) const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6a697dbb7..823fbdbf3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1218,6 +1218,9 @@ importers: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:^ version: link:../ui-layout @@ -1386,6 +1389,9 @@ importers: '@deepseek-ai/dsh-client-schema-form': specifier: workspace:^ version: link:../schema-form + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives @@ -1641,6 +1647,9 @@ importers: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives @@ -1797,6 +1806,9 @@ importers: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives @@ -3308,6 +3320,9 @@ importers: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../../client/runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../client/test-runtime '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../../client/ui-primitives