refactor(client): replace the per-field settings preference controller with a namespace settings scope
bindSettingsScope mirrors the Host-side settings owner seam in the browser: one scope per namespace publishes a snapshot store (status, section value, revision, writability, host/memory mode), validates sections against the namespace's serialized wire schema via dsh-client-schema-form, and keeps the controller's listener-before-read, revisioned serialized writes, latest-wins publication, conflict recovery, and disposal quiescence. Theme, locale, and busy-Enter services now take the scope as a constructor collaborator, which removes the bindPersistence/syncPreference two-phase callback pair and the defaulted no-op persist writers; hand-written wire guards fall away in favor of the registered schema. test-runtime gains a stubSettingsScope double.
This commit is contained in:
@@ -13,9 +13,11 @@ import type { Context } from 'cordis'
|
||||
import {
|
||||
type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { bindSettingsPreference, type ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
isLocaleId, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId,
|
||||
bindSettingsScope, type ClientContext, type SettingsScope,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, type LocaleSettings,
|
||||
} from '../locale-settings.ts'
|
||||
import { en, zh, type CommonKey } from '../locales/index.ts'
|
||||
import {
|
||||
@@ -29,7 +31,7 @@ export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageR
|
||||
export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts'
|
||||
export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts'
|
||||
export type { CommonKey } from '../locales/index.ts'
|
||||
export type { LocaleId } from '../locale-settings.ts'
|
||||
export type { LocaleId, LocaleSettings } from '../locale-settings.ts'
|
||||
|
||||
// The translate currency lives in ui-slots (the render machinery synthesizes
|
||||
// the seat); re-exported here so dictionary owners import one package.
|
||||
@@ -114,24 +116,25 @@ export class LocaleService {
|
||||
private snapshot: LocaleSnapshot
|
||||
private listeners = new Set<() => void>()
|
||||
private readonly ctx: Context
|
||||
private persist: (id: LocaleId) => void
|
||||
private readonly host: SettingsScope<LocaleSettings> | undefined
|
||||
/** Browser-derived locale standing wherever no explicit Host selection does. */
|
||||
private readonly provisional: LocaleId
|
||||
|
||||
/**
|
||||
* @param ctx - owning context (change events are emitted on it).
|
||||
* @param persist - durable write callback for explicit locale selections.
|
||||
* @param ctx - owning context (change events are emitted on it; the scope
|
||||
* listener is released through ctx.effect on dispose).
|
||||
* @param host - durable preference scope owned by the providing plugin;
|
||||
* absent compositions (standalone dictionary registries) stay process-local.
|
||||
*/
|
||||
constructor(ctx: Context, persist: (id: LocaleId) => void = () => {}) {
|
||||
constructor(ctx: Context, host?: SettingsScope<LocaleSettings>) {
|
||||
this.ctx = ctx
|
||||
this.persist = persist
|
||||
this.snapshot = Object.freeze({ active: resolveInitialLocale(), locales: LOCALES, revision: 0 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind the owning plugin's durable writer before the service is provided.
|
||||
* @param persist - callback accepting explicit locale changes.
|
||||
*/
|
||||
bindPersistence(persist: (id: LocaleId) => void): void {
|
||||
this.persist = persist
|
||||
this.host = host
|
||||
this.provisional = resolveInitialLocale()
|
||||
this.snapshot = Object.freeze({ active: this.provisional, locales: LOCALES, revision: 0 })
|
||||
if (host !== undefined) {
|
||||
ctx.effect(() => host.subscribe(() => { this.adopt(host) }), 'locale: settings scope adoption')
|
||||
this.adopt(host)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,16 +175,20 @@ export class LocaleService {
|
||||
if (match === undefined) throw new Error(`locale "${id}" is not registered`)
|
||||
if (this.snapshot.active === match.id) return
|
||||
this.publish(match.id, true)
|
||||
this.persist(match.id)
|
||||
void this.host?.set(LOCALE_PREFERENCE_FIELD, match.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an explicit Host preference without writing it back.
|
||||
* @param id - validated shipped locale.
|
||||
* Adopt the scope's accepted durable selection without writing it back; an
|
||||
* absent selection returns to the browser-derived locale.
|
||||
* @param host - the constructor-narrowed scope driving this adoption.
|
||||
*/
|
||||
syncPreference(id: LocaleId): void {
|
||||
if (this.snapshot.active === id) return
|
||||
this.publish(id, true)
|
||||
private adopt(host: SettingsScope<LocaleSettings>): void {
|
||||
const section = host.getSnapshot().value
|
||||
if (section === undefined) return
|
||||
const target = section.preference ?? this.provisional
|
||||
if (this.snapshot.active === target) return
|
||||
this.publish(target, true)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -345,17 +352,10 @@ export const inject = ['slots', 'connection']
|
||||
* @param ctx - client cordis context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const locale = new LocaleService(ctx)
|
||||
const browserLocale = locale.getLocale().active
|
||||
const host = bindSettingsScope<LocaleSettings>(ctx, { namespace: LOCALE_SETTINGS_NAMESPACE })
|
||||
const locale = new LocaleService(ctx, host)
|
||||
locale.register(COMMON_NS, { zh, en })
|
||||
locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn })
|
||||
const controller = bindSettingsPreference(ctx, {
|
||||
namespace: LOCALE_SETTINGS_NAMESPACE,
|
||||
field: LOCALE_PREFERENCE_FIELD,
|
||||
decode: value => isLocaleId(value) ? value : browserLocale,
|
||||
sync: (id) => { locale.syncPreference(id) },
|
||||
})
|
||||
locale.bindPersistence((id) => { void controller.persist(id) })
|
||||
ctx.provide('locale', locale)
|
||||
// The service IS the LocaleFace (bind + getSnapshot/subscribe): install it
|
||||
// so the render machinery can synthesize the `t` standard seat.
|
||||
|
||||
@@ -4,18 +4,16 @@ import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId,
|
||||
LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleSettings,
|
||||
} from './locale-settings.ts'
|
||||
|
||||
export {
|
||||
LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId,
|
||||
LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE,
|
||||
type LocaleId, type LocaleSettings,
|
||||
} from './locale-settings.ts'
|
||||
|
||||
interface LocaleSettings {
|
||||
preference?: LocaleId
|
||||
}
|
||||
|
||||
const LocaleSettingsSchema: z<LocaleSettings> = z.object({
|
||||
/** Durable locale schema; also the wire envelope the browser scope validates against. */
|
||||
export const LocaleSettingsSchema: z<LocaleSettings> = z.object({
|
||||
[LOCALE_PREFERENCE_FIELD]: z.union([...LOCALE_IDS]).required(false),
|
||||
})
|
||||
|
||||
|
||||
@@ -12,11 +12,8 @@ export const LOCALE_IDS = ['zh', 'en'] as const
|
||||
/** Shipped locale identifier. */
|
||||
export type LocaleId = typeof LOCALE_IDS[number]
|
||||
|
||||
/**
|
||||
* Narrow one settings-wire value to a shipped locale.
|
||||
* @param value - value crossing the settings boundary.
|
||||
* @returns whether the value names a shipped locale.
|
||||
*/
|
||||
export function isLocaleId(value: unknown): value is LocaleId {
|
||||
return LOCALE_IDS.some(locale => locale === value)
|
||||
/** Durable locale section shared by the Host schema and the browser scope. */
|
||||
export interface LocaleSettings {
|
||||
/** Explicit locale selection; absence delegates to the browser. */
|
||||
preference?: LocaleId
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { LanguageRowInjected, LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { LOCALE_SETTINGS_NAMESPACE } from '../src/locale-settings.ts'
|
||||
import { LocaleSettingsSchema } from '../src/index.ts'
|
||||
import { LanguageRow } from '../src/client/LanguageRow.tsx'
|
||||
import type { createLanguageRowStore } from '../src/client/settings-store.ts'
|
||||
|
||||
@@ -21,7 +22,7 @@ async function bench() {
|
||||
let revision = 0
|
||||
const namespace = () => ({
|
||||
ns: LOCALE_SETTINGS_NAMESPACE,
|
||||
schema: {},
|
||||
schema: LocaleSettingsSchema.toJSON(),
|
||||
value: preference === undefined ? {} : { preference },
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { LocaleSettings, LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
|
||||
const make = (): { ctx: Context; svc: LocaleService; events: LocaleSnapshot[] } => {
|
||||
const make = (host?: StubSettingsScope<LocaleSettings>): {
|
||||
ctx: Context
|
||||
svc: LocaleService
|
||||
events: LocaleSnapshot[]
|
||||
} => {
|
||||
const ctx = new Context()
|
||||
const events: LocaleSnapshot[] = []
|
||||
ctx.on('locale/change', (snapshot) => { events.push(snapshot) })
|
||||
return { ctx, svc: new LocaleService(ctx), events }
|
||||
return { ctx, svc: new LocaleService(ctx, host?.scope), events }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,19 +136,25 @@ describe('LocaleService', () => {
|
||||
expect(svc.getSnapshot().revision).toBe(before + 1)
|
||||
})
|
||||
|
||||
it('setLocale requests persistence, republishes an immutable snapshot, and no-ops on same value', () => {
|
||||
const { svc, events } = make()
|
||||
const persist = vi.fn()
|
||||
svc.bindPersistence(persist)
|
||||
it('setLocale writes through the scope, republishes an immutable snapshot, and no-ops on same value', () => {
|
||||
const host = stubSettingsScope<LocaleSettings>()
|
||||
const { svc, events } = make(host)
|
||||
svc.setLocale('en')
|
||||
expect(svc.getLocale().active).toBe('en')
|
||||
expect(persist).toHaveBeenCalledWith('en')
|
||||
expect(host.set).toHaveBeenCalledWith('preference', 'en')
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toBe(svc.getLocale())
|
||||
expect(events[0]!.revision).toBe(1)
|
||||
svc.setLocale('en')
|
||||
expect(events).toHaveLength(1)
|
||||
expect(persist).toHaveBeenCalledOnce()
|
||||
expect(host.set).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('setLocale without a host scope stays process-local', () => {
|
||||
const { svc, events } = make()
|
||||
svc.setLocale('en')
|
||||
expect(svc.getLocale().active).toBe('en')
|
||||
expect(events).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('throws on unknown locale ids', () => {
|
||||
@@ -151,18 +162,36 @@ describe('LocaleService', () => {
|
||||
expect(() => { svc.setLocale('fr') }).toThrow('not registered')
|
||||
})
|
||||
|
||||
it('syncs a Host preference over the browser language without writing it back', () => {
|
||||
const { svc, events } = make()
|
||||
const persist = vi.fn()
|
||||
svc.bindPersistence(persist)
|
||||
svc.syncPreference('en')
|
||||
it('adopts a Host preference over the browser language without writing it back', () => {
|
||||
const host = stubSettingsScope<LocaleSettings>()
|
||||
const { svc, events } = make(host)
|
||||
host.publish({ status: 'ready', value: { preference: 'en' }, revision: 1, writable: true })
|
||||
expect(svc.getLocale().active).toBe('en')
|
||||
expect(events).toHaveLength(1)
|
||||
expect(persist).not.toHaveBeenCalled()
|
||||
svc.syncPreference('en')
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
host.publish({ value: { preference: 'en' }, revision: 2 })
|
||||
expect(events).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('an absent Host preference returns to the browser-derived locale', () => {
|
||||
const host = stubSettingsScope<LocaleSettings>()
|
||||
const { svc } = make(host)
|
||||
host.publish({ status: 'ready', value: { preference: 'en' }, revision: 1, writable: true })
|
||||
expect(svc.getLocale().active).toBe('en')
|
||||
host.publish({ value: {}, revision: 2 })
|
||||
expect(svc.getLocale().active).toBe('zh')
|
||||
})
|
||||
|
||||
it('adopts a section already standing at construction and releases its subscription on dispose', async () => {
|
||||
const host = stubSettingsScope<LocaleSettings>()
|
||||
host.publish({ status: 'ready', value: { preference: 'en' }, revision: 1, writable: true })
|
||||
const { ctx, svc } = make(host)
|
||||
expect(svc.getLocale().active).toBe('en')
|
||||
expect(host.listenerCount()).toBe(1)
|
||||
await ctx.fiber.dispose()
|
||||
expect(host.listenerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('opens provisionally in the browser language, matching regional variants on their primary subtag', () => {
|
||||
stubLanguages('en-GB', 'zh-CN')
|
||||
expect(make().svc.getLocale().active).toBe('en')
|
||||
|
||||
@@ -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/runtime/README.md
|
||||
README.md: c05089badb29ad0e22ed1f66d7804eccbb11c1d4
|
||||
README.zh.md: ccbb96266cf8ca442adbdbf9784c54400593d5c2
|
||||
README.md: 767352a0682f16abcbfce3c226cda790adcc8011
|
||||
README.zh.md: 791a74691cd20705614ac782d6b55d9af290955c
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
|
||||
|
||||
`bindSettingsPreference` is the browser lifecycle for one domain-owned scalar setting. It subscribes before starting a nonblocking initial read, serializes writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. Loopback pages use the Host settings API; remote pages stay in memory. Domain packages own the namespace schema, value guard, default, and live service rather than putting product policy in runtime.
|
||||
`bindSettingsScope` is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, revision, writability, host/memory mode), serializes `set` writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime.
|
||||
|
||||
## Slot declaration injection
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
|
||||
|
||||
`bindSettingsPreference` 是单项由领域持有的标量设置所用的浏览器生命周期。它在开始非阻塞初始读取前建立订阅,使用已知最新 namespace revision 串行写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。回环页面使用 Host settings API,远程页面则只保留内存状态。namespace schema、取值校验器、默认值与实时服务归领域包所有,而非把产品政策放入运行时。
|
||||
`bindSettingsScope` 面向单个由领域持有的 namespace,是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照(状态、分节值、revision、可写性、host/内存模式),使用已知最新 namespace revision 串行执行 `set` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema(经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API,远程页面则停留在内存模式。namespace schema、默认值与实时服务归领域包所有,而非把产品政策放入运行时。
|
||||
|
||||
## Slot 声明注入
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
@@ -53,7 +54,8 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -21,8 +21,8 @@ export type { SessionProvideChannelHost } from './sessions/provide.ts'
|
||||
export { createScope } from './agents/scope.ts'
|
||||
export type { AgentScopeHandle } from './agents/scope.ts'
|
||||
export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
|
||||
export { bindSettingsPreference, SettingsPreferenceController } from './settings-preference.ts'
|
||||
export type { SettingsPreferenceSpec } from './settings-preference.ts'
|
||||
export { bindSettingsScope, SettingsScopeController } from './settings-scope.ts'
|
||||
export type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './settings-scope.ts'
|
||||
export type { Session } from './sessions/session.ts'
|
||||
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
|
||||
export type {
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
/** Host-backed scalar preference synchronization for browser plugins. */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
ConnectionHandle, IApiClient, SettingsNamespaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
/** Domain-owned description of one scalar field in a settings namespace. */
|
||||
export interface SettingsPreferenceSpec<T> {
|
||||
/** Settings namespace registered by the owning Host plugin. */
|
||||
namespace: string
|
||||
/** Scalar field inside that namespace. */
|
||||
field: string
|
||||
/** Validate a wire value; undefined leaves the current in-process value active. */
|
||||
decode(value: unknown): T | undefined
|
||||
/** Apply a validated Host value without writing it back. */
|
||||
sync(value: T): void
|
||||
}
|
||||
|
||||
type SettingsFace = Pick<IApiClient, 'settings'>
|
||||
|
||||
/**
|
||||
* Serializes one scalar preference's Host reads and writes. Reads never block
|
||||
* plugin activation; writes carry the latest known namespace revision and
|
||||
* teardown waits for the operation already crossing the wire.
|
||||
*/
|
||||
export class SettingsPreferenceController<T> {
|
||||
private tail: Promise<void> = Promise.resolve()
|
||||
private readGeneration = 0
|
||||
private writeGeneration = 0
|
||||
private revision: number | undefined
|
||||
private disposed = false
|
||||
|
||||
/**
|
||||
* @param api - settings wire face.
|
||||
* @param spec - namespace, field validator, and live target.
|
||||
* @param persistence - remote browsers remain process-local because settings RPCs are loopback-only.
|
||||
*/
|
||||
constructor(
|
||||
private readonly api: SettingsFace,
|
||||
private readonly spec: SettingsPreferenceSpec<T>,
|
||||
private readonly persistence: 'host' | 'memory' = 'host',
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Queue a Host refresh; a newer read or user write suppresses stale publication.
|
||||
* @returns settlement after the queued read completes or is skipped.
|
||||
*/
|
||||
load(): Promise<void> {
|
||||
const generation = ++this.readGeneration
|
||||
return this.enqueue(() => this.read(generation))
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue one user preference write. Rapid selections preserve mutation order,
|
||||
* while only the latest settlement may resynchronize the live target.
|
||||
* @param value - validated domain preference selected by the user.
|
||||
* @returns settlement after the write and any latest-write recovery read.
|
||||
*/
|
||||
persist(value: T): Promise<void> {
|
||||
this.readGeneration += 1
|
||||
const generation = ++this.writeGeneration
|
||||
return this.enqueue(async () => {
|
||||
let response: Awaited<ReturnType<SettingsFace['settings']['mutate']>>
|
||||
try {
|
||||
response = await this.api.settings.mutate({
|
||||
ns: this.spec.namespace,
|
||||
ops: [{ op: 'set', path: [this.spec.field], value }],
|
||||
...(this.revision === undefined ? {} : { expectedRevision: this.revision }),
|
||||
})
|
||||
} catch (_settingsWriteFailure) {
|
||||
if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration)
|
||||
return
|
||||
}
|
||||
if (!response.result.ok) {
|
||||
if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration)
|
||||
return
|
||||
}
|
||||
this.accept(response.result.value, generation === this.writeGeneration)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop queued operations and wait for the current wire call to settle.
|
||||
* @returns settlement after the controller reaches quiescence.
|
||||
*/
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
this.readGeneration += 1
|
||||
this.writeGeneration += 1
|
||||
await this.tail
|
||||
}
|
||||
|
||||
private enqueue(operation: () => Promise<void>): Promise<void> {
|
||||
if (this.persistence === 'memory' || this.disposed) return Promise.resolve()
|
||||
const task = this.tail.then(async () => {
|
||||
if (this.disposed) return
|
||||
await operation()
|
||||
})
|
||||
// The returned task carries its own settlement to the caller; the queue
|
||||
// tail is kept fulfilled so one failed target callback cannot strand later operations.
|
||||
this.tail = task.catch(() => {})
|
||||
return task
|
||||
}
|
||||
|
||||
private async read(generation: number): Promise<void> {
|
||||
let response: Awaited<ReturnType<SettingsFace['settings']['describe']>>
|
||||
try {
|
||||
response = await this.api.settings.describe({})
|
||||
} catch (_settingsReadFailure) {
|
||||
return
|
||||
}
|
||||
if (!response.result.ok || this.disposed) return
|
||||
const view = response.result.value.namespaces.find(candidate => candidate.ns === this.spec.namespace)
|
||||
if (view === undefined) return
|
||||
this.accept(view, generation === this.readGeneration)
|
||||
}
|
||||
|
||||
private accept(view: SettingsNamespaceView, publish: boolean): void {
|
||||
this.revision = view.revision
|
||||
if (!publish || typeof view.value !== 'object' || view.value === null) return
|
||||
const value = this.spec.decode((view.value as Record<string, unknown>)[this.spec.field])
|
||||
if (value !== undefined) this.spec.sync(value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind one controller to settings and connection invalidations on the caller's
|
||||
* plugin lifecycle. Listeners exist before the initial background read starts.
|
||||
* @param ctx - owning browser plugin context.
|
||||
* @param spec - domain-owned scalar preference contract.
|
||||
* @returns the bound controller used by the domain's user-write callback.
|
||||
*/
|
||||
export function bindSettingsPreference<T>(
|
||||
ctx: Context,
|
||||
spec: SettingsPreferenceSpec<T>,
|
||||
): SettingsPreferenceController<T> {
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const controller = new SettingsPreferenceController(
|
||||
connection.api,
|
||||
spec,
|
||||
connection.isLoopback ? 'host' : 'memory',
|
||||
)
|
||||
ctx.effect(() => {
|
||||
const refresh = (namespace?: string): void => {
|
||||
if (namespace !== undefined && namespace !== spec.namespace) return
|
||||
void controller.load()
|
||||
}
|
||||
const disposers = [
|
||||
ctx.on('settings/changed', refresh),
|
||||
ctx.on('connection/reset', () => { refresh() }),
|
||||
]
|
||||
void controller.load()
|
||||
return async () => {
|
||||
for (const dispose of disposers) dispose()
|
||||
await controller.dispose()
|
||||
}
|
||||
}, `runtime: ${spec.namespace}.${spec.field} preference`)
|
||||
return controller
|
||||
}
|
||||
261
packages/client/runtime/src/client/settings-scope.ts
Normal file
261
packages/client/runtime/src/client/settings-scope.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
/** Host-backed settings-namespace synchronization for browser plugins. */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
ConnectionHandle, IApiClient, SettingsNamespaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { rehydrateSchema, validateDraft } from '@deepseek-ai/dsh-client-schema-form'
|
||||
import { createSnapshotStore, type SnapshotStore } from './contract/store.ts'
|
||||
|
||||
/** Client-side sync state of one settings namespace. */
|
||||
export interface SettingsScopeSnapshot<T> {
|
||||
/**
|
||||
* `loading` until the first accepted section, `ready` while one stands, and
|
||||
* `unavailable` when the namespace is not exposed to this client or the
|
||||
* connection keeps preferences process-local (memory mode).
|
||||
*/
|
||||
status: 'loading' | 'ready' | 'unavailable'
|
||||
/** Last accepted schema-resolved section; undefined before the first acceptance. */
|
||||
value: T | undefined
|
||||
/** Namespace revision fencing the next write; undefined before the first Host view. */
|
||||
revision: number | undefined
|
||||
/** Whether the Host document accepts writes; memory mode never does. */
|
||||
writable: boolean
|
||||
/** `host` syncs with the Host document; `memory` keeps a remote browser process-local. */
|
||||
mode: 'host' | 'memory'
|
||||
}
|
||||
|
||||
/** Domain-owned description of one settings namespace consumed by a browser plugin. */
|
||||
export interface SettingsScopeSpec<T> {
|
||||
/** Settings namespace registered by the owning Host plugin. */
|
||||
namespace: string
|
||||
/**
|
||||
* Narrow one wire section; undefined keeps the last accepted value. The
|
||||
* default validates the section against the namespace's own serialized wire
|
||||
* schema, so domains add a decoder only to narrow beyond that schema.
|
||||
*/
|
||||
decode?: (section: unknown) => T | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive owner handle over one namespace's durable section — the browser
|
||||
* mirror of the Host-side `SettingsScope` owner seam. Domain services read
|
||||
* and observe the snapshot and route explicit user choices through `set`.
|
||||
*/
|
||||
export interface SettingsScope<T> {
|
||||
/** @returns the current sync snapshot (stable reference until the next change). */
|
||||
getSnapshot(): SettingsScopeSnapshot<T>
|
||||
/**
|
||||
* Observe snapshot replacements.
|
||||
* @param listener - invoked after each snapshot change.
|
||||
* @returns the disposer removing this listener.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void
|
||||
/**
|
||||
* Queue one field write. Rapid writes preserve mutation order, each carries
|
||||
* the latest known namespace revision, and only the latest settlement may
|
||||
* publish; a rejected or failed latest write reloads Host state instead.
|
||||
* @param field - scalar field inside the namespace section.
|
||||
* @param value - JSON-shaped value selected by the user.
|
||||
* @returns settlement after the write and any latest-write recovery read.
|
||||
*/
|
||||
set(field: string, value: unknown): Promise<void>
|
||||
}
|
||||
|
||||
type SettingsFace = Pick<IApiClient, 'settings'>
|
||||
|
||||
/**
|
||||
* Serializes one namespace's Host reads and writes behind a snapshot store.
|
||||
* Reads never block plugin activation; writes carry the latest known
|
||||
* namespace revision and teardown waits for the operation already crossing
|
||||
* the wire.
|
||||
*/
|
||||
export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
private readonly store: SnapshotStore<SettingsScopeSnapshot<T>>
|
||||
private tail: Promise<void> = Promise.resolve()
|
||||
private readGeneration = 0
|
||||
private writeGeneration = 0
|
||||
private disposed = false
|
||||
|
||||
/**
|
||||
* @param api - settings wire face.
|
||||
* @param spec - namespace identity and optional narrowing decoder.
|
||||
* @param persistence - remote browsers remain process-local because settings RPCs are loopback-only.
|
||||
*/
|
||||
constructor(
|
||||
private readonly api: SettingsFace,
|
||||
private readonly spec: SettingsScopeSpec<T>,
|
||||
private readonly persistence: 'host' | 'memory' = 'host',
|
||||
) {
|
||||
this.store = createSnapshotStore<SettingsScopeSnapshot<T>>({
|
||||
status: persistence === 'host' ? 'loading' : 'unavailable',
|
||||
value: undefined,
|
||||
revision: undefined,
|
||||
writable: false,
|
||||
mode: persistence,
|
||||
})
|
||||
}
|
||||
|
||||
/** @returns the current sync snapshot (stable reference until the next change). */
|
||||
getSnapshot(): SettingsScopeSnapshot<T> {
|
||||
return this.store.getSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe snapshot replacements.
|
||||
* @param listener - invoked after each snapshot change.
|
||||
* @returns the disposer removing this listener.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void {
|
||||
return this.store.subscribe(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a Host refresh; a newer read or user write suppresses stale publication.
|
||||
* @returns settlement after the queued read completes or is skipped.
|
||||
*/
|
||||
load(): Promise<void> {
|
||||
const generation = ++this.readGeneration
|
||||
return this.enqueue(() => this.read(generation))
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue one field write; see {@link SettingsScope.set} for the ordering,
|
||||
* revision, and recovery contract.
|
||||
* @param field - scalar field inside the namespace section.
|
||||
* @param value - JSON-shaped value selected by the user.
|
||||
* @returns settlement after the write and any latest-write recovery read.
|
||||
*/
|
||||
set(field: string, value: unknown): Promise<void> {
|
||||
this.readGeneration += 1
|
||||
const generation = ++this.writeGeneration
|
||||
return this.enqueue(async () => {
|
||||
const revision = this.getSnapshot().revision
|
||||
let response: Awaited<ReturnType<SettingsFace['settings']['mutate']>>
|
||||
try {
|
||||
response = await this.api.settings.mutate({
|
||||
ns: this.spec.namespace,
|
||||
ops: [{ op: 'set', path: [field], value }],
|
||||
...(revision === undefined ? {} : { expectedRevision: revision }),
|
||||
})
|
||||
} catch (_settingsWriteFailure) {
|
||||
if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration)
|
||||
return
|
||||
}
|
||||
if (!response.result.ok) {
|
||||
if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration)
|
||||
return
|
||||
}
|
||||
this.accept(response.result.value, generation === this.writeGeneration)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop queued operations and wait for the current wire call to settle.
|
||||
* @returns settlement after the controller reaches quiescence.
|
||||
*/
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
this.readGeneration += 1
|
||||
this.writeGeneration += 1
|
||||
await this.tail
|
||||
}
|
||||
|
||||
private enqueue(operation: () => Promise<void>): Promise<void> {
|
||||
if (this.persistence === 'memory' || this.disposed) return Promise.resolve()
|
||||
const task = this.tail.then(async () => {
|
||||
if (this.disposed) return
|
||||
await operation()
|
||||
})
|
||||
// The returned task carries its own settlement to the caller; the queue
|
||||
// tail is kept fulfilled so one failed subscriber cannot strand later operations.
|
||||
this.tail = task.catch(() => {})
|
||||
return task
|
||||
}
|
||||
|
||||
private async read(generation: number): Promise<void> {
|
||||
let response: Awaited<ReturnType<SettingsFace['settings']['describe']>>
|
||||
try {
|
||||
response = await this.api.settings.describe({})
|
||||
} catch (_settingsReadFailure) {
|
||||
return
|
||||
}
|
||||
if (!response.result.ok || this.disposed) return
|
||||
const { namespaces, writable } = response.result.value
|
||||
const view = namespaces.find(candidate => candidate.ns === this.spec.namespace)
|
||||
const publish = generation === this.readGeneration
|
||||
if (view === undefined) {
|
||||
if (publish) {
|
||||
this.store.update((draft) => {
|
||||
draft.status = 'unavailable'
|
||||
draft.writable = writable
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
this.accept(view, publish, writable)
|
||||
}
|
||||
|
||||
private accept(view: SettingsNamespaceView, publish: boolean, writable?: boolean): void {
|
||||
const decoded = publish ? this.decode(view) : undefined
|
||||
this.store.update((draft) => {
|
||||
draft.revision = view.revision
|
||||
if (writable !== undefined) draft.writable = writable
|
||||
if (decoded === undefined) return
|
||||
draft.status = 'ready'
|
||||
draft.value = decoded
|
||||
})
|
||||
}
|
||||
|
||||
private decode(view: SettingsNamespaceView): T | undefined {
|
||||
if (this.spec.decode !== undefined) return this.spec.decode(view.value)
|
||||
// Sections are plain objects by construction; schemastery alone would
|
||||
// resolve null or an array through object defaults instead of refusing.
|
||||
if (typeof view.value !== 'object' || view.value === null || Array.isArray(view.value)) return undefined
|
||||
let failure: string | undefined
|
||||
try {
|
||||
failure = validateDraft(rehydrateSchema(view.schema), view.value)
|
||||
} catch (_malformedSchemaEnvelope) {
|
||||
// A schema envelope this client cannot rehydrate vouches for no section;
|
||||
// the value is treated exactly like a schema-invalid one.
|
||||
return undefined
|
||||
}
|
||||
return failure === undefined ? view.value as T : undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind one namespace scope to settings and connection invalidations on the
|
||||
* caller's plugin lifecycle. Listeners exist before the initial background
|
||||
* read starts, so activation never blocks on the settings transport.
|
||||
* @param ctx - owning browser plugin context.
|
||||
* @param spec - domain-owned namespace contract.
|
||||
* @returns the bound scope consumed by the domain's services and rows.
|
||||
*/
|
||||
export function bindSettingsScope<T>(
|
||||
ctx: Context,
|
||||
spec: SettingsScopeSpec<T>,
|
||||
): SettingsScope<T> {
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const controller = new SettingsScopeController<T>(
|
||||
connection.api,
|
||||
spec,
|
||||
connection.isLoopback ? 'host' : 'memory',
|
||||
)
|
||||
ctx.effect(() => {
|
||||
const refresh = (namespace?: string): void => {
|
||||
if (namespace !== undefined && namespace !== spec.namespace) return
|
||||
void controller.load()
|
||||
}
|
||||
const disposers = [
|
||||
ctx.on('settings/changed', refresh),
|
||||
ctx.on('connection/reset', () => { refresh() }),
|
||||
]
|
||||
void controller.load()
|
||||
return async () => {
|
||||
for (const dispose of disposers) dispose()
|
||||
await controller.dispose()
|
||||
}
|
||||
}, `runtime: ${spec.namespace} settings scope`)
|
||||
return controller
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
bindSettingsPreference, SettingsPreferenceController,
|
||||
} from '../src/client/settings-preference.ts'
|
||||
|
||||
type Preference = 'light' | 'dark' | 'system'
|
||||
|
||||
let rpc = 0
|
||||
|
||||
function ok<T>(value: T): RpcResponse<T> {
|
||||
return { rpcId: `preference-${rpc++}` as never, result: { ok: true, value } }
|
||||
}
|
||||
|
||||
function rejected<T>(): RpcResponse<T> {
|
||||
return {
|
||||
rpcId: `preference-${rpc++}` as never,
|
||||
result: {
|
||||
ok: false,
|
||||
error: { code: 'settings-rejected', message: 'conflict', details: { ns: 'ui-test' } },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function view(value: unknown, revision = 0): SettingsNamespaceView {
|
||||
return {
|
||||
ns: 'ui-test',
|
||||
schema: {},
|
||||
value,
|
||||
applies: 'live',
|
||||
secrets: [],
|
||||
revision,
|
||||
}
|
||||
}
|
||||
|
||||
function described(value: unknown, revision = 0) {
|
||||
return ok({ writable: true, hasDocument: true, namespaces: [view(value, revision)] })
|
||||
}
|
||||
|
||||
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 spec(values: Preference[]) {
|
||||
return {
|
||||
namespace: 'ui-test',
|
||||
field: 'preference',
|
||||
decode: (value: unknown): Preference | undefined =>
|
||||
value === 'light' || value === 'dark' || value === 'system' ? value : undefined,
|
||||
sync: (value: Preference) => { values.push(value) },
|
||||
}
|
||||
}
|
||||
|
||||
describe('SettingsPreferenceController', () => {
|
||||
it('loads only a valid owned field and contains unavailable transports', async () => {
|
||||
const values: Preference[] = []
|
||||
const describe = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 3))
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
|
||||
.mockResolvedValueOnce(described({ preference: 'sepia' }))
|
||||
.mockResolvedValueOnce(described(null))
|
||||
.mockResolvedValueOnce(rejected())
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
const controller = new SettingsPreferenceController({ settings: { describe } } as never, spec(values))
|
||||
for (let i = 0; i < 6; i++) await controller.load()
|
||||
expect(values).toEqual(['dark'])
|
||||
})
|
||||
|
||||
it('serializes rapid writes, carries revisions, and publishes only the latest settlement', async () => {
|
||||
const first = deferred<RpcResponse<SettingsNamespaceView>>()
|
||||
const values: Preference[] = []
|
||||
const describe = vi.fn().mockResolvedValue(described({ preference: 'system' }, 4))
|
||||
const mutate = vi.fn()
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 6)))
|
||||
const controller = new SettingsPreferenceController(
|
||||
{ settings: { describe, mutate } } as never,
|
||||
spec(values),
|
||||
)
|
||||
await controller.load()
|
||||
const dark = controller.persist('dark')
|
||||
const light = controller.persist('light')
|
||||
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
first.resolve(ok(view({ preference: 'dark' }, 5)))
|
||||
await Promise.all([dark, light])
|
||||
expect(values).toEqual(['system', 'light'])
|
||||
expect(mutate).toHaveBeenNthCalledWith(1, {
|
||||
ns: 'ui-test',
|
||||
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
|
||||
expectedRevision: 4,
|
||||
})
|
||||
expect(mutate).toHaveBeenNthCalledWith(2, {
|
||||
ns: 'ui-test',
|
||||
ops: [{ op: 'set', path: ['preference'], value: 'light' }],
|
||||
expectedRevision: 5,
|
||||
})
|
||||
})
|
||||
|
||||
it('recovers the latest rejected or thrown write from Host state', async () => {
|
||||
const values: Preference[] = []
|
||||
const describe = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'system' }, 2))
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 3))
|
||||
const mutate = vi.fn()
|
||||
.mockResolvedValueOnce(rejected())
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
const controller = new SettingsPreferenceController(
|
||||
{ settings: { describe, mutate } } as never,
|
||||
spec(values),
|
||||
)
|
||||
await controller.persist('dark')
|
||||
await controller.persist('system')
|
||||
expect(values).toEqual(['system', 'light'])
|
||||
})
|
||||
|
||||
it('does not recover superseded rejected or thrown writes', async () => {
|
||||
const values: Preference[] = []
|
||||
const describe = vi.fn()
|
||||
const mutate = vi.fn()
|
||||
.mockResolvedValueOnce(rejected())
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 3)))
|
||||
const controller = new SettingsPreferenceController(
|
||||
{ settings: { describe, mutate } } as never,
|
||||
spec(values),
|
||||
)
|
||||
await Promise.all([
|
||||
controller.persist('dark'),
|
||||
controller.persist('system'),
|
||||
controller.persist('light'),
|
||||
])
|
||||
expect(describe).not.toHaveBeenCalled()
|
||||
expect(values).toEqual(['light'])
|
||||
})
|
||||
|
||||
it('keeps the queue usable when a target callback throws', async () => {
|
||||
const describe = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }))
|
||||
.mockResolvedValueOnce(described({ preference: 'sepia' }))
|
||||
const controller = new SettingsPreferenceController(
|
||||
{ settings: { describe } } as never,
|
||||
{ ...spec([]), sync: () => { throw new Error('target failed') } },
|
||||
)
|
||||
await expect(controller.load()).rejects.toThrow('target failed')
|
||||
await expect(controller.load()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => {
|
||||
const first = deferred<RpcResponse<SettingsNamespaceView>>()
|
||||
const mutate = vi.fn().mockReturnValue(first.promise)
|
||||
const values: Preference[] = []
|
||||
const controller = new SettingsPreferenceController(
|
||||
{ settings: { mutate } } as never,
|
||||
spec(values),
|
||||
)
|
||||
const dark = controller.persist('dark')
|
||||
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
const light = controller.persist('light')
|
||||
let stopped = false
|
||||
const stop = controller.dispose().then(() => { stopped = true })
|
||||
await Promise.resolve()
|
||||
expect(stopped).toBe(false)
|
||||
first.resolve(ok(view({ preference: 'dark' }, 1)))
|
||||
await Promise.all([dark, light, stop])
|
||||
await controller.persist('system')
|
||||
await controller.load()
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
expect(values).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps remote-browser preferences in memory without Host calls', async () => {
|
||||
const describe = vi.fn()
|
||||
const mutate = vi.fn()
|
||||
const controller = new SettingsPreferenceController(
|
||||
{ settings: { describe, mutate } } as never,
|
||||
spec([]),
|
||||
'memory',
|
||||
)
|
||||
await controller.load()
|
||||
await controller.persist('dark')
|
||||
await controller.dispose()
|
||||
expect(describe).not.toHaveBeenCalled()
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('bindSettingsPreference', () => {
|
||||
it('subscribes before the initial read and converges to the latest queued invalidation', async () => {
|
||||
const initial = deferred<ReturnType<typeof described>>()
|
||||
const describe = vi.fn()
|
||||
.mockReturnValueOnce(initial.promise)
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 2))
|
||||
.mockResolvedValueOnce(described({ preference: 'system' }, 3))
|
||||
const ctx = new Context()
|
||||
ctx.provide('connection', {
|
||||
api: { settings: { describe } },
|
||||
isLoopback: true,
|
||||
} as never)
|
||||
const values: Preference[] = []
|
||||
const fiber = ctx.plugin({
|
||||
inject: ['connection'],
|
||||
apply: (scope: Context) => { bindSettingsPreference(scope, spec(values)) },
|
||||
})
|
||||
await fiber.await()
|
||||
await vi.waitFor(() => { expect(describe).toHaveBeenCalledOnce() })
|
||||
ctx.emit('settings/changed', 'unrelated')
|
||||
ctx.emit('settings/changed', 'ui-test')
|
||||
ctx.emit('connection/reset')
|
||||
initial.resolve(described({ preference: 'dark' }, 1))
|
||||
await vi.waitFor(() => { expect(describe).toHaveBeenCalledTimes(3) })
|
||||
await vi.waitFor(() => { expect(values).toEqual(['system']) })
|
||||
await fiber.dispose()
|
||||
ctx.emit('settings/changed', 'ui-test')
|
||||
await Promise.resolve()
|
||||
expect(describe).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('binds a remote browser in memory without starting a settings read', async () => {
|
||||
const describe = vi.fn()
|
||||
const ctx = new Context()
|
||||
ctx.provide('connection', {
|
||||
api: { settings: { describe } },
|
||||
isLoopback: false,
|
||||
} as never)
|
||||
const fiber = ctx.plugin({
|
||||
inject: ['connection'],
|
||||
apply: (scope: Context) => { bindSettingsPreference(scope, spec([])) },
|
||||
})
|
||||
await fiber.await()
|
||||
await fiber.dispose()
|
||||
expect(describe).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
352
packages/client/runtime/tests/settings-scope.spec.ts
Normal file
352
packages/client/runtime/tests/settings-scope.spec.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
bindSettingsScope, SettingsScopeController, type SettingsScope,
|
||||
} from '../src/client/settings-scope.ts'
|
||||
|
||||
interface UiTestSettings {
|
||||
preference: 'light' | 'dark' | 'system'
|
||||
}
|
||||
|
||||
const ENVELOPE = z.object({
|
||||
preference: z.union(['light', 'dark', 'system']).default('system'),
|
||||
}).toJSON()
|
||||
|
||||
let rpc = 0
|
||||
|
||||
function ok<T>(value: T): RpcResponse<T> {
|
||||
return { rpcId: `scope-${rpc++}` as never, result: { ok: true, value } }
|
||||
}
|
||||
|
||||
function rejected<T>(): RpcResponse<T> {
|
||||
return {
|
||||
rpcId: `scope-${rpc++}` as never,
|
||||
result: {
|
||||
ok: false,
|
||||
error: { code: 'settings-rejected', message: 'conflict', details: { ns: 'ui-test' } },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function view(value: unknown, revision = 0): SettingsNamespaceView {
|
||||
return {
|
||||
ns: 'ui-test',
|
||||
schema: ENVELOPE,
|
||||
value,
|
||||
applies: 'live',
|
||||
secrets: [],
|
||||
revision,
|
||||
}
|
||||
}
|
||||
|
||||
function described(value: unknown, revision = 0) {
|
||||
return ok({ writable: true, hasDocument: true, namespaces: [view(value, revision)] })
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
/** Record each distinct published section, starting from the current one. */
|
||||
function trackValues(scope: SettingsScope<UiTestSettings>): Array<UiTestSettings | undefined> {
|
||||
const seen: Array<UiTestSettings | undefined> = [scope.getSnapshot().value]
|
||||
scope.subscribe(() => {
|
||||
const value = scope.getSnapshot().value
|
||||
if (value !== seen[seen.length - 1]) seen.push(value)
|
||||
})
|
||||
return seen
|
||||
}
|
||||
|
||||
describe('SettingsScopeController', () => {
|
||||
it('starts loading and publishes a schema-valid section with revision and writability', async () => {
|
||||
const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
expect(scope.getSnapshot()).toEqual({
|
||||
status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host',
|
||||
})
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toEqual({
|
||||
status: 'ready', value: { preference: 'dark' }, revision: 3, writable: true, mode: 'host',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the last good value across invalid, rejected, and failed reads while tracking revisions', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 3))
|
||||
.mockResolvedValueOnce(described({ preference: 'sepia' }, 4))
|
||||
.mockResolvedValueOnce(described(null, 5))
|
||||
.mockResolvedValueOnce(described('scalar', 6))
|
||||
.mockResolvedValueOnce(described(['queue'], 7))
|
||||
.mockResolvedValueOnce(rejected())
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const good = trackValues(scope)
|
||||
for (let i = 0; i < 7; i++) await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({
|
||||
status: 'ready', value: { preference: 'dark' }, revision: 7,
|
||||
})
|
||||
expect(good).toEqual([undefined, { preference: 'dark' }])
|
||||
})
|
||||
|
||||
it('treats a schema envelope it cannot rehydrate as vouching for no section', async () => {
|
||||
const broken = { ...view({ preference: 'dark' }, 2), schema: null }
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [broken] }))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 2 })
|
||||
})
|
||||
|
||||
it('suppresses a superseded read of an unexposed namespace', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 1))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const statuses: string[] = []
|
||||
scope.subscribe(() => { statuses.push(scope.getSnapshot().status) })
|
||||
const stale = scope.load()
|
||||
const fresh = scope.load()
|
||||
await Promise.all([stale, fresh])
|
||||
expect(statuses).not.toContain('unavailable')
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } })
|
||||
})
|
||||
|
||||
it('reports an unexposed namespace as unavailable and recovers when it reappears', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 1))
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
|
||||
.mockResolvedValueOnce(described({ preference: 'system' }, 2))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot().status).toBe('ready')
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', value: { preference: 'light' } })
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'system' }, revision: 2 })
|
||||
})
|
||||
|
||||
it('applies a custom decode override in place of the wire schema', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 1))
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 2))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{
|
||||
namespace: 'ui-test',
|
||||
decode: section => (section as UiTestSettings).preference === 'dark'
|
||||
? section as UiTestSettings
|
||||
: undefined,
|
||||
},
|
||||
)
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 1 })
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' }, revision: 2 })
|
||||
})
|
||||
|
||||
it('serializes rapid set writes, carries revisions, and publishes only the latest settlement', async () => {
|
||||
const first = deferred<RpcResponse<SettingsNamespaceView>>()
|
||||
const describeCall = vi.fn().mockResolvedValue(described({ preference: 'system' }, 4))
|
||||
const mutate = vi.fn()
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 6)))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const published = trackValues(scope)
|
||||
await scope.load()
|
||||
const dark = scope.set('preference', 'dark')
|
||||
const light = scope.set('preference', 'light')
|
||||
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
first.resolve(ok(view({ preference: 'dark' }, 5)))
|
||||
await Promise.all([dark, light])
|
||||
expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 6 })
|
||||
expect(mutate).toHaveBeenNthCalledWith(1, {
|
||||
ns: 'ui-test',
|
||||
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
|
||||
expectedRevision: 4,
|
||||
})
|
||||
expect(mutate).toHaveBeenNthCalledWith(2, {
|
||||
ns: 'ui-test',
|
||||
ops: [{ op: 'set', path: ['preference'], value: 'light' }],
|
||||
expectedRevision: 5,
|
||||
})
|
||||
})
|
||||
|
||||
it('recovers the latest rejected or thrown write from Host state', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'system' }, 2))
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 3))
|
||||
const mutate = vi.fn()
|
||||
.mockResolvedValueOnce(rejected())
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const published = trackValues(scope)
|
||||
await scope.set('preference', 'dark')
|
||||
await scope.set('preference', 'system')
|
||||
expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
|
||||
})
|
||||
|
||||
it('does not recover superseded rejected or thrown writes', async () => {
|
||||
const describeCall = vi.fn()
|
||||
const mutate = vi.fn()
|
||||
.mockResolvedValueOnce(rejected())
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 3)))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const published = trackValues(scope)
|
||||
await Promise.all([
|
||||
scope.set('preference', 'dark'),
|
||||
scope.set('preference', 'system'),
|
||||
scope.set('preference', 'light'),
|
||||
])
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
expect(published.map(section => section?.preference)).toEqual([undefined, 'light'])
|
||||
})
|
||||
|
||||
it('keeps the write queue usable when a subscriber throws', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 1))
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 2))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
let thrown = false
|
||||
scope.subscribe(() => {
|
||||
if (thrown) return
|
||||
thrown = true
|
||||
throw new Error('subscriber failed')
|
||||
})
|
||||
await expect(scope.load()).rejects.toThrow('subscriber failed')
|
||||
await expect(scope.load()).resolves.toBeUndefined()
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 2 })
|
||||
})
|
||||
|
||||
it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => {
|
||||
const first = deferred<RpcResponse<SettingsNamespaceView>>()
|
||||
const mutate = vi.fn().mockReturnValue(first.promise)
|
||||
const describeCall = vi.fn()
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const published = trackValues(scope)
|
||||
const dark = scope.set('preference', 'dark')
|
||||
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
const light = scope.set('preference', 'light')
|
||||
let stopped = false
|
||||
const stop = scope.dispose().then(() => { stopped = true })
|
||||
await Promise.resolve()
|
||||
expect(stopped).toBe(false)
|
||||
first.resolve(ok(view({ preference: 'dark' }, 1)))
|
||||
await Promise.all([dark, light, stop])
|
||||
await scope.set('preference', 'system')
|
||||
await scope.load()
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
expect(published).toEqual([undefined])
|
||||
})
|
||||
|
||||
it('keeps a remote browser in memory mode without Host calls', async () => {
|
||||
const describeCall = vi.fn()
|
||||
const mutate = vi.fn()
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
'memory',
|
||||
)
|
||||
expect(scope.getSnapshot()).toEqual({
|
||||
status: 'unavailable', value: undefined, revision: undefined, writable: false, mode: 'memory',
|
||||
})
|
||||
await scope.load()
|
||||
await scope.set('preference', 'dark')
|
||||
await scope.dispose()
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('bindSettingsScope', () => {
|
||||
it('subscribes before the initial read and converges to the latest queued invalidation', async () => {
|
||||
const initial = deferred<ReturnType<typeof described>>()
|
||||
const describeCall = vi.fn()
|
||||
.mockReturnValueOnce(initial.promise)
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 2))
|
||||
.mockResolvedValueOnce(described({ preference: 'system' }, 3))
|
||||
const ctx = new Context()
|
||||
ctx.provide('connection', {
|
||||
api: { settings: { describe: describeCall } },
|
||||
isLoopback: true,
|
||||
} as never)
|
||||
let scope!: SettingsScope<UiTestSettings>
|
||||
const fiber = ctx.plugin({
|
||||
inject: ['connection'],
|
||||
apply: (plugin: Context) => {
|
||||
scope = bindSettingsScope<UiTestSettings>(plugin, { namespace: 'ui-test' })
|
||||
},
|
||||
})
|
||||
await fiber.await()
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledOnce() })
|
||||
ctx.emit('settings/changed', 'unrelated')
|
||||
ctx.emit('settings/changed', 'ui-test')
|
||||
ctx.emit('connection/reset')
|
||||
initial.resolve(described({ preference: 'dark' }, 1))
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) })
|
||||
await vi.waitFor(() => {
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 3 })
|
||||
})
|
||||
await fiber.dispose()
|
||||
ctx.emit('settings/changed', 'ui-test')
|
||||
await Promise.resolve()
|
||||
expect(describeCall).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('binds a remote browser in memory mode without starting a settings read', async () => {
|
||||
const describeCall = vi.fn()
|
||||
const ctx = new Context()
|
||||
ctx.provide('connection', {
|
||||
api: { settings: { describe: describeCall } },
|
||||
isLoopback: false,
|
||||
} as never)
|
||||
let scope!: SettingsScope<UiTestSettings>
|
||||
const fiber = ctx.plugin({
|
||||
inject: ['connection'],
|
||||
apply: (plugin: Context) => {
|
||||
scope = bindSettingsScope<UiTestSettings>(plugin, { namespace: 'ui-test' })
|
||||
},
|
||||
})
|
||||
await fiber.await()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', mode: 'memory', writable: false })
|
||||
await fiber.dispose()
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../schema-form"
|
||||
},
|
||||
{
|
||||
"path": "../../host/apiproxy"
|
||||
},
|
||||
|
||||
@@ -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/test-runtime/README.md
|
||||
README.md: 74da8fde7fd9cc3733d2d1ae03dd3d213e4d553e
|
||||
README.zh.md: a86b9e469a5632886891628267002a14588afeaa
|
||||
README.md: 455d6f564cea2cb8f88165a8bba1047c762d2fb0
|
||||
README.zh.md: e292c57c21dde1f7639ce37ee9b65930c6d153ea
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
jsdom slot test runtime for client feature specs: a real Cordis `Context`, the production `SlotsService` and web-react renderer, assembled around typed session/workspace doubles. Feature suites exercise declaration, registration, scope, store, inject, rendering, updates, and disposal without hand-building the machinery per suite — and without a second implementation of any production logic.
|
||||
|
||||
The doubles implement the same outward faces features receive through ctx (`TestSessions implements ISessions`, `TestWorkspaces implements IWorkspaces`; each fixture session is a `FixtureSession implements SessionFace`), so a production face change breaks the bench at compile time instead of silently drifting. Provide-bundle materialization runs the production `SessionProvideChannel` — the one implementation shared with `SessionsService`. Fixtures feed plain data: list rows, conversation snapshots (immer-patched via `updateSnapshot`), projection values, and `ISession`-typed behavior stubs that fail loud when a spec calls an unstubbed verb. The typed `provide()` constrains fakes for declared service names to `Partial` of that service's outward face.
|
||||
The doubles implement the same outward faces features receive through ctx (`TestSessions implements ISessions`, `TestWorkspaces implements IWorkspaces`; each fixture session is a `FixtureSession implements SessionFace`; `stubSettingsScope` is a `SettingsScope` with test-driven publications and a write spy), so a production face change breaks the bench at compile time instead of silently drifting. Provide-bundle materialization runs the production `SessionProvideChannel` — the one implementation shared with `SessionsService`. Fixtures feed plain data: list rows, conversation snapshots (immer-patched via `updateSnapshot`), projection values, and `ISession`-typed behavior stubs that fail loud when a spec calls an unstubbed verb. The typed `provide()` constrains fakes for declared service names to `Partial` of that service's outward face.
|
||||
|
||||
Local DOM snapshots: `declare(children)` registers an auto frame whose per-key `<div data-slot>` wrappers are snapshot roots; `renderSlot(key, owner)` returns the slot-local view (container, scoped Testing Library queries, in-place `update(owner)`); a registered snapshot serializer folds CSS-module class hashes (`_frame_a1b2c3` → `frame`) to keep `.snap` files structural and collapses `<svg>` internals to a `data-content` fingerprint. Suites needing a custom page frame use `root.declare(children, Frame)` instead; `mount(plugin)` runs a real fiber with fail-loud service prechecks, and `dispose()` tears down views, feature fibers, minted scopes, and persisted store state on one axis.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
面向 client feature 测试的 jsdom slot 测试运行时:真实 Cordis `Context`、生产 `SlotsService` 与 web-react 渲染器,围绕带类型的 session/workspace 测试替身组装。feature 套件无需逐套件手搭机器即可测遍声明、注册、scope、store、inject、渲染、更新与销毁——且不存在任何生产逻辑的第二份实现。
|
||||
|
||||
替身实现的正是 feature 经 ctx 拿到的对外面(`TestSessions implements ISessions`、`TestWorkspaces implements IWorkspaces`;每个 fixture session 是 `FixtureSession implements SessionFace`),生产面一旦改形,测试台在编译期即断,而非静默漂移。provide bundle 材料化直接运行生产 `SessionProvideChannel`——与 `SessionsService` 共用同一份实现。fixture 灌入的是普通数据:列表行、会话快照(经 `updateSnapshot` 以 immer 补丁改写)、projection 值,以及按 `ISession` 取型的行为桩——spec 调用未打桩的动词时报错自明。带类型的 `provide()` 将已声明服务名的 fake 约束为该服务对外面的 `Partial` 子集。
|
||||
替身实现的正是 feature 经 ctx 拿到的对外面(`TestSessions implements ISessions`、`TestWorkspaces implements IWorkspaces`;每个 fixture session 是 `FixtureSession implements SessionFace`;`stubSettingsScope` 是发布由测试驱动、带写入 spy 的 `SettingsScope`),生产面一旦改形,测试台在编译期即断,而非静默漂移。provide bundle 材料化直接运行生产 `SessionProvideChannel`——与 `SessionsService` 共用同一份实现。fixture 灌入的是普通数据:列表行、会话快照(经 `updateSnapshot` 以 immer 补丁改写)、projection 值,以及按 `ISession` 取型的行为桩——spec 调用未打桩的动词时报错自明。带类型的 `provide()` 将已声明服务名的 fake 约束为该服务对外面的 `Partial` 子集。
|
||||
|
||||
局部 DOM 快照:`declare(children)` 注册自动 frame,逐 key 的 `<div data-slot>` 包裹层即快照根;`renderSlot(key, owner)` 返回该 slot 的局部视图(container、限定范围的 Testing Library 查询、原位 `update(owner)`);注册的快照序列化器把 CSS-module 哈希类名折回语义名(`_frame_a1b2c3` → `frame`)保持 `.snap` 只含结构,并把 `<svg>` 内部折叠为 `data-content` 指纹。需要自定义页面 frame 的套件改用 `root.declare(children, Frame)`;`mount(plugin)` 在真实 fiber 上运行并对缺失服务先行报错;`dispose()` 沿单一轴拆除视图、feature fiber、已铸 scope 与持久化 store 状态。
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ import type { Stabilizer } from './fixtures.ts'
|
||||
|
||||
export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot.ts'
|
||||
export { FixtureSession, TestSessions } from './sessions.ts'
|
||||
export { stubSettingsScope } from './settings-scope.ts'
|
||||
export type { StubSettingsScope } from './settings-scope.ts'
|
||||
export { TestWorkspaces } from './workspaces.ts'
|
||||
export { conversationSnapshot, workspaceListState } from './fixtures.ts'
|
||||
export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts'
|
||||
|
||||
48
packages/client/test-runtime/src/settings-scope.ts
Normal file
48
packages/client/test-runtime/src/settings-scope.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/** Test double for the client settings-scope seam. */
|
||||
import { vi } from 'vitest'
|
||||
import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Handle over one stubbed scope: the scope, its write spy, and publication controls. */
|
||||
export interface StubSettingsScope<T> {
|
||||
/** The scope face handed to the service under test. */
|
||||
scope: SettingsScope<T>
|
||||
/** Spy behind `scope.set`; resolves immediately. */
|
||||
set: ReturnType<typeof vi.fn>
|
||||
/** @returns how many listeners are currently subscribed (disposal assertions). */
|
||||
listenerCount(): number
|
||||
/**
|
||||
* Replace part of the snapshot and notify subscribers, as a Host
|
||||
* acceptance would.
|
||||
* @param next - snapshot fields to replace.
|
||||
*/
|
||||
publish(next: Partial<SettingsScopeSnapshot<T>>): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an in-memory settings scope for service specs: starts in the host
|
||||
* loading state, records writes, and lets the test publish Host acceptances.
|
||||
* @returns the stub handle.
|
||||
*/
|
||||
export function stubSettingsScope<T>(): StubSettingsScope<T> {
|
||||
let snapshot: SettingsScopeSnapshot<T> = {
|
||||
status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host',
|
||||
}
|
||||
const listeners = new Set<() => void>()
|
||||
const set = vi.fn(() => Promise.resolve())
|
||||
return {
|
||||
scope: {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
},
|
||||
set,
|
||||
},
|
||||
set,
|
||||
listenerCount: () => listeners.size,
|
||||
publish: (next) => {
|
||||
snapshot = { ...snapshot, ...next }
|
||||
for (const listener of [...listeners]) listener()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Registers the conversation components, shared store, and service callbacks. */
|
||||
import type { Context } from 'cordis'
|
||||
import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { bindSettingsPreference, type ISessions, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSettingsScope, type ISessions, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
@@ -38,9 +38,7 @@ import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
|
||||
import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx'
|
||||
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
|
||||
import { en, NS, zh, type ConversationKey } from './locales.ts'
|
||||
import {
|
||||
BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE, isBusyEnterBehavior,
|
||||
} from '../submission-settings.ts'
|
||||
import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
@@ -106,14 +104,9 @@ export function apply(ctx: Context): void {
|
||||
|
||||
// Apply-time construction keeps store identity bound to this fiber.
|
||||
const chatStore = createChatStore()
|
||||
const submissionPolicy = new ComposerSubmissionPolicy()
|
||||
const preference = bindSettingsPreference(ctx, {
|
||||
namespace: CONVERSATION_SETTINGS_NAMESPACE,
|
||||
field: BUSY_ENTER_FIELD,
|
||||
decode: value => isBusyEnterBehavior(value) ? value : undefined,
|
||||
sync: (behavior) => { submissionPolicy.syncPreference(behavior) },
|
||||
})
|
||||
submissionPolicy.bindPersistence((behavior) => { void preference.persist(behavior) })
|
||||
const submissionPolicy = new ComposerSubmissionPolicy(
|
||||
bindSettingsScope<ConversationSettings>(ctx, { namespace: CONVERSATION_SETTINGS_NAMESPACE }),
|
||||
)
|
||||
|
||||
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
||||
name: 'settings.general.item',
|
||||
|
||||
@@ -3,35 +3,39 @@
|
||||
* preference and resolves keyboard gestures into queue/steer delivery modes;
|
||||
* Host and Agent keep the actual delivery-window authority.
|
||||
*/
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
createSnapshotStore, type SettingsScope, type SnapshotStore,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
BusyEnterBehavior, ComposerSubmitGesture, InputSubmitMode,
|
||||
} from '../contract/composer-submission.ts'
|
||||
import { DEFAULT_BUSY_ENTER_BEHAVIOR } from '../../submission-settings.ts'
|
||||
import { BUSY_ENTER_FIELD, DEFAULT_BUSY_ENTER_BEHAVIOR } from '../../submission-settings.ts'
|
||||
import type { ConversationSettings } from '../../submission-settings.ts'
|
||||
|
||||
export { DEFAULT_BUSY_ENTER_BEHAVIOR } from '../../submission-settings.ts'
|
||||
|
||||
/**
|
||||
* Persisted policy used by both the composer inject face and its Settings row.
|
||||
* Busy-Enter policy used by both the composer inject face and its Settings row.
|
||||
* Direct `steer` is intentionally best-effort: AgentLoop turns a closed-window
|
||||
* submission into the next waking Queue item.
|
||||
*/
|
||||
export class ComposerSubmissionPolicy {
|
||||
/** Reactive preference source for the Settings row. */
|
||||
readonly busyEnter: SnapshotStore<BusyEnterBehavior> = createSnapshotStore(DEFAULT_BUSY_ENTER_BEHAVIOR)
|
||||
private persist: (behavior: BusyEnterBehavior) => void
|
||||
|
||||
/** @param persist - durable write callback for explicit behavior changes. */
|
||||
constructor(persist: (behavior: BusyEnterBehavior) => void = () => {}) {
|
||||
this.persist = persist
|
||||
}
|
||||
private readonly host: SettingsScope<ConversationSettings> | undefined
|
||||
|
||||
/**
|
||||
* Bind the owning plugin's durable writer before the policy is exposed.
|
||||
* @param persist - callback accepting explicit behavior changes.
|
||||
* @param host - durable preference scope owned by the providing plugin;
|
||||
* absent compositions stay process-local. The adoption subscription shares
|
||||
* the scope's plugin lifetime — a disposed scope never publishes again, so
|
||||
* the policy needs no release hook.
|
||||
*/
|
||||
bindPersistence(persist: (behavior: BusyEnterBehavior) => void): void {
|
||||
this.persist = persist
|
||||
constructor(host?: SettingsScope<ConversationSettings>) {
|
||||
this.host = host
|
||||
if (host !== undefined) {
|
||||
host.subscribe(() => { this.adopt(host) })
|
||||
this.adopt(host)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,21 +57,23 @@ export class ComposerSubmissionPolicy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the plain-Enter behavior used during busy state.
|
||||
* Change the plain-Enter behavior used during busy state; the live value
|
||||
* publishes before the durable write starts.
|
||||
* @param behavior - Queue or Steer.
|
||||
*/
|
||||
setBusyEnter(behavior: BusyEnterBehavior): void {
|
||||
if (this.busyEnter.getSnapshot() === behavior) return
|
||||
this.busyEnter.set(behavior)
|
||||
this.persist(behavior)
|
||||
void this.host?.set(BUSY_ENTER_FIELD, behavior)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a Host preference without writing it back.
|
||||
* @param behavior - validated behavior from settings.
|
||||
* Adopt the scope's accepted durable behavior without writing it back.
|
||||
* @param host - the constructor-narrowed scope driving this adoption.
|
||||
*/
|
||||
syncPreference(behavior: BusyEnterBehavior): void {
|
||||
if (this.busyEnter.getSnapshot() === behavior) return
|
||||
this.busyEnter.set(behavior)
|
||||
private adopt(host: SettingsScope<ConversationSettings>): void {
|
||||
const section = host.getSnapshot().value
|
||||
if (section === undefined || this.busyEnter.getSnapshot() === section.busyEnter) return
|
||||
this.busyEnter.set(section.busyEnter)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,19 +5,16 @@ import z from 'schemastery'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
BUSY_ENTER_BEHAVIORS, BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE,
|
||||
DEFAULT_BUSY_ENTER_BEHAVIOR, type BusyEnterBehavior,
|
||||
DEFAULT_BUSY_ENTER_BEHAVIOR, type ConversationSettings,
|
||||
} from './submission-settings.ts'
|
||||
|
||||
export {
|
||||
BUSY_ENTER_BEHAVIORS, BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE,
|
||||
DEFAULT_BUSY_ENTER_BEHAVIOR, type BusyEnterBehavior,
|
||||
DEFAULT_BUSY_ENTER_BEHAVIOR, type BusyEnterBehavior, type ConversationSettings,
|
||||
} from './submission-settings.ts'
|
||||
|
||||
interface ConversationSettings {
|
||||
busyEnter: BusyEnterBehavior
|
||||
}
|
||||
|
||||
const ConversationSettingsSchema: z<ConversationSettings> = z.object({
|
||||
/** Durable conversation schema; also the wire envelope the browser scope validates against. */
|
||||
export const ConversationSettingsSchema: z<ConversationSettings> = z.object({
|
||||
[BUSY_ENTER_FIELD]: z.union([...BUSY_ENTER_BEHAVIORS]).default(DEFAULT_BUSY_ENTER_BEHAVIOR),
|
||||
})
|
||||
|
||||
|
||||
@@ -15,11 +15,8 @@ export type BusyEnterBehavior = typeof BUSY_ENTER_BEHAVIORS[number]
|
||||
/** Default preserves Enter-as-Queue for running conversations. */
|
||||
export const DEFAULT_BUSY_ENTER_BEHAVIOR: BusyEnterBehavior = 'queue'
|
||||
|
||||
/**
|
||||
* Narrow one settings-wire value to a busy-Enter behavior.
|
||||
* @param value - value crossing the settings boundary.
|
||||
* @returns whether the value names a supported behavior.
|
||||
*/
|
||||
export function isBusyEnterBehavior(value: unknown): value is BusyEnterBehavior {
|
||||
return BUSY_ENTER_BEHAVIORS.some(behavior => behavior === value)
|
||||
/** Durable conversation section shared by the Host schema and the browser scope. */
|
||||
export interface ConversationSettings {
|
||||
/** Delivery mode for plain Enter while the addressed agent is busy. */
|
||||
busyEnter: BusyEnterBehavior
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-a
|
||||
import {
|
||||
CONVERSATION_SETTINGS_NAMESPACE, DEFAULT_BUSY_ENTER_BEHAVIOR, apply,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation'
|
||||
import { isBusyEnterBehavior } from '../src/submission-settings.ts'
|
||||
|
||||
class MemorySettings extends Settings {
|
||||
readonly writable = true
|
||||
@@ -15,12 +14,6 @@ class MemorySettings extends Settings {
|
||||
}
|
||||
|
||||
describe('ui-conversation host', () => {
|
||||
it('narrows settings-wire values to the supported behavior pair', () => {
|
||||
expect(isBusyEnterBehavior('queue')).toBe(true)
|
||||
expect(isBusyEnterBehavior('steer')).toBe(true)
|
||||
expect(isBusyEnterBehavior('later')).toBe(false)
|
||||
})
|
||||
|
||||
it('registers, validates, and disposes the durable busy-Enter preference', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(MemorySettings).await()
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import {
|
||||
ComposerSubmissionPolicy, DEFAULT_BUSY_ENTER_BEHAVIOR,
|
||||
} from '../src/client/input/submission-policy.ts'
|
||||
import type { ConversationSettings } from '../src/submission-settings.ts'
|
||||
|
||||
describe('ComposerSubmissionPolicy', () => {
|
||||
it('defaults to Queue and only applies the preference while running', () => {
|
||||
@@ -16,8 +18,6 @@ describe('ComposerSubmissionPolicy', () => {
|
||||
expect(policy.resolve(true, 'accelerated', false)).toBe('queue')
|
||||
|
||||
const changed = vi.fn()
|
||||
const persist = vi.fn()
|
||||
policy.bindPersistence(persist)
|
||||
policy.busyEnter.subscribe(changed)
|
||||
policy.setBusyEnter('steer')
|
||||
expect(changed).toHaveBeenCalledTimes(1)
|
||||
@@ -25,25 +25,42 @@ describe('ComposerSubmissionPolicy', () => {
|
||||
expect(policy.resolve(true, 'accelerated', true)).toBe('queue')
|
||||
expect(policy.resolve(false, 'enter', true)).toBe('queue')
|
||||
expect(policy.resolve(false, 'accelerated', true)).toBe('queue')
|
||||
expect(persist).toHaveBeenCalledWith('steer')
|
||||
})
|
||||
|
||||
it('syncs a Host preference without writing it back and leaves an identical write untouched', () => {
|
||||
const persist = vi.fn()
|
||||
const policy = new ComposerSubmissionPolicy(persist)
|
||||
policy.syncPreference('steer')
|
||||
it('writes an explicit change through the scope after publishing it locally', () => {
|
||||
const host = stubSettingsScope<ConversationSettings>()
|
||||
const observed: string[] = []
|
||||
let liveBehavior = (): string => 'unconstructed'
|
||||
const scope: typeof host.scope = {
|
||||
...host.scope,
|
||||
set: (field, value) => {
|
||||
observed.push(`${field}=${String(value)}:${liveBehavior()}`)
|
||||
return host.scope.set(field, value)
|
||||
},
|
||||
}
|
||||
const policy = new ComposerSubmissionPolicy(scope)
|
||||
liveBehavior = () => policy.busyEnter.getSnapshot()
|
||||
policy.setBusyEnter('steer')
|
||||
expect(observed).toEqual(['busyEnter=steer:steer'])
|
||||
expect(host.set).toHaveBeenCalledWith('busyEnter', 'steer')
|
||||
expect(host.set).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('adopts a Host preference without writing it back and leaves an identical write untouched', () => {
|
||||
const host = stubSettingsScope<ConversationSettings>()
|
||||
const policy = new ComposerSubmissionPolicy(host.scope)
|
||||
host.publish({ status: 'ready', value: { busyEnter: 'steer' }, revision: 1, writable: true })
|
||||
expect(policy.busyEnter.getSnapshot()).toBe('steer')
|
||||
policy.setBusyEnter('steer')
|
||||
expect(persist).not.toHaveBeenCalled()
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
host.publish({ value: { busyEnter: 'steer' }, revision: 2 })
|
||||
expect(policy.busyEnter.getSnapshot()).toBe('steer')
|
||||
})
|
||||
|
||||
it('publishes the in-memory preference before calling the durable writer', () => {
|
||||
const policy = new ComposerSubmissionPolicy()
|
||||
const persist = vi.fn(() => {
|
||||
expect(policy.busyEnter.getSnapshot()).toBe('steer')
|
||||
})
|
||||
policy.bindPersistence(persist)
|
||||
policy.setBusyEnter('steer')
|
||||
expect(persist).toHaveBeenCalledOnce()
|
||||
it('adopts a section already standing at construction', () => {
|
||||
const host = stubSettingsScope<ConversationSettings>()
|
||||
host.publish({ status: 'ready', value: { busyEnter: 'steer' }, revision: 1, writable: true })
|
||||
const policy = new ComposerSubmissionPolicy(host.scope)
|
||||
expect(policy.busyEnter.getSnapshot()).toBe('steer')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
* owns the live theme preference (light/dark/system), resolves `system` through
|
||||
* `prefers-color-scheme`, and publishes immutable snapshots; it never touches
|
||||
* the DOM — ui-layout's presenter consumes the resolved snapshot. The Host
|
||||
* settings controller loads and stores the preference in the user-settings
|
||||
* settings scope loads and stores the preference in the user-settings
|
||||
* document. The plugin also registers the Appearance preference row into the
|
||||
* settings General section — the theme feature owns its own settings surface.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { bindSettingsPreference, type ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
bindSettingsScope, type ClientContext, type SettingsScope,
|
||||
} 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'
|
||||
@@ -18,7 +20,7 @@ import { createAppearanceRowStore } from './settings-store.ts'
|
||||
import { en, zh, type ThemeKey } from './locales.ts'
|
||||
import {
|
||||
DEFAULT_PREFERENCE, isThemePreference, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE,
|
||||
type ThemePreference,
|
||||
type ThemePreference, type ThemeSettings,
|
||||
} from '../theme-settings.ts'
|
||||
|
||||
export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx'
|
||||
@@ -26,7 +28,7 @@ export type { AppearanceRowState } from './settings-store.ts'
|
||||
export type { ThemeKey } from './locales.ts'
|
||||
export {
|
||||
DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE,
|
||||
type ThemePreference,
|
||||
type ThemePreference, type ThemeSettings,
|
||||
} from '../theme-settings.ts'
|
||||
|
||||
/** Namespace owning this feature's settings-row copy. */
|
||||
@@ -98,21 +100,21 @@ const BUILTIN_THEMES: readonly ThemeDefinition[] = Object.freeze([
|
||||
*/
|
||||
export class ThemeService {
|
||||
private readonly ctx: Context
|
||||
private readonly host: SettingsScope<ThemeSettings>
|
||||
private themes: ThemeDefinition[] = [...BUILTIN_THEMES]
|
||||
private preference: ThemePreference
|
||||
private revision = 0
|
||||
private snapshot: ThemeSnapshot
|
||||
private readonly media: MediaQueryList | undefined
|
||||
private persist: (preference: ThemePreference) => void
|
||||
|
||||
/**
|
||||
* @param ctx - owning context (change events are emitted on it; the
|
||||
* media-query listener is released through ctx.effect on dispose).
|
||||
* @param persist - durable write callback for built-in preferences.
|
||||
* media-query and scope listeners are released through ctx.effect on dispose).
|
||||
* @param host - durable preference scope owned by the same plugin.
|
||||
*/
|
||||
constructor(ctx: Context, persist: (preference: ThemePreference) => void = () => {}) {
|
||||
constructor(ctx: Context, host: SettingsScope<ThemeSettings>) {
|
||||
this.ctx = ctx
|
||||
this.persist = persist
|
||||
this.host = host
|
||||
this.preference = DEFAULT_PREFERENCE
|
||||
// Non-browser runs (node e2e booting the client tree) have no matchMedia.
|
||||
this.media = typeof matchMedia === 'undefined' ? undefined : matchMedia('(prefers-color-scheme: dark)')
|
||||
@@ -128,6 +130,8 @@ export class ThemeService {
|
||||
return () => { media.removeEventListener('change', onChange) }
|
||||
}, 'ui-theme: prefers-color-scheme listener')
|
||||
}
|
||||
ctx.effect(() => host.subscribe(() => { this.adopt() }), 'ui-theme: settings scope adoption')
|
||||
this.adopt()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,18 +142,10 @@ export class ThemeService {
|
||||
return this.snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind the owning plugin's durable writer before the service is provided.
|
||||
* @param persist - callback accepting built-in preference changes.
|
||||
*/
|
||||
bindPersistence(persist: (preference: ThemePreference) => void): void {
|
||||
this.persist = persist
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the theme preference — the only user preference write entry.
|
||||
* Built-in preferences are persisted and every accepted value emits
|
||||
* `theme/change`.
|
||||
* Built-in preferences are written through the settings scope and every
|
||||
* accepted value emits `theme/change`.
|
||||
* @param id - a registered theme id or `system`; unknown ids throw.
|
||||
*/
|
||||
setTheme(id: string): void {
|
||||
@@ -158,17 +154,15 @@ export class ThemeService {
|
||||
}
|
||||
if (this.preference === id) return
|
||||
this.preference = id as ThemePreference
|
||||
if (isThemePreference(id)) this.persist(id)
|
||||
if (isThemePreference(id)) void this.host.set(THEME_PREFERENCE_FIELD, id)
|
||||
this.publish()
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a preference read from Host settings without writing it back.
|
||||
* @param preference - validated durable preference.
|
||||
*/
|
||||
syncPreference(preference: ThemePreference): void {
|
||||
if (this.preference === preference) return
|
||||
this.preference = preference
|
||||
/** Adopt the scope's accepted durable preference without writing it back. */
|
||||
private adopt(): void {
|
||||
const section = this.host.getSnapshot().value
|
||||
if (section === undefined || this.preference === section.preference) return
|
||||
this.preference = section.preference
|
||||
this.publish()
|
||||
}
|
||||
|
||||
@@ -231,14 +225,8 @@ export const inject = ['slots', 'locale', 'connection']
|
||||
* @param ctx - client cordis context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const theme = new ThemeService(ctx)
|
||||
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) })
|
||||
const host = bindSettingsScope<ThemeSettings>(ctx, { namespace: THEME_SETTINGS_NAMESPACE })
|
||||
const theme = new ThemeService(ctx, host)
|
||||
ctx.provide('theme', theme)
|
||||
|
||||
ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), 'ui-theme: settings row dictionaries')
|
||||
|
||||
@@ -5,19 +5,16 @@ import z from 'schemastery'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE,
|
||||
type ThemePreference,
|
||||
type ThemeSettings,
|
||||
} from './theme-settings.ts'
|
||||
|
||||
export {
|
||||
DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE,
|
||||
type ThemePreference,
|
||||
type ThemePreference, type ThemeSettings,
|
||||
} from './theme-settings.ts'
|
||||
|
||||
interface ThemeSettings {
|
||||
preference: ThemePreference
|
||||
}
|
||||
|
||||
const ThemeSettingsSchema: z<ThemeSettings> = z.object({
|
||||
/** Durable theme schema; also the wire envelope the browser scope validates against. */
|
||||
export const ThemeSettingsSchema: z<ThemeSettings> = z.object({
|
||||
[THEME_PREFERENCE_FIELD]: z.union([...THEME_PREFERENCES]).default(DEFAULT_PREFERENCE),
|
||||
})
|
||||
|
||||
|
||||
@@ -15,10 +15,10 @@ export const name = 'client-ui-theme-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the settings seam validates and publishes the durable
|
||||
* No runtime invariant: the settings scope validates and publishes the durable
|
||||
* theme section, while the registry emits `theme/change` synchronously with
|
||||
* its own mutations. Store/registry agreement is covered directly by this
|
||||
* package's Host, controller, and service behavior specs.
|
||||
* package's Host, scope, and service behavior specs.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
|
||||
@@ -15,6 +15,12 @@ export type ThemePreference = typeof THEME_PREFERENCES[number]
|
||||
/** Default preference when the user-settings document has no override. */
|
||||
export const DEFAULT_PREFERENCE: ThemePreference = 'system'
|
||||
|
||||
/** Durable theme section shared by the Host schema and the browser scope. */
|
||||
export interface ThemeSettings {
|
||||
/** Selected built-in preference. */
|
||||
preference: ThemePreference
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow one wire or registry value to a persistable preference.
|
||||
* @param value - value crossing the settings or registry boundary.
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
apply, inject, SETTINGS_NS, THEME_SETTINGS_NAMESPACE,
|
||||
} from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import type { AppearanceRowInjected, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import { ThemeSettingsSchema } from '@deepseek-ai/dsh-client-ui-theme'
|
||||
import { AppearanceRow } from '../src/client/AppearanceRow.tsx'
|
||||
import type { createAppearanceRowStore } from '../src/client/settings-store.ts'
|
||||
|
||||
@@ -33,7 +34,7 @@ async function bench(isLoopback = true) {
|
||||
let preference = 'system'
|
||||
const namespace = () => ({
|
||||
ns: THEME_SETTINGS_NAMESPACE,
|
||||
schema: {},
|
||||
schema: ThemeSettingsSchema.toJSON(),
|
||||
value: { preference },
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { ThemeSettings, ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
|
||||
const make = (persist = vi.fn()): {
|
||||
const make = (host = stubSettingsScope<ThemeSettings>()): {
|
||||
ctx: Context
|
||||
theme: ThemeService
|
||||
events: ThemeSnapshot[]
|
||||
persist: typeof persist
|
||||
host: StubSettingsScope<ThemeSettings>
|
||||
} => {
|
||||
const ctx = new Context()
|
||||
const events: ThemeSnapshot[] = []
|
||||
ctx.on('theme/change', (snapshot) => { events.push(snapshot) })
|
||||
return { ctx, theme: new ThemeService(ctx, persist), events, persist }
|
||||
return { ctx, theme: new ThemeService(ctx, host.scope), events, host }
|
||||
}
|
||||
|
||||
describe('ThemeService', () => {
|
||||
@@ -27,12 +28,12 @@ describe('ThemeService', () => {
|
||||
expect(snapshot.themes.map(t => t.id)).toEqual(['light', 'dark'])
|
||||
})
|
||||
|
||||
it('setTheme switches, requests persistence, republishes, and keeps DOM untouched', () => {
|
||||
const { theme, events, persist } = make()
|
||||
it('setTheme switches, writes through the scope, republishes, and keeps DOM untouched', () => {
|
||||
const { theme, events, host } = make()
|
||||
theme.setTheme('dark')
|
||||
expect(theme.getTheme().preference).toBe('dark')
|
||||
expect(theme.getTheme().active.colorScheme).toBe('dark')
|
||||
expect(persist).toHaveBeenCalledWith('dark')
|
||||
expect(host.set).toHaveBeenCalledWith('preference', 'dark')
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toBe(theme.getTheme())
|
||||
// The service never touches presentation state.
|
||||
@@ -40,19 +41,26 @@ describe('ThemeService', () => {
|
||||
// Same-value set is a no-op (no extra event).
|
||||
theme.setTheme('dark')
|
||||
expect(events).toHaveLength(1)
|
||||
expect(persist).toHaveBeenCalledOnce()
|
||||
expect(host.set).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('syncs a Host preference without writing it back', () => {
|
||||
const { theme, events, persist } = make()
|
||||
theme.syncPreference('dark')
|
||||
it('adopts a published Host section without writing it back', () => {
|
||||
const { theme, events, host } = make()
|
||||
host.publish({ status: 'ready', value: { preference: 'dark' }, revision: 1, writable: true })
|
||||
expect(theme.getTheme().preference).toBe('dark')
|
||||
expect(events).toHaveLength(1)
|
||||
expect(persist).not.toHaveBeenCalled()
|
||||
theme.syncPreference('dark')
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
host.publish({ value: { preference: 'dark' }, revision: 2 })
|
||||
expect(events).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('adopts a section already standing at construction', () => {
|
||||
const host = stubSettingsScope<ThemeSettings>()
|
||||
host.publish({ status: 'ready', value: { preference: 'dark' }, revision: 1, writable: true })
|
||||
const { theme } = make(host)
|
||||
expect(theme.getTheme().preference).toBe('dark')
|
||||
})
|
||||
|
||||
it('throws on unknown setTheme ids, duplicate registration, and the system id', () => {
|
||||
const { theme } = make()
|
||||
expect(() => { theme.setTheme('sepia') }).toThrow('not registered')
|
||||
@@ -61,7 +69,7 @@ describe('ThemeService', () => {
|
||||
})
|
||||
|
||||
it('registered themes join the snapshot; disposing the active one resets to default', () => {
|
||||
const { theme, events, persist } = make()
|
||||
const { theme, events, host } = make()
|
||||
const dispose = theme.register({ id: 'sepia', colorScheme: 'light', tokens: { '--dsw-alias-bg-base': 'red' } })
|
||||
expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark', 'sepia'])
|
||||
theme.setTheme('sepia')
|
||||
@@ -71,7 +79,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).not.toHaveBeenCalled()
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
// register + set + dispose = three publishes; disposer is idempotent.
|
||||
expect(events.length).toBe(3)
|
||||
dispose()
|
||||
@@ -95,11 +103,11 @@ describe('ThemeService', () => {
|
||||
expect(events.map(e => e.revision)).toEqual([1, 2, 3, 4])
|
||||
})
|
||||
|
||||
it('uses a no-op persistence callback when constructed directly', () => {
|
||||
const ctx = new Context()
|
||||
const theme = new ThemeService(ctx)
|
||||
theme.setTheme('dark')
|
||||
expect(theme.getTheme().preference).toBe('dark')
|
||||
it('context dispose releases the scope subscription', async () => {
|
||||
const { ctx, host } = make()
|
||||
expect(host.listenerCount()).toBe(1)
|
||||
await ctx.fiber.dispose()
|
||||
expect(host.listenerCount()).toBe(0)
|
||||
})
|
||||
|
||||
describe('prefers-color-scheme resolution (stubbed matchMedia)', () => {
|
||||
|
||||
Reference in New Issue
Block a user