diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index e01d69ea25..35f29a0e52 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -161,8 +161,10 @@ export class LocaleService { /** Read the persisted locale id; unknown or unreadable values fall back to zh. */ function restorePreference(): LocaleId { + // Non-browser runs (node e2e booting the client tree) have no localStorage. + if (typeof localStorage === 'undefined') return FALLBACK_LOCALE try { - const stored = globalThis.localStorage?.getItem(STORAGE_KEY) + 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. @@ -172,8 +174,9 @@ function restorePreference(): LocaleId { /** Persist the locale id; storage failures are non-fatal (preference resets next boot). */ function persistPreference(id: LocaleId): void { + if (typeof localStorage === 'undefined') return try { - globalThis.localStorage?.setItem(STORAGE_KEY, id) + localStorage.setItem(STORAGE_KEY, id) } catch { // Storage access can throw (privacy mode / quota); the preference simply // does not survive the session. diff --git a/packages/client/locale/tests/locale.spec.ts b/packages/client/locale/tests/locale.spec.ts index 750c2452e8..3f9efaed19 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 } from 'vitest' +import { 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' @@ -80,6 +80,18 @@ describe('LocaleService', () => { expect(make().svc.getLocale().active).toBe('zh') }) + it('runs without localStorage (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() + } + }) + it('exposes the two shipped locales with self-described labels', () => { const { svc } = make() expect(svc.getLocale().locales).toEqual([ diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index 39fee1fb52..7474cd69c3 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -107,7 +107,7 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => { const presenter = new ThemePresenter() presenter.apply(ctx.theme.getTheme()) - const off = ctx.on('theme/change', snapshot => { presenter.apply(snapshot) }) + const off = ctx.on('theme/change', (snapshot) => { presenter.apply(snapshot) }) return () => { off() presenter.dispose() diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 688436c25b..3613476ede 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -41,7 +41,7 @@ export function apply(ctx: ClientContext): void { return ctx.locale.bind(ref.slice(0, colon))(ref.slice(colon + 1)) }, sectionsVersion: () => ctx.slots.getVersion('settings.section'), - subscribeSections: (listener) => ctx.slots.subscribe('settings.section', listener), + subscribeSections: listener => ctx.slots.subscribe('settings.section', listener), sections: () => ctx.slots.entries('settings.section') .map(e => ({ /* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */ diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index b8a75c4bed..5c7ca3fe12 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -87,7 +87,8 @@ export class ThemeService { constructor(ctx: Context) { this.ctx = ctx this.preference = restorePreference() - this.media = globalThis.matchMedia?.('(prefers-color-scheme: dark)') + // Non-browser runs (node e2e booting the client tree) have no matchMedia. + this.media = typeof matchMedia === 'undefined' ? undefined : matchMedia('(prefers-color-scheme: dark)') this.snapshot = this.buildSnapshot() if (this.media !== undefined) { const media = this.media @@ -157,8 +158,9 @@ export class ThemeService { : this.preference // Both built-ins always exist; a registered preference id resolves or has // been reset by its disposer, so the lookup cannot miss. - /* v8 ignore next -- the ?? arm needs a registry without light/dark, which register()/dispose() cannot produce */ - const active = this.themes.find(t => t.id === resolvedId) ?? this.themes[0]! + const active = this.themes.find(t => t.id === resolvedId) + /* v8 ignore next 2 -- needs a registry without light/dark, which register()/dispose() cannot produce */ + if (active === undefined) throw new Error(`theme registry lost "${resolvedId}"`) return Object.freeze({ preference: this.preference, active, @@ -176,8 +178,10 @@ export class ThemeService { /** Read the persisted preference; unknown or unreadable values fall back to the default. */ function restorePreference(): ThemePreference { + // Non-browser runs (node e2e booting the client tree) have no localStorage. + if (typeof localStorage === 'undefined') return DEFAULT_PREFERENCE try { - const stored = globalThis.localStorage?.getItem(STORAGE_KEY) + const stored = localStorage.getItem(STORAGE_KEY) if (stored === 'light' || stored === 'dark' || stored === 'system') return stored } catch { // Storage access can throw (privacy mode); the default below covers it. @@ -187,8 +191,9 @@ function restorePreference(): ThemePreference { /** Persist the preference; storage failures are non-fatal (preference resets next boot). */ function persistPreference(preference: ThemePreference): void { + if (typeof localStorage === 'undefined') return try { - globalThis.localStorage?.setItem(STORAGE_KEY, preference) + localStorage.setItem(STORAGE_KEY, preference) } catch { // Storage access can throw (privacy mode / quota); the preference simply // does not survive the session. diff --git a/packages/client/ui-theme/tests/theme.spec.ts b/packages/client/ui-theme/tests/theme.spec.ts index fdac111e73..c853c9fd67 100644 --- a/packages/client/ui-theme/tests/theme.spec.ts +++ b/packages/client/ui-theme/tests/theme.spec.ts @@ -88,6 +88,18 @@ describe('ThemeService', () => { expect(events.map(e => e.revision)).toEqual([1, 2, 3, 4]) }) + it('runs without localStorage (node boots): defaults on read, no-op on write', () => { + vi.stubGlobal('localStorage', undefined) + try { + const { theme } = make() + expect(theme.getTheme().preference).toBe('system') + theme.setTheme('dark') + expect(theme.getTheme().preference).toBe('dark') + } finally { + vi.unstubAllGlobals() + } + }) + describe('prefers-color-scheme resolution (stubbed matchMedia)', () => { type Listener = () => void const stubMedia = (initialMatches: boolean) => {