fix(web): persist general preferences in host settings
This commit is contained in:
@@ -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/ui-theme/README.md
|
||||
README.md: 32868bcac4313a3badfe92dbf41c84e793f09709
|
||||
README.zh.md: a38765b8004826133875c38deeb66128d52ec986
|
||||
README.md: b79eac0d7777ac7af9b6a8960dc4d9797b41513d
|
||||
README.zh.md: c57ccbdb8fdfb735b3a5d0d66f3538dd01966ada
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the live theme preference (`light`/`dark`/`system`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). A loopback browser loads `ui-theme.preference` before providing the service and writes each built-in selection through the Host settings API, whose local provider stores it in `$DSH_HOME/settings.yaml` by default; pushed settings changes and reconnects refetch it, rapid selections are serialized in gesture order, and a rejected latest write reloads the durable value. A remote browser cannot access the privileged settings API, so its selection remains process-local. Third-party registered theme ids remain an in-process extension and do not cross the built-in settings schema. Contract: api-contracts v3 §8; the [Host-backed preference decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md) owns the persistence boundary.
|
||||
Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the live theme preference (`light`/`dark`/`system`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). A loopback browser provides the service immediately with `system`, then loads `ui-theme.preference` in the background and writes each built-in selection through the Host settings API, whose local provider stores it in `$DSH_HOME/settings.yaml` by default; pushed settings changes and reconnects refetch it, rapid selections are serialized in gesture order with namespace revisions, and a rejected latest write reloads the durable value. A remote browser cannot access the privileged settings API, so its selection remains process-local. Third-party registered theme ids remain an in-process extension and do not cross the built-in settings schema; removing one never overwrites the last durable built-in preference. Contract: api-contracts v3 §8; the [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary.
|
||||
|
||||
`src/styles/` holds five sheets, all imported by the web shell's `base.css`: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有实时主题偏好(`light`/`dark`/`system`),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。来自回环地址的浏览器会在提供该服务前加载 `ui-theme.preference`,并将每次内置主题选择通过 Host settings API 写入;其本地提供方默认将设置存入 `$DSH_HOME/settings.yaml`。收到推送的 settings 变更时或重连后,浏览器都会重新拉取该设置;连续快速选择会按操作顺序串行写入,最新写入被拒时则重新加载持久化值。远程浏览器无法访问特权 settings API,因此它的选择仅保留在进程内。已注册的第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema。契约:api-contracts v3 §8;该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md)拥有。
|
||||
主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有实时主题偏好(`light`/`dark`/`system`),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。来自回环地址的浏览器会先以 `system` 立即提供该服务,随后在后台加载 `ui-theme.preference`,并将每次内置主题选择通过 Host settings API 写入;其本地提供方默认将设置存入 `$DSH_HOME/settings.yaml`。收到推送的 settings 变更时或重连后,浏览器都会重新拉取该设置;连续快速选择会按操作顺序携带 namespace revision 串行写入,最新写入被拒时则重新加载持久化值。远程浏览器无法访问特权 settings API,因此它的选择仅保留在进程内。已注册的第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema;移除其中任意一个都绝不会覆盖最后一个持久化的内置偏好。契约:api-contracts v3 §8;该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。
|
||||
|
||||
`src/styles/` 下有五张样式表,全部由 web 壳的 `base.css` 导入:`base.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。`scrollbar.css` 是 `--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
|
||||
@@ -8,28 +8,24 @@
|
||||
* settings General section — the theme feature owns its own settings surface.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSettingsPreference, type ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { AppearanceRowInjected } from './AppearanceRow.tsx'
|
||||
import { AppearanceRow } from './AppearanceRow.tsx'
|
||||
import { createAppearanceRowStore } from './settings-store.ts'
|
||||
import { ThemeSettingsController } from './theme-settings.ts'
|
||||
import { en, zh, type ThemeKey } from './locales.ts'
|
||||
import {
|
||||
DEFAULT_PREFERENCE, isThemePreference, THEME_SETTINGS_NAMESPACE,
|
||||
DEFAULT_PREFERENCE, isThemePreference, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE,
|
||||
type ThemePreference,
|
||||
} from '../theme-settings.ts'
|
||||
|
||||
export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx'
|
||||
export type { AppearanceRowState } from './settings-store.ts'
|
||||
export type { ThemePreferenceTarget } from './theme-settings.ts'
|
||||
export { ThemeSettingsController } from './theme-settings.ts'
|
||||
export type { ThemeKey } from './locales.ts'
|
||||
export {
|
||||
DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE,
|
||||
DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE,
|
||||
type ThemePreference,
|
||||
} from '../theme-settings.ts'
|
||||
|
||||
@@ -196,7 +192,6 @@ export class ThemeService {
|
||||
this.themes = this.themes.filter(t => t.id !== definition.id)
|
||||
if (this.preference === definition.id) {
|
||||
this.preference = DEFAULT_PREFERENCE
|
||||
this.persist(this.preference)
|
||||
}
|
||||
this.publish()
|
||||
}
|
||||
@@ -235,33 +230,17 @@ export const inject = ['slots', 'locale', 'connection']
|
||||
* slot (a feature owns its settings surface).
|
||||
* @param ctx - client cordis context.
|
||||
*/
|
||||
export async function apply(ctx: ClientContext): Promise<void> {
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const theme = new ThemeService(ctx)
|
||||
const controller = new ThemeSettingsController(
|
||||
connection.api,
|
||||
theme,
|
||||
connection.isLoopback ? 'host' : 'memory',
|
||||
)
|
||||
const controller = bindSettingsPreference(ctx, {
|
||||
namespace: THEME_SETTINGS_NAMESPACE,
|
||||
field: THEME_PREFERENCE_FIELD,
|
||||
decode: value => isThemePreference(value) ? value : undefined,
|
||||
sync: (preference) => { theme.syncPreference(preference) },
|
||||
})
|
||||
theme.bindPersistence((preference) => { void controller.persist(preference) })
|
||||
await controller.load()
|
||||
ctx.provide('theme', theme)
|
||||
|
||||
ctx.effect(() => {
|
||||
const refresh = (ns?: string): void => {
|
||||
if (ns !== undefined && ns !== THEME_SETTINGS_NAMESPACE) return
|
||||
void controller.load()
|
||||
}
|
||||
const disposers = [
|
||||
ctx.on('settings/changed', refresh),
|
||||
ctx.on('connection/reset', () => { refresh() }),
|
||||
]
|
||||
return () => {
|
||||
controller.dispose()
|
||||
for (const dispose of disposers) dispose()
|
||||
}
|
||||
}, 'ui-theme: settings invalidations')
|
||||
|
||||
ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), 'ui-theme: settings row dictionaries')
|
||||
|
||||
const store = createAppearanceRowStore()
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
/** Host-backed persistence controller for the browser theme preference. */
|
||||
|
||||
import type {
|
||||
IApiClient, SettingsNamespaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, isThemePreference,
|
||||
type ThemePreference,
|
||||
} from '../theme-settings.ts'
|
||||
|
||||
/** Preference target implemented by {@link ThemeService}. */
|
||||
export interface ThemePreferenceTarget {
|
||||
/**
|
||||
* Apply a Host value without writing it back.
|
||||
* @param preference - validated durable preference.
|
||||
*/
|
||||
syncPreference(preference: ThemePreference): void
|
||||
}
|
||||
|
||||
function preferenceOf(view: SettingsNamespaceView): ThemePreference | undefined {
|
||||
if (typeof view.value !== 'object' || view.value === null) return undefined
|
||||
const preference = (view.value as Record<string, unknown>)[THEME_PREFERENCE_FIELD]
|
||||
return isThemePreference(preference) ? preference : undefined
|
||||
}
|
||||
|
||||
/** Coordinates startup reads, ordered writes, and pushed invalidations. */
|
||||
export class ThemeSettingsController {
|
||||
private generation = 0
|
||||
private writeTail: Promise<void> = Promise.resolve()
|
||||
|
||||
/**
|
||||
* @param api - settings wire face.
|
||||
* @param target - live theme service receiving durable values.
|
||||
* @param persistence - remote browsers stay process-local because the settings API is loopback-only.
|
||||
*/
|
||||
constructor(
|
||||
private readonly api: Pick<IApiClient, 'settings'>,
|
||||
private readonly target: ThemePreferenceTarget,
|
||||
private readonly persistence: 'host' | 'memory' = 'host',
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Load the durable preference after earlier writes settle; the latest operation wins.
|
||||
* @returns nothing; an unavailable or invalid descriptor leaves the last good value active.
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
const generation = ++this.generation
|
||||
if (this.persistence === 'memory') return
|
||||
await this.writeTail
|
||||
if (generation !== this.generation) return
|
||||
let response: Awaited<ReturnType<Pick<IApiClient, 'settings'>['settings']['describe']>>
|
||||
try {
|
||||
response = await this.api.settings.describe({})
|
||||
} catch (_settingsReadFailure) {
|
||||
// A transport failure leaves the last good in-process theme active. A
|
||||
// connection/reset or settings/changed notification retries the read.
|
||||
return
|
||||
}
|
||||
if (!response.result.ok || generation !== this.generation) return
|
||||
const view = response.result.value.namespaces.find(
|
||||
candidate => candidate.ns === THEME_SETTINGS_NAMESPACE,
|
||||
)
|
||||
if (view === undefined) return
|
||||
const preference = preferenceOf(view)
|
||||
if (preference !== undefined) this.target.syncPreference(preference)
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist one user selection. Writes are serialized so rapid picks land in
|
||||
* gesture order; a rejected latest write reloads the durable value.
|
||||
* @param preference - selected built-in preference.
|
||||
* @returns nothing after the write or recovery read settles.
|
||||
*/
|
||||
async persist(preference: ThemePreference): Promise<void> {
|
||||
const generation = ++this.generation
|
||||
if (this.persistence === 'memory') return
|
||||
const write = this.writeTail.then(async () => {
|
||||
const response = await this.api.settings.mutate({
|
||||
ns: THEME_SETTINGS_NAMESPACE,
|
||||
ops: [{ op: 'set', path: [THEME_PREFERENCE_FIELD], value: preference }],
|
||||
})
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
if (generation === this.generation) {
|
||||
const accepted = preferenceOf(response.result.value)
|
||||
if (accepted !== undefined) this.target.syncPreference(accepted)
|
||||
}
|
||||
})
|
||||
this.writeTail = write.catch(() => {})
|
||||
try {
|
||||
await write
|
||||
} catch {
|
||||
if (generation === this.generation) await this.load()
|
||||
}
|
||||
}
|
||||
|
||||
/** Prevent in-flight reads and writes from publishing after plugin disposal. */
|
||||
dispose(): void {
|
||||
this.generation += 1
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,12 @@ import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE,
|
||||
DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE,
|
||||
type ThemePreference,
|
||||
} from './theme-settings.ts'
|
||||
|
||||
export {
|
||||
DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE,
|
||||
DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE,
|
||||
type ThemePreference,
|
||||
} from './theme-settings.ts'
|
||||
|
||||
@@ -18,7 +18,7 @@ interface ThemeSettings {
|
||||
}
|
||||
|
||||
const ThemeSettingsSchema: z<ThemeSettings> = z.object({
|
||||
[THEME_PREFERENCE_FIELD]: z.union(['light', 'dark', 'system']).default(DEFAULT_PREFERENCE),
|
||||
[THEME_PREFERENCE_FIELD]: z.union([...THEME_PREFERENCES]).default(DEFAULT_PREFERENCE),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/** Theme preferences stored in the Host user-settings document. */
|
||||
|
||||
/** Built-in preferences accepted at the registry and settings boundaries. */
|
||||
export const THEME_PREFERENCES = ['light', 'dark', 'system'] as const
|
||||
|
||||
/** Settings namespace owned by the theme plugin. */
|
||||
export const THEME_SETTINGS_NAMESPACE = 'ui-theme'
|
||||
|
||||
@@ -7,7 +10,7 @@ export const THEME_SETTINGS_NAMESPACE = 'ui-theme'
|
||||
export const THEME_PREFERENCE_FIELD = 'preference'
|
||||
|
||||
/** Theme preference persisted by the product Appearance row. */
|
||||
export type ThemePreference = 'light' | 'dark' | 'system'
|
||||
export type ThemePreference = typeof THEME_PREFERENCES[number]
|
||||
|
||||
/** Default preference when the user-settings document has no override. */
|
||||
export const DEFAULT_PREFERENCE: ThemePreference = 'system'
|
||||
@@ -18,5 +21,5 @@ export const DEFAULT_PREFERENCE: ThemePreference = 'system'
|
||||
* @returns whether the value is a built-in preference.
|
||||
*/
|
||||
export function isThemePreference(value: unknown): value is ThemePreference {
|
||||
return value === 'light' || value === 'dark' || value === 'system'
|
||||
return THEME_PREFERENCES.some(preference => preference === value)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,12 @@ usePinnedBrowserLanguages('zh-CN')
|
||||
|
||||
const SLOT = 'settings.general.item'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((done) => { resolve = done })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
async function bench(isLoopback = true) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
@@ -122,7 +128,7 @@ describe('ui-theme apply', () => {
|
||||
declareItems(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const theme = b.ctx.get('theme') as ThemeService
|
||||
expect(theme.getTheme().preference).toBe('dark')
|
||||
await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') })
|
||||
b.ctx.emit('settings/changed', 'unrelated')
|
||||
expect(b.describe).toHaveBeenCalledOnce()
|
||||
b.setHostPreference('light')
|
||||
@@ -142,6 +148,30 @@ describe('ui-theme apply', () => {
|
||||
expect(remote.mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('activates before a slow initial settings read and converges when it settles', async () => {
|
||||
const b = await bench()
|
||||
b.setHostPreference('dark')
|
||||
const describe = b.describe.getMockImplementation()!
|
||||
const pending = deferred<Awaited<ReturnType<typeof describe>>>()
|
||||
b.describe.mockImplementationOnce(() => pending.promise)
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const theme = b.ctx.get('theme') as ThemeService
|
||||
expect(theme.getTheme().preference).toBe('system')
|
||||
pending.resolve(await describe())
|
||||
await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') })
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('ignores an invalid preference crossing the settings wire', async () => {
|
||||
const b = await bench()
|
||||
b.setHostPreference('sepia')
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const theme = b.ctx.get('theme') as ThemeService
|
||||
await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledOnce() })
|
||||
expect(theme.getTheme().preference).toBe('system')
|
||||
})
|
||||
|
||||
it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {
|
||||
const b = await bench()
|
||||
const host = declareItems(b.slots)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Context } from 'cordis'
|
||||
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-theme'
|
||||
import { apply as clientApply, inject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import * as ThemeInvariant from '@deepseek-ai/dsh-client-ui-theme/invariant'
|
||||
import { apply as localeApply } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
@@ -26,7 +26,6 @@ describe('invariant companion', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||
const ctx = new Context()
|
||||
new SlotsService(ctx)
|
||||
await ctx.plugin({ inject: ['slots'], apply: localeApply }).await()
|
||||
ctx.provide('connection', {
|
||||
api: { settings: { describe: () => Promise.resolve({
|
||||
rpcId: 'theme-invariant' as never,
|
||||
@@ -34,6 +33,7 @@ describe('invariant companion', () => {
|
||||
}) } },
|
||||
isLoopback: true,
|
||||
} as never)
|
||||
await ctx.plugin({ inject: localeInject, apply: localeApply }).await()
|
||||
await ctx.plugin({ inject, apply: clientApply }).await()
|
||||
expect(ctx.get('theme')).toBeInstanceOf(ThemeService)
|
||||
})
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, ThemeSettingsController,
|
||||
type ThemePreference,
|
||||
} from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
|
||||
let rpc = 0
|
||||
|
||||
function ok<T>(value: T): RpcResponse<T> {
|
||||
return { rpcId: `theme-${rpc++}` as never, result: { ok: true, value } }
|
||||
}
|
||||
|
||||
function view(preference: unknown = 'system'): SettingsNamespaceView {
|
||||
return {
|
||||
ns: THEME_SETTINGS_NAMESPACE,
|
||||
schema: {},
|
||||
value: { [THEME_PREFERENCE_FIELD]: preference },
|
||||
applies: 'live',
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function described(preference: unknown = 'system') {
|
||||
return ok({ writable: true, hasDocument: true, namespaces: [view(preference)] })
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function target() {
|
||||
const values: ThemePreference[] = []
|
||||
return { values, syncPreference: (preference: ThemePreference) => { values.push(preference) } }
|
||||
}
|
||||
|
||||
describe('ThemeSettingsController', () => {
|
||||
it('loads a valid Host value and ignores unavailable or malformed namespaces', async () => {
|
||||
const receiver = target()
|
||||
const describe = vi.fn()
|
||||
.mockResolvedValueOnce(described('dark'))
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
|
||||
.mockResolvedValueOnce(described('sepia'))
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [{ ...view(), value: null }] }))
|
||||
.mockResolvedValueOnce({
|
||||
rpcId: 'failed' as never,
|
||||
result: { ok: false as const, error: { code: 'internal' as const, message: 'offline', details: {} } },
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('transport offline'))
|
||||
const controller = new ThemeSettingsController({ settings: { describe } } as never, receiver)
|
||||
for (let i = 0; i < 6; i++) await controller.load()
|
||||
expect(receiver.values).toEqual(['dark'])
|
||||
})
|
||||
|
||||
it('persists ordered rapid selections and publishes only the latest settlement', async () => {
|
||||
const first = deferred<ReturnType<typeof ok<SettingsNamespaceView>>>()
|
||||
const calls: string[] = []
|
||||
const mutate = vi.fn(async (request: { ops: { value: string }[] }) => {
|
||||
const preference = request.ops[0]!.value
|
||||
calls.push(preference)
|
||||
if (preference === 'dark') return first.promise
|
||||
return ok(view(preference))
|
||||
})
|
||||
const receiver = target()
|
||||
const controller = new ThemeSettingsController({ settings: { mutate } } as never, receiver)
|
||||
const dark = controller.persist('dark')
|
||||
const light = controller.persist('light')
|
||||
await Promise.resolve()
|
||||
expect(calls).toEqual(['dark'])
|
||||
first.resolve(ok(view('dark')))
|
||||
await Promise.all([dark, light])
|
||||
expect(calls).toEqual(['dark', 'light'])
|
||||
expect(receiver.values).toEqual(['light'])
|
||||
expect(mutate).toHaveBeenNthCalledWith(1, {
|
||||
ns: THEME_SETTINGS_NAMESPACE,
|
||||
ops: [{ op: 'set', path: [THEME_PREFERENCE_FIELD], value: 'dark' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('reloads after a rejected latest write and contains stale reads and disposal', async () => {
|
||||
const stale = deferred<ReturnType<typeof described>>()
|
||||
const describe = vi.fn()
|
||||
.mockImplementationOnce(() => stale.promise)
|
||||
.mockResolvedValueOnce(described('system'))
|
||||
const mutate = vi.fn().mockResolvedValue({
|
||||
rpcId: 'rejected' as never,
|
||||
result: { ok: false as const, error: { code: 'settings-rejected' as const, message: 'disk full', details: {} } },
|
||||
})
|
||||
const receiver = target()
|
||||
const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver)
|
||||
const oldLoad = controller.load()
|
||||
await vi.waitFor(() => { expect(describe).toHaveBeenCalledOnce() })
|
||||
await controller.persist('dark')
|
||||
stale.resolve(described('light'))
|
||||
await oldLoad
|
||||
expect(receiver.values).toEqual(['system'])
|
||||
|
||||
const disposedRead = deferred<ReturnType<typeof described>>()
|
||||
describe.mockImplementationOnce(() => disposedRead.promise)
|
||||
const pending = controller.load()
|
||||
controller.dispose()
|
||||
disposedRead.resolve(described('dark'))
|
||||
await pending
|
||||
expect(receiver.values).toEqual(['system'])
|
||||
})
|
||||
|
||||
it('keeps remote-browser persistence in memory without calling Host settings', async () => {
|
||||
const describe = vi.fn()
|
||||
const mutate = vi.fn()
|
||||
const receiver = target()
|
||||
const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver, 'memory')
|
||||
await controller.load()
|
||||
await controller.persist('dark')
|
||||
expect(describe).not.toHaveBeenCalled()
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
expect(receiver.values).toEqual([])
|
||||
})
|
||||
|
||||
it('reloads after a thrown write and ignores a malformed success response', async () => {
|
||||
const receiver = target()
|
||||
const describe = vi.fn().mockResolvedValue(described('light'))
|
||||
const mutate = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValueOnce(ok(view('sepia')))
|
||||
const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver)
|
||||
await controller.persist('dark')
|
||||
await controller.persist('system')
|
||||
expect(receiver.values).toEqual(['light'])
|
||||
})
|
||||
|
||||
it('lets an explicit refresh supersede a stale rejected write', async () => {
|
||||
const rejected = deferred<never>()
|
||||
const receiver = target()
|
||||
const describe = vi.fn().mockResolvedValue(described('system'))
|
||||
const mutate = vi.fn().mockReturnValue(rejected.promise)
|
||||
const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver)
|
||||
const write = controller.persist('dark')
|
||||
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
const refresh = controller.load()
|
||||
rejected.reject(new Error('stale rejection'))
|
||||
await Promise.all([write, refresh])
|
||||
expect(receiver.values).toEqual(['system'])
|
||||
expect(describe).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -71,8 +71,7 @@ describe('ThemeService', () => {
|
||||
expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark'])
|
||||
// Custom ids are in-process extension themes; only the built-in product
|
||||
// preferences cross the Host settings schema.
|
||||
expect(persist).toHaveBeenCalledTimes(1)
|
||||
expect(persist).toHaveBeenCalledWith('system')
|
||||
expect(persist).not.toHaveBeenCalled()
|
||||
// register + set + dispose = three publishes; disposer is idempotent.
|
||||
expect(events.length).toBe(3)
|
||||
dispose()
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user