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
429 lines
13 KiB
Python
429 lines
13 KiB
Python
import json
|
|
import pathlib
|
|
import re
|
|
import subprocess
|
|
import textwrap
|
|
|
|
|
|
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, *, 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');
|
|
const vm = require('vm');
|
|
const src = fs.readFileSync({json.dumps(str(REPO_ROOT / "static" / "i18n.js"))}, 'utf8');
|
|
const storage = {{}};
|
|
const ctx = {{
|
|
localStorage: {{
|
|
getItem: (k) => Object.prototype.hasOwnProperty.call(storage, k) ? storage[k] : null,
|
|
setItem: (k, v) => {{ storage[k] = String(v); }},
|
|
}},
|
|
document: {{
|
|
documentElement: {{ lang: '' }},
|
|
querySelectorAll: () => [],
|
|
}},
|
|
navigator: {navigator_src},
|
|
}};
|
|
vm.createContext(ctx);
|
|
vm.runInContext(src, ctx);
|
|
const out = vm.runInContext({json.dumps(wrapped_expr)}, ctx);
|
|
process.stdout.write(JSON.stringify(out));
|
|
"""
|
|
)
|
|
proc = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
return json.loads(proc.stdout)
|
|
|
|
|
|
def _extract_call_arglists(src: str, fn_name: str) -> list[str]:
|
|
token = f"{fn_name}("
|
|
out = []
|
|
search_from = 0
|
|
|
|
while True:
|
|
start = src.find(token, search_from)
|
|
if start < 0:
|
|
return out
|
|
|
|
i = start + len(token)
|
|
depth = 1
|
|
in_single = False
|
|
in_double = False
|
|
in_backtick = False
|
|
escape = False
|
|
|
|
while i < len(src):
|
|
ch = src[i]
|
|
|
|
if escape:
|
|
escape = False
|
|
i += 1
|
|
continue
|
|
|
|
if in_single:
|
|
if ch == "\\":
|
|
escape = True
|
|
elif ch == "'":
|
|
in_single = False
|
|
i += 1
|
|
continue
|
|
|
|
if in_double:
|
|
if ch == "\\":
|
|
escape = True
|
|
elif ch == '"':
|
|
in_double = False
|
|
i += 1
|
|
continue
|
|
|
|
if in_backtick:
|
|
if ch == "\\":
|
|
escape = True
|
|
elif ch == "`":
|
|
in_backtick = False
|
|
i += 1
|
|
continue
|
|
|
|
if ch == "'":
|
|
in_single = True
|
|
elif ch == '"':
|
|
in_double = True
|
|
elif ch == "`":
|
|
in_backtick = True
|
|
elif ch == "(":
|
|
depth += 1
|
|
elif ch == ")":
|
|
depth -= 1
|
|
if depth == 0:
|
|
out.append(src[start + len(token) : i])
|
|
break
|
|
i += 1
|
|
|
|
search_from = start + len(token)
|
|
|
|
|
|
def _split_top_level_args(arg_src: str) -> list[str]:
|
|
args = []
|
|
cur = []
|
|
paren = 0
|
|
brace = 0
|
|
bracket = 0
|
|
in_single = False
|
|
in_double = False
|
|
in_backtick = False
|
|
escape = False
|
|
|
|
for ch in arg_src:
|
|
if escape:
|
|
cur.append(ch)
|
|
escape = False
|
|
continue
|
|
|
|
if in_single:
|
|
cur.append(ch)
|
|
if ch == "\\":
|
|
escape = True
|
|
elif ch == "'":
|
|
in_single = False
|
|
continue
|
|
|
|
if in_double:
|
|
cur.append(ch)
|
|
if ch == "\\":
|
|
escape = True
|
|
elif ch == '"':
|
|
in_double = False
|
|
continue
|
|
|
|
if in_backtick:
|
|
cur.append(ch)
|
|
if ch == "\\":
|
|
escape = True
|
|
elif ch == "`":
|
|
in_backtick = False
|
|
continue
|
|
|
|
if ch == "'":
|
|
in_single = True
|
|
cur.append(ch)
|
|
continue
|
|
if ch == '"':
|
|
in_double = True
|
|
cur.append(ch)
|
|
continue
|
|
if ch == "`":
|
|
in_backtick = True
|
|
cur.append(ch)
|
|
continue
|
|
|
|
if ch == "(":
|
|
paren += 1
|
|
cur.append(ch)
|
|
continue
|
|
if ch == ")":
|
|
paren -= 1
|
|
cur.append(ch)
|
|
continue
|
|
if ch == "{":
|
|
brace += 1
|
|
cur.append(ch)
|
|
continue
|
|
if ch == "}":
|
|
brace -= 1
|
|
cur.append(ch)
|
|
continue
|
|
if ch == "[":
|
|
bracket += 1
|
|
cur.append(ch)
|
|
continue
|
|
if ch == "]":
|
|
bracket -= 1
|
|
cur.append(ch)
|
|
continue
|
|
|
|
if ch == "," and paren == 0 and brace == 0 and bracket == 0:
|
|
args.append("".join(cur).strip())
|
|
cur = []
|
|
continue
|
|
|
|
cur.append(ch)
|
|
|
|
if cur:
|
|
args.append("".join(cur).strip())
|
|
return args
|
|
|
|
|
|
def _has_precedence_call(src: str, first_arg: str) -> bool:
|
|
expected_second = {
|
|
"localStorage.getItem('hermes-lang')",
|
|
'localStorage.getItem("hermes-lang")',
|
|
}
|
|
for arg_src in _extract_call_arglists(src, "resolvePreferredLocale"):
|
|
args = _split_top_level_args(arg_src)
|
|
if len(args) < 2:
|
|
continue
|
|
first = re.sub(r"\s+", "", args[0])
|
|
second = re.sub(r"\s+", "", args[1])
|
|
if first == first_arg and second in expected_second:
|
|
return True
|
|
return False
|
|
|
|
|
|
def test_i18n_exposes_locale_resolvers():
|
|
assert "function resolveLocale(" in I18N_JS
|
|
assert "function resolvePreferredLocale(" in I18N_JS
|
|
|
|
|
|
def test_locale_alias_resolution_and_precedence_logic():
|
|
result = _run_i18n_case(
|
|
"""
|
|
{
|
|
zhCn: resolveLocale('zh-CN'),
|
|
zhTw: resolveLocale('zh_TW'),
|
|
enUs: resolveLocale('EN-us'),
|
|
esMx: resolveLocale('es-MX'),
|
|
bad: resolveLocale('xx-YY'),
|
|
preferred1: resolvePreferredLocale('zh-CN', 'en'),
|
|
preferred2: resolvePreferredLocale('xx-YY', 'zh-Hant'),
|
|
preferred3: resolvePreferredLocale('', 'xx-YY'),
|
|
}
|
|
"""
|
|
)
|
|
assert result["zhCn"] == "zh"
|
|
assert result["zhTw"] == "zh-Hant"
|
|
assert result["enUs"] == "en"
|
|
assert result["esMx"] == "es"
|
|
assert result["bad"] is None
|
|
assert result["preferred1"] == "zh"
|
|
assert result["preferred2"] == "zh-Hant"
|
|
assert result["preferred3"] == "en"
|
|
|
|
|
|
def test_set_locale_normalizes_alias_and_persists_canonical_key():
|
|
result = _run_i18n_case(
|
|
"""
|
|
{
|
|
...(setLocale('zh-CN'), {}),
|
|
saved: localStorage.getItem('hermes-lang'),
|
|
htmlLang: document.documentElement.lang,
|
|
}
|
|
"""
|
|
)
|
|
assert result["saved"] == "zh"
|
|
assert result["htmlLang"] == "zh-CN"
|
|
|
|
|
|
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"
|