The per-file coverage gate flagged four uncovered locations the new section directory introduced: refresh() after disposal, a read superseded by a newer one, and the two invalidation handlers that make the served namespaces re-read (settings/document-updated and connection/reset).
195 lines
7.7 KiB
TypeScript
195 lines
7.7 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 served namespaces when the Host commits a settings document', async () => {
|
|
// Which namespaces the Host serves is a registration fact the wire never
|
|
// announces on its own, so the section rides the invalidation that can
|
|
// accompany a changed composition.
|
|
const { ctx, slots, describeSettings } = await bench(['bash'])
|
|
declareRoot(slots)
|
|
await ctx.plugin({ inject: [...inject], apply }).await()
|
|
await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() })
|
|
describeSettings.mockClear()
|
|
|
|
ctx.remote.$dispatch('settings/document-updated', ['bash', 1])
|
|
|
|
await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() })
|
|
})
|
|
|
|
it('re-reads the served namespaces after a reconnect', async () => {
|
|
const { ctx, slots, describeSettings } = await bench(['bash'])
|
|
declareRoot(slots)
|
|
await ctx.plugin({ inject: [...inject], apply }).await()
|
|
await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() })
|
|
describeSettings.mockClear()
|
|
|
|
ctx.emit('connection/reset')
|
|
|
|
await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() })
|
|
})
|
|
|
|
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()
|
|
})
|
|
})
|