Files
deepseek-harness/packages/client/ui-plugin-config/tests/apply.client.spec.ts
Yichen Jiang 4366528a38 feat(settings): serve every registered namespace and key plugin cards on it
A plugin that registered a settings namespace could not reach the browser
configuration page: the api-proxy filtered every read and gated every
write through two hardcoded namespace lists, and the plugin configuration
section rendered an unordered list of cards carrying an opaque id rather
than the namespace they edit. Both gates lived in this repository, so a
user-authored plugin was configurable only by hand-editing settings.yaml.

The proxy now serves whatever ctx.settings.describe() returns and adds no
boundary of its own; a name no registration answers folds into the seam's
own settings-rejected, and the settings-not-exposed code retires. The
settings seam is untouched: which client may read a namespace, and which
page renders it, are facts about consumers.

settings.plugin.item becomes a keyed slot whose key is the namespace a
card edits, following tool.call.toolview. The section reads describe once
and dispatches the intersection of the slot ledger and the served set, so
a namespace another surface owns renders nothing without declaring
anything, and a card for an uncomposed plugin is never dispatched.
2026-08-12 21:07:57 +08:00

168 lines
6.6 KiB
TypeScript

/** What the browser half registers, and that it all leaves with the fiber. */
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { SettingsScopeService } from '@deepseek-ai/dsh-client-ui-settings/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-plugin-config/client'
// The service reads its initial locale from the browser; these specs assert
// the shipped Chinese copy, so they state the browser they assume.
usePinnedBrowserLanguages('zh-CN')
/**
* @param served - namespaces the Host describes; omitted answers a failed read,
* which is what most of these specs want (no card has anything to render).
*/
async function bench(served?: string[]) {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
const describeCredentials = vi.fn(() => Promise.resolve({ rpcId: 'c', result: { ok: false, error: {} } }))
const describeSettings = vi.fn(() => Promise.resolve(served === undefined
? { rpcId: 's', result: { ok: false, error: {} } }
: {
rpcId: 's',
result: {
ok: true,
value: {
writable: true,
hasDocument: true,
namespaces: served.map(ns => ({
ns, schema: {}, value: {}, applies: 'live', secrets: [], revision: 0,
})),
},
},
}))
// The section binds its scopes through the Settings surface's service, and
// forwarded Host events reach it through the same `$dispatch` handoff the
// connection sink makes.
new TestRemote(ctx)
ctx.provide('connection', {
isLoopback: true,
api: {
settings: { describe: describeSettings },
credentials: { describe: describeCredentials },
},
} as never)
await ctx.plugin(SettingsScopeService).await()
return { ctx, slots: ctx.get('slots') as SlotsService, describeCredentials, describeSettings }
}
function declareRoot(slots: SlotsService): () => void {
return slots.register({
name: 'root',
children: { 'settings.section': { kind: 'list', scope: 'root' } },
} as never, () => null)
}
describe('ui-plugin-config apply', () => {
it('declares the services it uses', () => {
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope'])
})
it('registers the section and declares the per-plugin card slot', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
const section = slots.entries('settings.section')[0]!
expect(section.options).toMatchObject({ id: 'plugins', order: 30 })
// The nav label is a locale-following thunk; owners resolve it at read time.
expect(resolveSlotLabel(section.options.label)).toBe('插件配置')
expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'keyed', scope: 'root' })
})
it('keys each card it ships on the settings namespace that card edits', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
expect(slots.entries('settings.plugin.item').map(entry => entry.options.key))
.toEqual(['bash', 'agent-loop', 'web-search-deepseek'])
})
it('dispatches the served namespaces its cards claim, and no others', async () => {
// ui-theme is served but belongs to another surface, and a deployment
// composing no PowerShell/POSIX executor serves no `bash` at all.
const { ctx, slots } = await bench(['agent-loop', 'ui-theme', 'web-search-deepseek'])
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
const section = slots.entries('settings.section')[0]!
const face = (section as { inject?: () => unknown })
.inject?.() as { hooks: { pluginConfigSection: { getSnapshot: () => { namespaces: string[] } } } }
await vi.waitFor(() => {
expect(face.hooks.pluginConfigSection.getSnapshot().namespaces)
.toEqual(['agent-loop', 'web-search-deepseek'])
})
})
it('injects one business face per card', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
for (const entry of slots.entries('settings.plugin.item')) {
const face = (entry as { inject?: () => unknown }).inject?.() as { hooks: Record<string, unknown> }
// Each card injects exactly one snapshot store plus its own actions.
expect(Object.keys(face.hooks)).toHaveLength(1)
}
})
it('re-reads the credential when the Host reports the watched reference changed', async () => {
const { ctx, slots, describeCredentials } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() })
describeCredentials.mockClear()
// A key written on another surface changes no settings section, so this
// event is the only thing that reaches the card.
ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY'])
await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) })
})
it('ignores a credential change for a reference no card watches', async () => {
const { ctx, slots, describeCredentials } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() })
describeCredentials.mockClear()
ctx.remote.$dispatch('credentials/updated', ['SOME_OTHER_KEY'])
await Promise.resolve()
expect(describeCredentials).not.toHaveBeenCalled()
})
it('registers into a declaration that arrives after apply', async () => {
const { ctx, slots } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
declareRoot(slots)
await vi.waitFor(() => { expect(slots.entries('settings.section')).toHaveLength(1) })
})
it('collapses every contribution on teardown', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(slots.entries('settings.plugin.item')).toHaveLength(3)
await fiber.dispose()
expect(slots.entries('settings.section')).toHaveLength(0)
expect(slots.spec('settings.plugin.item')).toBeUndefined()
})
})