chore(gen1): 第一代版本封板 — DSH 2.0.17 兼容 + settings API 迁移 + React #130 修复

- peerDeps 升级到 0.2.0-rc.2
- settings API 迁移: register() → describe() + settings/document-updated
- turnTail slots.register 补 id 字段
- EditorHost viewer.component undefined guard
- 56/56 测试全绿, typecheck/build 通过

tag: v1.2.0-gen1
This commit is contained in:
figmar
2026-10-11 16:52:48 +08:00
parent df6f99c4a3
commit 0aafa94c4d
6 changed files with 484 additions and 258 deletions
+75 -49
View File
@@ -11,6 +11,8 @@ import { join, resolve as resolvePath } from 'node:path'
import { SettingsConflictError, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
import { apply, mediaTypeForPath, wsCloseReasonOf } from '../src/index.ts'
import { SidebarError } from '../src/wire.ts'
import { PrefsSchema } from '../src/config.ts'
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
import { encodeHtmlUrl } from '../src/html-route.ts'
import * as git from '../src/git.ts'
import { listDirectory } from '../src/fs-tree.ts'
@@ -828,29 +830,31 @@ describe('session cwd resolution over the API route', () => {
})
describe('side card settings routes', () => {
/** A minimal settings seam: register/describe/update with the revision guard. */
/** A minimal settings seam: describe/update with the revision guard (0.2.0-rc.2 API). */
const createFakeSettings = (pre?: Record<string, Record<string, unknown>>) => {
const namespaces = new Map<string, {
schema: unknown
value: Record<string, unknown> | undefined
revision: number
}>()
// 0.2.0-rc.2: the plugin's Config schema is auto-registered by the loader.
// The mock pre-seeds the sidebar namespace so describe() returns it even
// when the plugin no longer calls register() explicitly.
if (!pre || !('dsh-apes-sidebar' in pre)) {
// Use SIDEBAR_PREFS_DEFAULTS as the resolved value for the sidebar ns:
// the real SettingsForms service applies the Config schema defaults
// when no stored value exists, which is exactly what this constant is.
namespaces.set('dsh-apes-sidebar', { schema: (input: unknown) => input ?? SIDEBAR_PREFS_DEFAULTS, value: undefined, revision: 0 })
}
for (const [ns, value] of Object.entries(pre ?? {})) {
namespaces.set(ns, { schema: (input: unknown) => input, value, revision: 0 })
namespaces.set(ns, { schema: (input: unknown) => input ?? {}, value, revision: 0 })
}
const resolve = (entry: { schema: unknown; value: Record<string, unknown> | undefined }): unknown => {
const schema = entry.schema as (input: unknown) => unknown
return entry.value === undefined ? schema(undefined) : schema(entry.value)
}
return {
register(ns: string, schema: unknown) {
// Preserve a pre-seeded value: tests stage prefs through the `pre`
// map before the plugin mounts and registers the same namespace.
const existing = namespaces.get(ns)
namespaces.set(ns, { schema, value: existing?.value ?? undefined, revision: 0 })
return { get: () => ({}), watch: () => () => {}, update: async () => {}, replace: async () => {} }
},
describe() {
describe(_options?: { redactSecrets?: boolean }) {
return [...namespaces.entries()].map(([ns, entry]) => ({
ns,
value: resolve(entry),
@@ -864,7 +868,9 @@ describe('side card settings routes', () => {
if (expectedRevision !== undefined && expectedRevision !== entry.revision) {
throw new SettingsConflictError(ns as SettingsNamespace, expectedRevision, entry.revision)
}
entry.value = { ...entry.value, ...patch }
// The real SettingsForms re-applies the full schema (with defaults)
// on every update, so the stored value is always a complete object.
entry.value = { ...SIDEBAR_PREFS_DEFAULTS, ...entry.value, ...patch }
entry.revision += 1
},
}
@@ -872,6 +878,9 @@ describe('side card settings routes', () => {
const mountWithSettings = (settings?: unknown): SidebarWebRoute => {
const routes: SidebarWebRoute[] = []
// Event bus: the 0.2.0-rc.2 settings service emits
// `settings/document-updated` on the plugin context after each update.
const eventHandlers = new Map<string, Array<(...args: unknown[]) => void>>()
const ctx = {
webRuntime: { trustedHosts: [] },
webServer: {
@@ -882,11 +891,30 @@ describe('side card settings routes', () => {
tools: { register: () => () => {} },
effect: (fn: () => void | (() => void)) => { fn() },
inject: (deps: string[], callback: (sctx: { settings: unknown }) => void) => {
if (deps.includes('settings') && settings !== undefined) callback({ settings })
if (deps.includes('settings') && settings !== undefined) {
const s = settings as { update: Function; describe: Function }
// Wrap update to emit `settings/document-updated` after each write,
// mirroring the real SettingsForms service behavior.
const origUpdate = s.update.bind(s)
s.update = async (ns: string, ...rest: unknown[]) => {
await origUpdate(ns, ...rest)
const handlers = eventHandlers.get('settings/document-updated') ?? []
for (const h of handlers) h(ns)
}
callback({ settings: s })
}
return () => {}
},
// The session/agent event feeds: nothing emits in these tests.
on: () => () => {},
on: (event: string, handler: (...args: unknown[]) => void) => {
const existing = eventHandlers.get(event) ?? []
existing.push(handler)
eventHandlers.set(event, existing)
return () => {
const handlers = eventHandlers.get(event) ?? []
const idx = handlers.indexOf(handler)
if (idx >= 0) handlers.splice(idx, 1)
}
},
// No jobs/agents services: the jobs routes degrade to a 503.
get: () => undefined,
}
@@ -1002,6 +1030,9 @@ describe('side card settings routes', () => {
workspaceFence: true,
terminalShell: '',
terminalShellArgs: '',
titleBarScheme: 'auto',
titleBarPresetId: '',
customCss: '',
titleBarCompat: false,
titleBarStripPx: 40,
htmlViewerNoSandbox: false,
@@ -1144,23 +1175,18 @@ describe('agent terminal tool gating', () => {
it('injects the eight tools only when the side-card setting is enabled (default off)', () => {
let registered = 0
let disposed = 0
// The tools currently registered (registered minus disposed).
const live = (): number => registered - disposed
// A ref container: the watch callback is only assigned inside a closure,
// which TypeScript's control-flow analysis ignores (the bare variable
// would narrow to null and refuse the optional call).
const watcherRef: { current: (() => void) | null } = { current: null }
// 0.2.0-rc.2: no more register()/watch(). The plugin reads settings via
// describe() and listens to `settings/document-updated` on ctx.on().
let enabled = false
const handlerRef: { current: ((ns: string) => void) | null } = { current: null }
const settings = {
register() {
return {
get: () => ({ agentTerminalTools: enabled }),
watch: (callback: () => void) => { watcherRef.current = callback; return () => {} },
update: async () => {},
replace: async () => {},
}
},
describe: () => [],
describe: () => [{
ns: 'dsh-apes-sidebar',
value: { ...SIDEBAR_PREFS_DEFAULTS, agentTerminalTools: enabled },
applies: 'live' as const,
revision: 0,
}],
async update() {},
}
const ctx = {
@@ -1176,9 +1202,10 @@ describe('agent terminal tool gating', () => {
if (deps.includes('settings')) callback({ settings })
return () => {}
},
// The session/agent event feeds: nothing emits in these tests.
on: () => () => {},
// No jobs/agents services: the jobs routes degrade to a 503.
on: (event: string, handler: (...args: unknown[]) => void) => {
if (event === 'settings/document-updated') handlerRef.current = handler as (ns: string) => void
return () => { handlerRef.current = null }
},
get: () => undefined,
}
apply(ctx as never)
@@ -1186,18 +1213,18 @@ describe('agent terminal tool gating', () => {
expect(live()).toBe(0)
// Flipping the setting on registers all eight tools.
enabled = true
watcherRef.current?.()
handlerRef.current?.('dsh-apes-sidebar')
expect(live()).toBe(8)
expect(disposed).toBe(0)
// Flipping it back off unregisters them (and releases any agent terminals).
enabled = false
watcherRef.current?.()
handlerRef.current?.('dsh-apes-sidebar')
expect(live()).toBe(0)
expect(disposed).toBe(8)
// And a redundant toggle registers them fresh (no double-registration per
// flip: the guard only skips when the tools are already live).
enabled = true
watcherRef.current?.()
handlerRef.current?.('dsh-apes-sidebar')
expect(live()).toBe(8)
expect(registered).toBe(16)
})
@@ -1208,18 +1235,15 @@ describe('agent sidebar-open tool gating', () => {
let registered = 0
let disposed = 0
const live = (): number => registered - disposed
const watcherRef: { current: (() => void) | null } = { current: null }
let enabled = false
const handlerRef: { current: ((ns: string) => void) | null } = { current: null }
const settings = {
register() {
return {
get: () => ({ agentOpenTools: enabled, tabsEnabled: {} }),
watch: (callback: () => void) => { watcherRef.current = callback; return () => {} },
update: async () => {},
replace: async () => {},
}
},
describe: () => [],
describe: () => [{
ns: 'dsh-apes-sidebar',
value: { ...SIDEBAR_PREFS_DEFAULTS, agentOpenTools: enabled },
applies: 'live' as const,
revision: 0,
}],
async update() {},
}
const ctx = {
@@ -1235,8 +1259,10 @@ describe('agent sidebar-open tool gating', () => {
if (deps.includes('settings')) callback({ settings })
return () => {}
},
// The session/agent event feeds: nothing emits in these tests.
on: () => () => {},
on: (event: string, handler: (...args: unknown[]) => void) => {
if (event === 'settings/document-updated') handlerRef.current = handler as (ns: string) => void
return () => { handlerRef.current = null }
},
get: () => undefined,
}
apply(ctx as never)
@@ -1244,17 +1270,17 @@ describe('agent sidebar-open tool gating', () => {
expect(live()).toBe(0)
// Flipping the setting on registers the single sidebar_open tool.
enabled = true
watcherRef.current?.()
handlerRef.current?.('dsh-apes-sidebar')
expect(live()).toBe(1)
expect(disposed).toBe(0)
// Flipping it back off unregisters it (and drains the undelivered queue).
enabled = false
watcherRef.current?.()
handlerRef.current?.('dsh-apes-sidebar')
expect(live()).toBe(0)
expect(disposed).toBe(1)
// And a redundant toggle registers it fresh (no double-registration).
enabled = true
watcherRef.current?.()
handlerRef.current?.('dsh-apes-sidebar')
expect(live()).toBe(1)
expect(registered).toBe(2)
})