fix(locale): gate browser detection on window and tolerate a missing languages list

Node >= 21 exposes a global `navigator` reporting the machine's own language,
so gating detection on `navigator` let a non-browser boot of the client tree
resolve to `en` instead of the documented fallback; `window` is the browser
test. `navigator.languages` is spec-required but absent on some embedders and
older WebViews, where spreading it would throw at boot, so the walk tolerates
its absence and `navigator.language` covers that host.

The per-spec pin boilerplate collapses into one suite-level
`usePinnedBrowserLanguages('zh-CN')`, which owns the rationale in
dsh-client-test-runtime, and the English-browser e2e scenario now clears the
console warnings channel too — its page has no closing inventory spec.
This commit is contained in:
creatixchu
2026-07-31 15:49:59 +08:00
parent cb754a0319
commit e5563ae433
19 changed files with 84 additions and 76 deletions

View File

@@ -315,13 +315,20 @@ function restorePreference(): LocaleId | 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.
* `en-GB` -> en). `window` is the browser test, not `navigator`: Node exposes
* a global `navigator` reporting the machine's own language, which would
* otherwise decide the locale for non-browser runs (node e2e booting the
* client tree). `navigator.language` trails the ordered `languages` list and
* covers its absence on hosts that expose only the single tag.
*/
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]) {
if (typeof window === 'undefined') return undefined
/* oxlint-disable-next-line typescript/no-unnecessary-condition --
* The DOM lib types `languages` as always present; embedders and older
* WebViews ship a Navigator without it, and spreading undefined would
* throw at boot. Same environment-boundary distrust as the localStorage
* guards below. */
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

View File

@@ -11,7 +11,13 @@ 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. */
/**
* Pin the browser environment a fresh service reads its initial locale from.
* This package's own specs stub the globals directly instead of using
* `usePinnedBrowserLanguages` (dsh-client-test-runtime): they need the shapes
* that helper deliberately cannot express — a missing `languages` list, a
* list decoupled from `language`, and a non-browser run with no `window`.
*/
const stubLanguages = (...tags: string[]): void => {
vi.stubGlobal('navigator', { languages: tags, language: tags[0] ?? '' })
}
@@ -158,18 +164,24 @@ describe('LocaleService', () => {
// 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).
// Only `language` populated: an empty ordered list, and a host that
// exposes no `languages` property at all.
vi.stubGlobal('navigator', { languages: [], language: 'en-US' })
expect(make().svc.getLocale().active).toBe('en')
vi.stubGlobal('navigator', { language: 'en-US' })
expect(make().svc.getLocale().active).toBe('en')
// No shipped language anywhere in the browser's preferences: zh remains
// the product default rather than an arbitrary near-match.
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', () => {
it('runs outside a browser (node boots): the fallback decides, the machine language does not, writes no-op', () => {
vi.stubGlobal('localStorage', undefined)
vi.stubGlobal('navigator', undefined)
vi.stubGlobal('window', undefined)
// Node exposes its own global navigator; without a window it must not
// reach the resolution at all.
stubLanguages('en-US')
const { svc } = make()
expect(svc.getLocale().active).toBe('zh')
svc.setLocale('en')