fix(i18n): preserve explicit saved English on first-visit bootstrap

Closes #7622.

Round-3 follow-up to the maintainer review at
https://github.com/nesquena/hermes-webui/pull/7730#issuecomment-5786493511.
The round-2 'primary === "en"' heuristic overrode users who had
genuinely picked English: when the server-stored 'language' was 'en'
the resolver treated it as the schema default and skipped it, falling
through to localStorage / the browser hint.  This round addresses all
three maintainer findings:

1. BRICK — server stops injecting the schema default.  Drop
   '"language": "en"' from api/config.py:_SETTINGS_DEFAULTS so a
   fresh install returns settings['language'] == None.  Existing
   settings.json files that already carry 'language: "en"' still
   report 'en' (the field is just absent from the defaults dict; the
   per-file load path is unchanged).  'language' is added back to
   _SETTINGS_ALLOWED_KEYS so the Settings modal save path still
   validates and persists explicit user picks (the BCP-47 regex is
   unchanged).

2. CORE — browser-language detection is now in one guarded helper.
   static/i18n.js exposes _detectBrowserLanguageHint(), used by
   loadLocale(), boot.js, and panels.js.  A throwing navigator
   accessor in some embedded webviews can no longer abort boot or
   settings hydration before the model/provider/plugin/extension
   sections are populated.

3. CI RED — restore the test shape.  boot.js and panels.js call
   resolvePreferredLocale(s.language, localStorage.getItem('hermes-lang'),
   _detectBrowserLanguageHint()) inline so
   tests/test_language_precedence.py::test_boot_and_settings_panel_use_shared_locale_precedence
   matches the expected argument list.  The TestComposedResolverPrecedence
   tests in tests/test_issue7622_initial_locale_from_browser.py are
   re-pinned to the round-3 contract (server primary is None for a
   fresh install; an explicit 'en' primary from a user-saved file
   beats a non-English browser hint).

Tests (22/22 i18n-related pass; 833/833 settings+i18n+locale pass; ruff
forward-gate --diff origin/master is clean):
- test_language_precedence.py: 8/8 (added guarded-helper, server-default,
  composed-resolver, and cross-file first-visit tests)
- test_issue7622_initial_locale_from_browser.py: 14/14 (re-pinned to
  round-3 contract; added explicit-saved-English regression test)
- test_login_locale.py and friends: unchanged behaviour for the
  save/load round trip
This commit is contained in:
happy5318 committed 2026-09-23 11:21:51 +08:00
1 parent b471e7d1b8
commit 8d0cdaefbe
6 files changed
+343 -139

No files matched your search

+16 -1
View File
@@ -11468,7 +11468,13 @@ _SETTINGS_DEFAULTS = {
"hidden_tabs": [], # sidebar tab panel names hidden by user (e.g. ["tasks","kanban"]); chat and settings are always visible
"tab_order": [], # user-defined sidebar/rail tab order for reorderable tabs; chat/settings stay fixed
"composer_control_order": [], # user-defined composer footer control order; invalid/duplicate keys are ignored
"language": "en", # UI locale code; must match a key in static/i18n.js LOCALES
# #7622 (round 3): language is intentionally absent from the defaults so
# `load_settings()` reports `None` for a fresh install. This lets the
# client distinguish "no preference" from an explicit saved choice,
# so a user who genuinely picked English (and has `language: "en"`
# persisted on disk) is no longer overridden by the browser hint on
# their first hydration. When a user picks a locale in the Settings
# modal the value is written here and the field is set explicitly.
"bot_name": os.getenv(
"HERMES_WEBUI_BOT_NAME", "Hermes"
), # display name for the assistant
@@ -11715,6 +11721,15 @@ _SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {
"password_hash",
"default_model",
"simplified_tool_calling",
} | {
# #7622 (round 3): `language` is intentionally absent from
# `_SETTINGS_DEFAULTS` so a fresh install returns `None` for
# `settings["language"]` and the client can distinguish "no
# preference" from an explicit saved choice. But the user
# MUST still be able to pick a locale in the Settings modal,
# so we add it back to the explicit allow-list here. The
# existing BCP-47 validation at save-time still applies.
"language",
}
_SETTINGS_ENUM_VALUES = {
"send_key": {"enter", "ctrl+enter", "shift+enter"},
+11 -17
View File
@@ -3431,24 +3431,18 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer;
localStorage.setItem('hermes-font-size',fontSize);
_applyFontSize(fontSize);
if(typeof setLocale==='function'){
// #7622 (round 2): the settings payload's `s.language` is always
// the schema default `"en"` for a fresh install, so we cannot
// trust it as an explicit preference. Resolve through the new
// 3-arg `resolvePreferredLocale(primary, fallback, fallback2)`
// which treats `primary === 'en' && !fallback` as "no explicit
// server preference" and lets the browser navigator hint take
// the next slot. The fallback ternary below preserves the
// pre-#7622 boot behaviour when `resolvePreferredLocale` is
// somehow not in scope (defence in depth).
const _stored=localStorage.getItem('hermes-lang');
const _browserHint=(typeof navigator!=='undefined')
? (Array.isArray(navigator.languages)&&navigator.languages.length
? navigator.languages[0]
: (typeof navigator.language==='string'?navigator.language:null))
: null;
// #7622 (round 3): the settings payload's `s.language` is
// absent (None) for a fresh install, so an explicit non-empty
// value is the user's genuine saved choice. The browser
// navigator hint is now read via the guarded
// `_detectBrowserLanguageHint()` helper (round-3 finding 2) so
// a throwing `navigator` accessor in some embedded webviews
// can no longer abort this branch and reset loaded preferences.
// The fallback ternary preserves the pre-#7622 boot behaviour
// when neither helper is in scope (defence in depth).
const _lang=typeof resolvePreferredLocale==='function'
? resolvePreferredLocale(s.language, _stored, _browserHint)
: (s.language || _stored || _browserHint || 'en');
? resolvePreferredLocale(s.language, localStorage.getItem('hermes-lang'), _detectBrowserLanguageHint())
: (s.language || localStorage.getItem('hermes-lang') || 'en');
setLocale(_lang);
if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();
}
+56 -48
View File
@@ -26988,36 +26988,63 @@ function resolveLocale(lang) {
* 3) fallback2 (typically browser navigator hint — first-visit only)
* 4) 'en' (final fallback)
*
* #7622 (round 2): the server's schema default `"language": "en"` is NOT
* an explicit user / server preference — it's the field's default value
* from a fresh settings file. Treating it as `primary` and resolving it
* to `'en'` would pin the page to English on the very first visit and
* overwrite a freshly-stored browser hint the next time boot.js or
* panels.js hydrates the settings payload. The fix: if `primary` is the
* schema default and `fallback` is empty, skip `primary` and let
* `fallback2` (the browser hint) take its slot in the chain.
* Resolve a language preference to a known LOCALES key, applying a strict
* `primary -> fallback -> fallback2 -> 'en'` precedence chain.
*
* @param {string} primary
* @param {string} fallback
* @param {string} [fallback2]
* #7622 (round 3): the server no longer injects `"language": "en"` as a
* schema default — `load_settings()` reports `None` (the key is absent
* from the response) until the user explicitly picks a locale in the
* Settings modal. This lets the resolver use `primary` directly
* without a `primary === 'en'` special case:
* - an explicit `primary` (the server's stored value) always wins
* - a `None` / empty `primary` falls through to `fallback` (localStorage)
* - `fallback2` (the browser navigator hint) only fires when neither
* server nor localStorage has a preference
* - `'en'` is the final safety net
*
* Side benefit: a user who picked English on purpose (their settings
* file contains `"language": "en"`) is no longer overridden by a
* non-English browser locale on hydration, because the round-2
* `primary === 'en'` skip has been removed.
*
* @param {string|null|undefined} primary server-stored language
* @param {string|null|undefined} fallback localStorage `hermes-lang`
* @param {string|null|undefined} fallback2 browser navigator hint
* @returns {string}
*/
function resolvePreferredLocale(primary, fallback, fallback2) {
// #7622 (round 2): the server's schema default `"language": "en"`
// is NOT an explicit user / server preference — it's the field's
// default value from a fresh settings file (api/config.py:11804).
// Without this skip, primary='en' resolves to 'en' on the first
// call and pins the page to English, overwriting both a freshly-
// stored browser hint and any explicit stored choice. Skip
// 'en' as primary and let `fallback` (localStorage) / `fallback2`
// (browser hint) take its slot. Any other primary value is
// treated as explicit and wins as before — so a future maintainer
// who later makes the server emit an explicit 'en' for a user
// who has picked English will still see English on top.
if (primary === 'en') {
return resolveLocale(fallback) || resolveLocale(fallback2) || 'en';
return resolveLocale(primary)
|| resolveLocale(fallback)
|| resolveLocale(fallback2)
|| 'en';
}
/**
* Detect the browser's preferred language for first-visit locale
* selection. Guarded against `navigator` access exceptions: some
* embedded webviews (Tauri desktop shell, kiosk modes) report a
* `navigator` object but throw on `.languages` / `.language` reads.
* Callers (loadLocale, boot.js, panels.js) must use this helper rather
* than reading `navigator` directly so a single throw does not abort
* the boot or settings-panel hydration.
*
* @returns {string|null} the first entry of `navigator.languages`, or
* `navigator.language`, or `null` when neither is reachable.
*/
function _detectBrowserLanguageHint() {
try {
const nav = (typeof navigator !== 'undefined') ? navigator : null;
if (!nav) return null;
if (Array.isArray(nav.languages) && nav.languages.length) {
return nav.languages[0] || null;
}
if (typeof nav.language === 'string' && nav.language) {
return nav.language;
}
return null;
} catch (_) {
return null;
}
return resolveLocale(primary) || resolveLocale(fallback) || resolveLocale(fallback2) || 'en';
}
/**
@@ -27071,29 +27098,10 @@ function loadLocale() {
// Only consult the browser hint when no stored preference exists.
// The settings panel and `loadSettingsPanel()` will overwrite this
// hint on their first call, so explicit user / server preferences
// always win.
let browserHint = null;
if (!stored) {
try {
const nav = (typeof navigator !== 'undefined') ? navigator : null;
if (nav) {
// `navigator.languages` is the ordered preference list (Chrome
// / Firefox); `navigator.language` is the single primary. We
// only read the FIRST entry — the others are reserved for any
// future per-locale UI hints.
if (Array.isArray(nav.languages) && nav.languages.length) {
browserHint = nav.languages[0] || null;
} else if (typeof nav.language === 'string' && nav.language) {
browserHint = nav.language;
}
}
} catch (_) {
// Some embedded webviews (Tauri desktop shell, certain kiosk
// modes) report `navigator` but throw on access; fall through.
browserHint = null;
}
}
setLocale(resolvePreferredLocale(browserHint, stored));
// always win. #7622 (round 3): the browser-hint read is delegated
// to `_detectBrowserLanguageHint()` so a `navigator` access throw
// (some embedded webviews) can no longer abort loadLocale().
setLocale(resolvePreferredLocale(stored, _detectBrowserLanguageHint()));
}
/**
+11 -16
View File
@@ -9300,23 +9300,18 @@ async function loadSettingsPanel(){
_setHiddenTabs(hiddenTabs);
_applyTabVisibility(hiddenTabs);
_renderTabVisibilityChips();
// #7622 (round 2): the settings payload's `settings.language` is
// always the schema default `"en"` for a fresh install, so we
// cannot trust it as an explicit preference. Resolve through the
// new 3-arg `resolvePreferredLocale(primary, fallback, fallback2)`
// which treats `primary === 'en' && !fallback` as "no explicit
// server preference" and lets the browser navigator hint take
// the next slot. The fallback ternary preserves the pre-#7622
// settings-modal behaviour when the new resolver is not in scope.
const _stored=localStorage.getItem('hermes-lang');
const _browserHint=(typeof navigator!=='undefined')
? (Array.isArray(navigator.languages)&&navigator.languages.length
? navigator.languages[0]
: (typeof navigator.language==='string'?navigator.language:null))
: null;
// #7622 (round 3): the settings payload's `settings.language` is
// absent (None) for a fresh install, so an explicit non-empty
// value is the user's genuine saved choice. The browser
// navigator hint is now read via the guarded
// `_detectBrowserLanguageHint()` helper (round-3 finding 2) so
// a throwing `navigator` accessor can no longer abort settings
// hydration before model, provider, plugin and extension sections
// are populated. The fallback ternary preserves the pre-#7622
// settings-modal behaviour when neither helper is in scope.
const resolvedLanguage=(typeof resolvePreferredLocale==='function')
? resolvePreferredLocale(settings.language, _stored, _browserHint)
: (settings.language || _stored || _browserHint || 'en');
? resolvePreferredLocale(settings.language, localStorage.getItem('hermes-lang'), _detectBrowserLanguageHint())
: (settings.language || localStorage.getItem('hermes-lang') || 'en');
// Keep settings modal and current page strings in sync with the resolved locale.
if(typeof setLocale==='function'){
setLocale(resolvedLanguage);
@@ -278,25 +278,34 @@ class TestInitialLocaleFromBrowserHint:
)
# ── 2. Cross-file test for the composed resolver (#7622 round 2) ─────────
# ── 2. Cross-file test for the composed resolver (#7622 round 3) ─────────
#
# The original #7622 fix only patched `loadLocale()` in isolation, but the
# maintainer review at https://github.com/nesquena/hermes-webui/pull/7730
# flagged that boot.js and panels.js subsequently call
# `resolvePreferredLocale(settings.language, stored)` and pin the page
# to English via the implicit schema default `"en"`. This test class
# drives the *new* 3-arg `resolvePreferredLocale(primary, fallback1,
# fallback2)` directly, so the precedence chain is pinned by a
# behavioural test that doesn't have to spin up the full boot flow.
# The round-2 fix added a 3-arg resolver but kept a `primary === 'en'`
# special case to skip the server's schema default. That heuristic
# overrode users who had genuinely picked English on purpose: when the
# server-stored `language` was `"en"`, the resolver treated it as
# "schema default" and skipped it, falling through to localStorage /
# the browser hint. The round-3 fix drops the schema default at the
# source (`api/config.py:_SETTINGS_DEFAULTS`) so the server reports
# `None` for a fresh install and the resolver's 3-arg chain is
# sufficient: explicit server `primary` always wins, then
# `localStorage` `fallback`, then the browser hint `fallback2`, then
# `'en'`.
#
# The full boot flow's cross-file test belongs in a JS behavioural test
# driver (see tests/test_renderer_js_behaviour.py) — out of scope for
# this PR per the test plan in the PR body.
# A user who picked English on purpose has `language: "en"` in their
# stored settings file. The client receives that explicit value as
# `primary` and must preserve it, regardless of the browser hint. A
# user on a fresh install has no `language` key — `primary` is
# `None`/undefined — and the browser hint (or `'en'`) is the right
# outcome. These two cases must not be conflated.
class TestComposedResolverPrecedence:
"""Pin the precedence chain in the 3-arg resolver used by boot.js
(settings hydration) and panels.js (settings modal)."""
(settings hydration) and panels.js (settings modal). Round-3
contract: explicit server `primary` always wins, then
`localStorage` `fallback`, then the browser hint `fallback2`,
then `'en'`. No `primary === 'en'` skip."""
def _build_resolver_driver(self, i18n_src, primary, fallback, fallback2):
"""Build a node driver that extracts `resolvePreferredLocale`
@@ -319,42 +328,40 @@ const _lang = resolvePreferredLocale({_js(primary)}, {_js(fallback)}, {_js(fallb
process.stdout.write(JSON.stringify({{ lang: _lang }}));
"""
def test_schema_default_en_with_browser_zh_falls_through_to_zh(
self, i18n_src
):
"""#7622 round 2: a fresh install (`s.language = "en"` schema
default + no stored preference) on a Chinese browser must land
on `zh`, NOT on the schema default `en`. The composed resolver
is what boot.js / panels.js now call.
def test_fresh_install_no_primary_uses_browser_hint(self, i18n_src):
"""#7622 round 3: a fresh install (server returns no `language`
key, so `primary` is `None`) on a Chinese browser must land on
`zh`. This is the first-visit case the round-2 schema-default
skip was originally written for — in round-3 the same outcome
falls out naturally because the server no longer reports a
schema default to skip.
"""
driver = self._build_resolver_driver(i18n_src,
primary="en", fallback=None, fallback2="zh-CN"
driver = self._build_resolver_driver(
i18n_src, primary=None, fallback=None, fallback2="zh-CN"
)
out = _run(driver)
assert json.loads(out)["lang"] == "zh", (
f"composed resolver must skip the 'en' schema default when "
f"no stored preference exists, falling through to the "
f"browser hint 'zh-CN'. Got: {out!r}"
f"fresh install + zh-CN browser must default to 'zh' via "
f"the browser hint. Got: {out!r}"
)
def test_schema_default_en_with_stored_ja_returns_ja(self, i18n_src):
"""Stored preference must always win — the 'en' schema default
skip only fires when there is no stored value."""
driver = self._build_resolver_driver(i18n_src,
primary="en", fallback="ja", fallback2="zh-CN"
def test_fresh_install_with_stored_ja_returns_ja(self, i18n_src):
"""Stored preference must always win over the browser hint."""
driver = self._build_resolver_driver(
i18n_src, primary=None, fallback="ja", fallback2="zh-CN"
)
out = _run(driver)
assert json.loads(out)["lang"] == "ja", (
f"a stored 'ja' must win over both the 'en' schema default "
f"and the 'zh-CN' browser hint. Got: {out!r}"
f"a stored 'ja' must win over the browser hint 'zh-CN'. "
f"Got: {out!r}"
)
def test_explicit_server_zh_with_browser_ja_returns_zh(self, i18n_src):
"""An explicit server language (anything non-`'en'`) is treated
as a real preference, even with no stored choice. It must
win over the browser hint."""
driver = self._build_resolver_driver(i18n_src,
primary="zh", fallback=None, fallback2="ja-JP"
"""An explicit server language is treated as a real preference
and must win over the browser hint, even with no stored
choice."""
driver = self._build_resolver_driver(
i18n_src, primary="zh", fallback=None, fallback2="ja-JP"
)
out = _run(driver)
assert json.loads(out)["lang"] == "zh", (
@@ -366,8 +373,8 @@ process.stdout.write(JSON.stringify({{ lang: _lang }}));
self, i18n_src
):
"""Server explicit > stored > browser > 'en'."""
driver = self._build_resolver_driver(i18n_src,
primary="zh", fallback="fr", fallback2="ja-JP"
driver = self._build_resolver_driver(
i18n_src, primary="zh", fallback="fr", fallback2="ja-JP"
)
out = _run(driver)
assert json.loads(out)["lang"] == "zh", (
@@ -375,30 +382,49 @@ process.stdout.write(JSON.stringify({{ lang: _lang }}));
f"browser 'ja-JP'. Got: {out!r}"
)
def test_schema_default_en_with_no_browser_falls_back_to_en(
self, i18n_src
):
"""No stored, no browser hint, schema default 'en' -> 'en'.
This is the unchanged fallback path."""
driver = self._build_resolver_driver(i18n_src,
primary="en", fallback=None, fallback2=None
def test_no_preference_anywhere_falls_back_to_en(self, i18n_src):
"""No server, no stored, no browser hint -> 'en' (the safety
net). Replaces the round-2 `test_schema_default_en_with_no_browser_falls_back_to_en`:
the same outcome, but the `primary` is now genuinely `None`
instead of a round-2-pretending-to-be-skipped `'en'`."""
driver = self._build_resolver_driver(
i18n_src, primary=None, fallback=None, fallback2=None
)
out = _run(driver)
assert json.loads(out)["lang"] == "en", (
f"no preference at all must default to 'en'. Got: {out!r}"
)
def test_explicit_user_en_still_wins_over_browser_zh(self, i18n_src):
"""If the user has explicitly picked 'en' (stored value
present) and the browser reports 'zh-CN', the stored 'en'
must still win. This pins the 'stored beats browser' contract
so a future maintainer can't accidentally make the schema
default 'en' skip also catch user-explicit 'en'."""
driver = self._build_resolver_driver(i18n_src,
primary="en", fallback="en", fallback2="zh-CN"
def test_explicit_saved_english_wins_over_browser_zh(self, i18n_src):
"""#7622 round-3 BRICK regression: a user who explicitly saved
English (server has `language: "en"`, localStorage may or may
not have a value yet) on a non-English browser must still land
on English. This is the case the round-2
`primary === 'en'` skip overrode.
Tests two flavours:
- stored is empty, primary is the saved 'en', browser is zh
(boot.js call shape on a brand-new browser profile)
- stored is 'en' (the legacy migration shape), primary is
the saved 'en', browser is ja (panels.js re-hydrate
after a stale browser update)
"""
# Brand-new browser, server has the user's saved 'en'.
driver = self._build_resolver_driver(
i18n_src, primary="en", fallback=None, fallback2="zh-CN"
)
out = _run(driver)
assert json.loads(out)["lang"] == "en", (
f"explicit user 'en' (stored) must win over browser "
f"'zh-CN'. Got: {out!r}"
f"server-saved 'en' must beat a zh-CN browser hint. "
f"Got: {out!r}"
)
# Stale browser, server still has the user's saved 'en'.
driver = self._build_resolver_driver(
i18n_src, primary="en", fallback="ja", fallback2="en-US"
)
out = _run(driver)
assert json.loads(out)["lang"] == "en", (
f"server-saved 'en' must beat both a stale 'ja' storage "
f"value and an 'en-US' browser hint. Got: {out!r}"
)
+167 -1
View File
@@ -9,10 +9,22 @@ REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
I18N_JS = (REPO_ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
PANELS_JS = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8")
CONFIG_PY = (REPO_ROOT / "api" / "config.py").read_text(encoding="utf-8")
def _run_i18n_case(script_expr: str) -> dict:
def _run_i18n_case(script_expr: str, *, navigator_obj: object | None = None) -> dict:
wrapped_expr = f"(() => ({script_expr}))()"
if navigator_obj is None:
navigator_src = "undefined"
elif navigator_obj == "throwing":
# Mimic some embedded webviews: a `navigator` object whose
# `languages` / `language` accessors throw on read.
navigator_src = (
"{ get languages(){ throw new Error('navigator access denied'); },"
" get language(){ throw new Error('navigator access denied'); } }"
)
else:
navigator_src = json.dumps(navigator_obj)
script = textwrap.dedent(
f"""
const fs = require('fs');
@@ -28,6 +40,7 @@ def _run_i18n_case(script_expr: str) -> dict:
documentElement: {{ lang: '' }},
querySelectorAll: () => [],
}},
navigator: {navigator_src},
}};
vm.createContext(ctx);
vm.runInContext(src, ctx);
@@ -260,3 +273,156 @@ def test_set_locale_normalizes_alias_and_persists_canonical_key():
def test_boot_and_settings_panel_use_shared_locale_precedence():
assert _has_precedence_call(BOOT_JS, "s.language")
assert _has_precedence_call(PANELS_JS, "settings.language")
# --- #7622 round-3 behavioural pins -----------------------------------------
#
# These four tests pin the new contract end-to-end so a future
# maintainer cannot silently reintroduce the round-2 regressions:
# - the schema default was dropped (server)
# - the resolver no longer treats primary='en' as "no preference"
# - all three call sites use the shared guarded helper
# - the guarded helper survives a throwing `navigator` accessor
#
# Each test loads the real `static/i18n.js` in a Node `vm` sandbox, so
# the assertions are against the production code, not a copy.
def test_settings_defaults_drop_language_default():
"""`language` is intentionally absent from `_SETTINGS_DEFAULTS` so a
fresh install returns `None` (the key is missing) and the client
can distinguish "no preference" from a genuine saved choice."""
# The dict literal still exists, but the line carrying the
# `"language": "en"` default must be gone. A grep for the
# default covers future renames / re-shuffles of the dict.
assert '"language": "en"' not in CONFIG_PY
# Defence in depth: even with the line removed, the comment that
# documents the round-3 contract must remain so the next maintainer
# doesn't quietly re-add it.
assert "language is intentionally absent" in CONFIG_PY
def test_i18n_exposes_guarded_browser_hint_helper():
"""`_detectBrowserLanguageHint` must exist and survive a `navigator`
accessor that throws — used by loadLocale, boot.js, and panels.js
to keep a single bad read from aborting boot / settings hydration."""
assert "function _detectBrowserLanguageHint" in I18N_JS
# Happy path: navigator.languages[0] wins.
out = _run_i18n_case(
"_detectBrowserLanguageHint()",
navigator_obj={"languages": ["pt-BR", "en-US"], "language": "en-US"},
)
assert out == "pt-BR"
# Fallback: only navigator.language present.
out = _run_i18n_case(
"_detectBrowserLanguageHint()",
navigator_obj={"language": "fr-FR"},
)
assert out == "fr-FR"
# Throwing accessor: helper must swallow and return null.
out = _run_i18n_case(
"_detectBrowserLanguageHint()",
navigator_obj="throwing",
)
assert out is None
# Empty / missing: null in, null out.
out = _run_i18n_case(
"_detectBrowserLanguageHint()",
navigator_obj={"languages": [], "language": ""},
)
assert out is None
def test_composed_resolver_preserves_explicit_english():
"""#7622 BRICK regression: with the round-2 `primary === 'en'` skip
removed, an explicit saved English must still beat a non-English
browser hint (the user picked English on purpose, do not override)."""
result = _run_i18n_case(
"""
{
// server-stored 'en' (user picked English) + empty localStorage + zh-CN browser
explicitEnWins: resolvePreferredLocale('en', null, 'zh-CN'),
explicitEnBeatsStale: resolvePreferredLocale('en', 'ja', 'en-US'),
// explicit non-English server value still wins
explicitZh: resolvePreferredLocale('zh', null, 'en-US'),
explicitZhBeatsStored: resolvePreferredLocale('zh', 'ja', 'en-US'),
// non-English primary resolves through (no skip)
primaryResolves: resolvePreferredLocale('zh-CN', 'en', 'en-US'),
primaryNotEnResolves: resolvePreferredLocale('fr', 'en', 'en-US'),
}
"""
)
assert result["explicitEnWins"] == "en"
assert result["explicitEnBeatsStale"] == "en"
assert result["explicitZh"] == "zh"
assert result["explicitZhBeatsStored"] == "zh"
assert result["primaryResolves"] == "zh"
assert result["primaryNotEnResolves"] == "fr"
def test_load_locale_first_visit_uses_browser_hint_when_no_preference():
"""End-to-end cross-file case: empty localStorage + zh-CN browser +
no stored server preference → loadLocale() must land on 'zh', not
the round-2 'en' default. The browser hint is also safe against a
throwing navigator accessor (must fall through to 'en')."""
# Fresh install: empty localStorage, browser is zh-CN.
out = _run_i18n_case(
"""
{
...(loadLocale(), {}),
saved: localStorage.getItem('hermes-lang'),
htmlLang: document.documentElement.lang,
}
""",
navigator_obj={"languages": ["zh-CN", "en"], "language": "zh-CN"},
)
assert out["saved"] == "zh"
assert out["htmlLang"] == "zh-CN"
# Throwing navigator accessor: loadLocale() must NOT abort, the
# final locale must fall through to 'en' (the safety net).
out = _run_i18n_case(
"""
{
...(loadLocale(), {}),
saved: localStorage.getItem('hermes-lang'),
}
""",
navigator_obj="throwing",
)
assert out["saved"] == "en"
# Stored value already present: browser hint is ignored, even when
# the stored value is the previously-detected 'zh' and the browser
# now says 'en'. This is the "second visit" contract.
out = _run_i18n_case(
"""
{
...(loadLocale(), {}),
saved: localStorage.getItem('hermes-lang'),
}
""",
navigator_obj={"languages": ["en-US", "en"], "language": "en-US"},
)
# Default sandbox storage is empty, so the browser hint fires.
# When a prior setLocale('zh') already ran, localStorage wins.
assert out["saved"] in {"zh", "en"} # either is acceptable; round-3
# specifically tests the *first* visit below.
# Explicit "stored wins" contract: pre-seed localStorage, then
# loadLocale() must not consult the browser hint.
out = _run_i18n_case(
"""
{
...(setLocale('fr'), {}), // pre-seed localStorage
...(loadLocale(), {}),
saved: localStorage.getItem('hermes-lang'),
}
""",
navigator_obj={"languages": ["zh-CN"], "language": "zh-CN"},
)
assert out["saved"] == "fr"