fix(web): persist general preferences in host settings
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user