feat(client): configure host-plane plugins from a settings section
The section knows no namespace: it declares `settings.plugin.item` and renders whatever cards were registered into it, so a plugin that ships a browser half owns its card and its controls. The three cards here cover the host-plane sections this deployment exposes. A field shows its effective value and, when the raw user layer carries it, an override badge and a reset that clears it back to the composition layer. Controls commit on blur and Enter rather than per keystroke, which would burn namespace revisions and race its own reads. The search key is the one value that never rides a response: the card reports only whether one is configured and writes it through the credentials domain, addressed by the reference the section names. A card renders nothing while its namespace is unavailable — a deployment that does not compose the owning plugin should show no trace of it rather than a disabled card the user cannot act on.
This commit is contained in:
100
packages/client/ui-plugin-config/tests/apply.spec.ts
Normal file
100
packages/client/ui-plugin-config/tests/apply.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/** What the browser half registers, and that it all leaves with the fiber. */
|
||||
|
||||
import { Context } from '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 { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
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')
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
ctx.provide('connection', {
|
||||
isLoopback: true,
|
||||
api: {
|
||||
settings: { describe: vi.fn(() => Promise.resolve({ rpcId: 's', result: { ok: false, error: {} } })) },
|
||||
credentials: { describe: vi.fn(() => Promise.resolve({ rpcId: 'c', result: { ok: false, error: {} } })) },
|
||||
},
|
||||
} as never)
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService }
|
||||
}
|
||||
|
||||
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'])
|
||||
})
|
||||
|
||||
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: 'list', scope: 'root' })
|
||||
})
|
||||
|
||||
it('registers one card per host-plane section it ships, in a stable order', 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.id))
|
||||
.toEqual(['bash', 'agent-loop', 'web-search'])
|
||||
})
|
||||
|
||||
it('injects a live card count and one business face per card', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
const section = slots.entries('settings.section')[0]!
|
||||
expect((section as { inject?: () => unknown }).inject?.()).toEqual({ cardCount: 3 })
|
||||
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('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()
|
||||
})
|
||||
})
|
||||
330
packages/client/ui-plugin-config/tests/fields.spec.tsx
Normal file
330
packages/client/ui-plugin-config/tests/fields.spec.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Field-control behavior: when a draft becomes a write, what a bad draft does
|
||||
* instead, and how an overridden field offers its reset.
|
||||
*/
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NumberField, SecretField, TextField } from '../src/client/fields.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const frame = {
|
||||
id: 'field',
|
||||
label: 'Command timeout',
|
||||
hint: 'How long one command may run.',
|
||||
overriddenLabel: 'Overridden',
|
||||
resetLabel: 'Reset to default',
|
||||
disabled: false,
|
||||
}
|
||||
|
||||
describe('NumberField', () => {
|
||||
it('commits a changed draft on blur', () => {
|
||||
const onCommit = vi.fn()
|
||||
render(
|
||||
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={60_000} onCommit={onCommit} />,
|
||||
)
|
||||
const input = screen.getByLabelText('Command timeout')
|
||||
|
||||
fireEvent.change(input, { target: { value: '9000' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onCommit).toHaveBeenCalledWith(9_000)
|
||||
})
|
||||
|
||||
it('commits on Enter through the blur the key triggers', () => {
|
||||
const onCommit = vi.fn()
|
||||
render(
|
||||
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={60_000} onCommit={onCommit} />,
|
||||
)
|
||||
const input = screen.getByLabelText('Command timeout')
|
||||
|
||||
fireEvent.change(input, { target: { value: '1234' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onCommit).toHaveBeenCalledWith(1_234)
|
||||
})
|
||||
|
||||
it('restores the last good value instead of committing a draft that is not a number', () => {
|
||||
const onCommit = vi.fn()
|
||||
render(
|
||||
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={60_000} onCommit={onCommit} />,
|
||||
)
|
||||
const input = screen.getByLabelText('Command timeout')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'soon' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onCommit).not.toHaveBeenCalled()
|
||||
expect(input).toHaveProperty('value', '60000')
|
||||
})
|
||||
|
||||
it('writes nothing when the draft settles on the value already shown', () => {
|
||||
const onCommit = vi.fn()
|
||||
render(
|
||||
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={60_000} onCommit={onCommit} />,
|
||||
)
|
||||
const input = screen.getByLabelText('Command timeout')
|
||||
|
||||
fireEvent.change(input, { target: { value: '60000' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onCommit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('offers the reset only while the field is overridden', () => {
|
||||
const onReset = vi.fn()
|
||||
const { rerender } = render(
|
||||
<NumberField {...frame} overridden={false} onReset={onReset} value={9_000} onCommit={vi.fn()} />,
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: 'Reset to default' })).toBeNull()
|
||||
|
||||
rerender(
|
||||
<NumberField {...frame} overridden onReset={onReset} value={9_000} onCommit={vi.fn()} />,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset to default' }))
|
||||
|
||||
expect(screen.getByText('Overridden')).toBeTruthy()
|
||||
expect(onReset).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('re-seeds the draft when the authoritative value changes underneath', () => {
|
||||
const { rerender } = render(
|
||||
<NumberField {...frame} overridden onReset={vi.fn()} value={9_000} onCommit={vi.fn()} />,
|
||||
)
|
||||
expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '9000')
|
||||
|
||||
rerender(
|
||||
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={60_000} onCommit={vi.fn()} />,
|
||||
)
|
||||
|
||||
expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '60000')
|
||||
})
|
||||
|
||||
it('ignores a keystroke that is not Enter', () => {
|
||||
const onCommit = vi.fn()
|
||||
render(
|
||||
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={60_000} onCommit={onCommit} />,
|
||||
)
|
||||
const input = screen.getByLabelText('Command timeout')
|
||||
|
||||
fireEvent.change(input, { target: { value: '9000' } })
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
|
||||
expect(onCommit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('suppresses every interaction while disabled', () => {
|
||||
const onCommit = vi.fn()
|
||||
const onReset = vi.fn()
|
||||
render(
|
||||
<NumberField
|
||||
{...frame}
|
||||
disabled
|
||||
overridden
|
||||
onReset={onReset}
|
||||
value={9_000}
|
||||
onCommit={onCommit}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByLabelText('Command timeout')
|
||||
|
||||
expect(input).toHaveProperty('disabled', true)
|
||||
expect(screen.getByRole('button', { name: 'Reset to default' })).toHaveProperty('disabled', true)
|
||||
expect(onCommit).not.toHaveBeenCalled()
|
||||
expect(onReset).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TextField', () => {
|
||||
it('commits the trimmed draft', () => {
|
||||
const onCommit = vi.fn()
|
||||
render(
|
||||
<TextField
|
||||
{...frame}
|
||||
label="Endpoint"
|
||||
overridden={false}
|
||||
onReset={vi.fn()}
|
||||
value=""
|
||||
onCommit={onCommit}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByLabelText('Endpoint')
|
||||
|
||||
fireEvent.change(input, { target: { value: ' https://search.test/v1 ' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onCommit).toHaveBeenCalledWith('https://search.test/v1')
|
||||
})
|
||||
|
||||
it('commits an emptied draft, which clears the field', () => {
|
||||
const onCommit = vi.fn()
|
||||
render(
|
||||
<TextField
|
||||
{...frame}
|
||||
label="Endpoint"
|
||||
overridden
|
||||
onReset={vi.fn()}
|
||||
value="https://search.test/v1"
|
||||
onCommit={onCommit}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByLabelText('Endpoint')
|
||||
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onCommit).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('renders its placeholder and commits on Enter', () => {
|
||||
const onCommit = vi.fn()
|
||||
render(
|
||||
<TextField
|
||||
{...frame}
|
||||
label="Endpoint"
|
||||
placeholder="https://api.deepseek.com"
|
||||
overridden={false}
|
||||
onReset={vi.fn()}
|
||||
value=""
|
||||
onCommit={onCommit}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByLabelText('Endpoint')
|
||||
expect(input).toHaveProperty('placeholder', 'https://api.deepseek.com')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'https://other.test' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onCommit).toHaveBeenCalledWith('https://other.test')
|
||||
})
|
||||
|
||||
it('ignores a keystroke that is not Enter and writes nothing unchanged', () => {
|
||||
const onCommit = vi.fn()
|
||||
render(
|
||||
<TextField
|
||||
{...frame}
|
||||
label="Endpoint"
|
||||
overridden={false}
|
||||
onReset={vi.fn()}
|
||||
value="https://search.test/v1"
|
||||
onCommit={onCommit}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByLabelText('Endpoint')
|
||||
|
||||
fireEvent.keyDown(input, { key: 'a' })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onCommit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SecretField', () => {
|
||||
it('commits a non-empty draft and clears the control after writing', () => {
|
||||
const onCommit = vi.fn()
|
||||
render(
|
||||
<SecretField
|
||||
{...frame}
|
||||
label="API key"
|
||||
configured={false}
|
||||
stateLabel="No key is configured."
|
||||
onCommit={onCommit}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByLabelText('API key')
|
||||
|
||||
fireEvent.change(input, { target: { value: ' ds-secret ' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onCommit).toHaveBeenCalledWith('ds-secret')
|
||||
expect(input).toHaveProperty('value', '')
|
||||
})
|
||||
|
||||
it('keeps the stored key when the draft is left blank', () => {
|
||||
const onCommit = vi.fn()
|
||||
render(
|
||||
<SecretField
|
||||
{...frame}
|
||||
label="API key"
|
||||
configured
|
||||
stateLabel="A key is configured."
|
||||
onCommit={onCommit}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByLabelText('API key')
|
||||
|
||||
fireEvent.change(input, { target: { value: ' ' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onCommit).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('A key is configured.')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('ignores a keystroke that is not Enter', () => {
|
||||
const onCommit = vi.fn()
|
||||
render(
|
||||
<SecretField
|
||||
{...frame}
|
||||
label="API key"
|
||||
configured={false}
|
||||
stateLabel="No key is configured."
|
||||
onCommit={onCommit}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByLabelText('API key')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'ds-secret' } })
|
||||
fireEvent.keyDown(input, { key: 'Tab' })
|
||||
|
||||
expect(onCommit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never renders the value it writes', () => {
|
||||
render(
|
||||
<SecretField
|
||||
{...frame}
|
||||
label="API key"
|
||||
configured
|
||||
stateLabel="A key is configured."
|
||||
onCommit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByLabelText('API key')).toHaveProperty('type', 'password')
|
||||
})
|
||||
|
||||
it('commits on Enter and stays disabled when the document is read-only', () => {
|
||||
const onCommit = vi.fn()
|
||||
const { rerender } = render(
|
||||
<SecretField
|
||||
{...frame}
|
||||
label="API key"
|
||||
configured={false}
|
||||
stateLabel="No key is configured."
|
||||
onCommit={onCommit}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByLabelText('API key')
|
||||
fireEvent.change(input, { target: { value: 'ds-secret' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
fireEvent.blur(input)
|
||||
expect(onCommit).toHaveBeenCalledWith('ds-secret')
|
||||
|
||||
rerender(
|
||||
<SecretField
|
||||
{...frame}
|
||||
disabled
|
||||
label="API key"
|
||||
configured
|
||||
stateLabel="A key is configured."
|
||||
onCommit={onCommit}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByLabelText('API key')).toHaveProperty('disabled', true)
|
||||
})
|
||||
})
|
||||
25
packages/client/ui-plugin-config/tests/invariant.spec.ts
Normal file
25
packages/client/ui-plugin-config/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/** The package's node half: an empty host body and an explained empty invariant companion. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as PluginConfigInvariant from '@deepseek-ai/dsh-client-ui-plugin-config/invariant'
|
||||
|
||||
describe('invariant companion', () => {
|
||||
it('reserves package ownership with an empty installer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
|
||||
await expect(ctx.plugin(PluginConfigInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('has an empty node half', async () => {
|
||||
const { apply } = await import('@deepseek-ai/dsh-client-ui-plugin-config')
|
||||
|
||||
// The host body exists only so the plugin appears in the host cordis.yml;
|
||||
// every surface this package ships lives in the browser half.
|
||||
apply()
|
||||
|
||||
expect(typeof apply).toBe('function')
|
||||
})
|
||||
})
|
||||
223
packages/client/ui-plugin-config/tests/section.spec.tsx
Normal file
223
packages/client/ui-plugin-config/tests/section.spec.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* What the section and its cards show: the empty line when no plugin
|
||||
* contributed one, a card that renders nothing while its namespace is
|
||||
* unavailable, and the read-only notice a locked document produces.
|
||||
*/
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { AgentLoopCard } from '../src/client/AgentLoopCard.tsx'
|
||||
import type { AgentLoopCardProps } from '../src/client/AgentLoopCard.tsx'
|
||||
import { BashCard } from '../src/client/BashCard.tsx'
|
||||
import type { BashCardProps } from '../src/client/BashCard.tsx'
|
||||
import { PluginConfigSection } from '../src/client/PluginConfigSection.tsx'
|
||||
import type { PluginConfigSectionProps } from '../src/client/PluginConfigSection.tsx'
|
||||
import { WebSearchCard } from '../src/client/WebSearchCard.tsx'
|
||||
import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx'
|
||||
import type { AgentLoopCardState } from '../src/client/agent-loop-store.ts'
|
||||
import type { BashCardState } from '../src/client/bash-store.ts'
|
||||
import type { WebSearchCardState } from '../src/client/web-search-store.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const t = (key: keyof typeof en) => en[key]
|
||||
|
||||
function renderSection(cardCount: number, cards = 'cards') {
|
||||
const props = {
|
||||
t,
|
||||
cardCount,
|
||||
renderSlot: () => <li>{cards}</li>,
|
||||
} as unknown as PluginConfigSectionProps
|
||||
render(<PluginConfigSection {...props} />)
|
||||
}
|
||||
|
||||
function renderBash(state: Partial<BashCardState> = {}) {
|
||||
const store = createSnapshotStore<BashCardState>({
|
||||
available: true,
|
||||
writable: true,
|
||||
timeoutMs: { value: 60_000, overridden: false },
|
||||
maxOutputBytes: { value: 64_000, overridden: false },
|
||||
...state,
|
||||
})
|
||||
const actions = {
|
||||
setTimeoutMs: vi.fn(),
|
||||
resetTimeoutMs: vi.fn(),
|
||||
setMaxOutputBytes: vi.fn(),
|
||||
resetMaxOutputBytes: vi.fn(),
|
||||
}
|
||||
const props = {
|
||||
...actions,
|
||||
t,
|
||||
useBashCard: bindSnapshotSelector(store),
|
||||
} as unknown as BashCardProps
|
||||
render(<BashCard {...props} />)
|
||||
return actions
|
||||
}
|
||||
|
||||
describe('PluginConfigSection', () => {
|
||||
it('says so when no plugin contributed a card', () => {
|
||||
renderSection(0)
|
||||
|
||||
expect(screen.getByText(en.empty)).toBeTruthy()
|
||||
expect(screen.queryByText('cards')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the card list once a plugin contributed one', () => {
|
||||
renderSection(1)
|
||||
|
||||
expect(screen.getByText('cards')).toBeTruthy()
|
||||
expect(screen.queryByText(en.empty)).toBeNull()
|
||||
})
|
||||
|
||||
it('leads with its own heading and intro', () => {
|
||||
renderSection(1)
|
||||
|
||||
expect(screen.getByRole('heading', { name: en.title })).toBeTruthy()
|
||||
expect(screen.getByText(en.intro)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BashCard', () => {
|
||||
it('renders nothing while its namespace is unavailable', () => {
|
||||
const { container } = render(<div />)
|
||||
renderBash({ available: false })
|
||||
|
||||
expect(container.textContent).toBe('')
|
||||
expect(screen.queryByText(en.bashTitle)).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the plugin and reveals its fields only once expanded', () => {
|
||||
renderBash()
|
||||
expect(screen.getByText(en.bashTitle)).toBeTruthy()
|
||||
expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy()
|
||||
expect(screen.getByLabelText(en.bashMaxOutputBytes)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('commits an edited field through its action', () => {
|
||||
const actions = renderBash()
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
const input = screen.getByLabelText(en.bashTimeoutMs)
|
||||
fireEvent.change(input, { target: { value: '9000' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(actions.setTimeoutMs).toHaveBeenCalledWith(9_000)
|
||||
})
|
||||
|
||||
it('offers the reset for an overridden field only', () => {
|
||||
const actions = renderBash({ timeoutMs: { value: 9_000, overridden: true } })
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
// One badge and one reset: the output cap is still inherited.
|
||||
expect(screen.getAllByText(en.overridden)).toHaveLength(1)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.reset }))
|
||||
|
||||
expect(actions.resetTimeoutMs).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('says the document is read-only and disables its controls', () => {
|
||||
renderBash({ writable: false })
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
expect(screen.getByRole('status')).toHaveProperty('textContent', en.readOnly)
|
||||
expect(screen.getByLabelText(en.bashTimeoutMs)).toHaveProperty('disabled', true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentLoopCard', () => {
|
||||
it('edits the only field it owns', () => {
|
||||
const store = createSnapshotStore<AgentLoopCardState>({
|
||||
available: true,
|
||||
writable: true,
|
||||
maxParallelToolCalls: { value: 10, overridden: false },
|
||||
})
|
||||
const setMaxParallelToolCalls = vi.fn()
|
||||
const props = {
|
||||
t,
|
||||
useAgentLoopCard: bindSnapshotSelector(store),
|
||||
setMaxParallelToolCalls,
|
||||
resetMaxParallelToolCalls: vi.fn(),
|
||||
} as unknown as AgentLoopCardProps
|
||||
render(<AgentLoopCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByText(en.agentLoopTitle))
|
||||
const input = screen.getByLabelText(en.agentLoopMaxParallel)
|
||||
fireEvent.change(input, { target: { value: '2' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(setMaxParallelToolCalls).toHaveBeenCalledWith(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebSearchCard', () => {
|
||||
function renderWebSearch(state: Partial<WebSearchCardState> = {}) {
|
||||
const store = createSnapshotStore<WebSearchCardState>({
|
||||
available: true,
|
||||
writable: true,
|
||||
baseURL: { value: '', overridden: false },
|
||||
maxUses: { value: 5, overridden: false },
|
||||
apiKeyRef: 'DEEPSEEK_API_KEY',
|
||||
apiKeyConfigured: false,
|
||||
...state,
|
||||
})
|
||||
const actions = {
|
||||
setBaseUrl: vi.fn(),
|
||||
resetBaseUrl: vi.fn(),
|
||||
setMaxUses: vi.fn(),
|
||||
resetMaxUses: vi.fn(),
|
||||
setApiKey: vi.fn(),
|
||||
}
|
||||
const props = {
|
||||
...actions,
|
||||
t,
|
||||
useWebSearchCard: bindSnapshotSelector(store),
|
||||
} as unknown as WebSearchCardProps
|
||||
render(<WebSearchCard {...props} />)
|
||||
return actions
|
||||
}
|
||||
|
||||
it('reports whether a key is configured without ever showing one', () => {
|
||||
renderWebSearch({ apiKeyConfigured: true })
|
||||
fireEvent.click(screen.getByText(en.webSearchTitle))
|
||||
|
||||
expect(screen.getByText(en.webSearchApiKeySet)).toBeTruthy()
|
||||
expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('type', 'password')
|
||||
})
|
||||
|
||||
it('keeps the key control usable while the settings document is read-only', () => {
|
||||
const actions = renderWebSearch({ writable: false })
|
||||
fireEvent.click(screen.getByText(en.webSearchTitle))
|
||||
|
||||
const key = screen.getByLabelText(en.webSearchApiKey)
|
||||
expect(key).toHaveProperty('disabled', false)
|
||||
expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', true)
|
||||
|
||||
fireEvent.change(key, { target: { value: 'ds-secret' } })
|
||||
fireEvent.blur(key)
|
||||
|
||||
expect(actions.setApiKey).toHaveBeenCalledWith('ds-secret')
|
||||
})
|
||||
|
||||
it('commits the endpoint and the search budget', () => {
|
||||
const actions = renderWebSearch()
|
||||
fireEvent.click(screen.getByText(en.webSearchTitle))
|
||||
|
||||
const endpoint = screen.getByLabelText(en.webSearchBaseUrl)
|
||||
fireEvent.change(endpoint, { target: { value: 'https://search.test/v1' } })
|
||||
fireEvent.blur(endpoint)
|
||||
const budget = screen.getByLabelText(en.webSearchMaxUses)
|
||||
fireEvent.change(budget, { target: { value: '3' } })
|
||||
fireEvent.blur(budget)
|
||||
|
||||
expect(actions.setBaseUrl).toHaveBeenCalledWith('https://search.test/v1')
|
||||
expect(actions.setMaxUses).toHaveBeenCalledWith(3)
|
||||
})
|
||||
})
|
||||
193
packages/client/ui-plugin-config/tests/stores.spec.ts
Normal file
193
packages/client/ui-plugin-config/tests/stores.spec.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Card controllers: how a scope snapshot becomes card state, and which wire
|
||||
* call each action reaches.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-store.ts'
|
||||
import { BashCardController, type BashSettings } from '../src/client/bash-store.ts'
|
||||
import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-store.ts'
|
||||
|
||||
function credentialsApi(configured: boolean) {
|
||||
const describe = vi.fn(() => Promise.resolve({
|
||||
rpcId: 'c-1' as never,
|
||||
result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured, writable: true } } } },
|
||||
}))
|
||||
const set = vi.fn(() => Promise.resolve({ rpcId: 'c-2' as never, result: { ok: true as const, value: {} } }))
|
||||
return { api: { credentials: { describe, set } } as never, describe, set }
|
||||
}
|
||||
|
||||
describe('BashCardController', () => {
|
||||
it('publishes the effective value and marks only user-layer fields overridden', () => {
|
||||
const host = stubSettingsScope<BashSettings>()
|
||||
const controller = new BashCardController(host.scope)
|
||||
|
||||
host.publish({
|
||||
status: 'ready',
|
||||
writable: true,
|
||||
revision: 3,
|
||||
value: { timeoutMs: 5_000, maxOutputBytes: 64_000 },
|
||||
base: { timeoutMs: 60_000, maxOutputBytes: 64_000 },
|
||||
user: { timeoutMs: 5_000 },
|
||||
})
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
available: true,
|
||||
writable: true,
|
||||
timeoutMs: { value: 5_000, overridden: true },
|
||||
maxOutputBytes: { value: 64_000, overridden: false },
|
||||
})
|
||||
})
|
||||
|
||||
it('treats an override equal to the composition default as an override', () => {
|
||||
const host = stubSettingsScope<BashSettings>()
|
||||
const controller = new BashCardController(host.scope)
|
||||
|
||||
host.publish({
|
||||
status: 'ready',
|
||||
writable: true,
|
||||
value: { timeoutMs: 60_000 },
|
||||
base: { timeoutMs: 60_000 },
|
||||
user: { timeoutMs: 60_000 },
|
||||
})
|
||||
|
||||
expect(controller.store.getSnapshot().timeoutMs).toEqual({ value: 60_000, overridden: true })
|
||||
})
|
||||
|
||||
it('routes each action to its field write', async () => {
|
||||
const host = stubSettingsScope<BashSettings>()
|
||||
const controller = new BashCardController(host.scope)
|
||||
host.publish({ status: 'ready', writable: true, value: { timeoutMs: 5_000 } })
|
||||
const actions = controller.inject()
|
||||
|
||||
actions.setTimeoutMs(9_000)
|
||||
actions.resetTimeoutMs()
|
||||
actions.setMaxOutputBytes(1_024)
|
||||
actions.resetMaxOutputBytes()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000], ['maxOutputBytes', 1_024]])
|
||||
expect(host.unset.mock.calls).toEqual([['timeoutMs'], ['maxOutputBytes']])
|
||||
})
|
||||
|
||||
it('stays unavailable while the namespace is not served', () => {
|
||||
const host = stubSettingsScope<BashSettings>()
|
||||
const controller = new BashCardController(host.scope)
|
||||
|
||||
host.publish({ status: 'unavailable' })
|
||||
|
||||
expect(controller.store.getSnapshot().available).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentLoopCardController', () => {
|
||||
it('publishes the cap and routes its two actions', async () => {
|
||||
const host = stubSettingsScope<AgentLoopSettings>()
|
||||
const controller = new AgentLoopCardController(host.scope)
|
||||
host.publish({
|
||||
status: 'ready',
|
||||
writable: true,
|
||||
value: { maxParallelToolCalls: 2 },
|
||||
base: { maxParallelToolCalls: 10 },
|
||||
user: { maxParallelToolCalls: 2 },
|
||||
})
|
||||
expect(controller.store.getSnapshot().maxParallelToolCalls).toEqual({ value: 2, overridden: true })
|
||||
|
||||
const actions = controller.inject()
|
||||
actions.setMaxParallelToolCalls(4)
|
||||
actions.resetMaxParallelToolCalls()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(host.set).toHaveBeenCalledWith('maxParallelToolCalls', 4)
|
||||
expect(host.unset).toHaveBeenCalledWith('maxParallelToolCalls')
|
||||
})
|
||||
|
||||
it('reports a read-only document so the card can disable its controls', () => {
|
||||
const host = stubSettingsScope<AgentLoopSettings>()
|
||||
const controller = new AgentLoopCardController(host.scope)
|
||||
|
||||
host.publish({ status: 'ready', writable: false, value: { maxParallelToolCalls: 10 } })
|
||||
|
||||
expect(controller.store.getSnapshot().writable).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebSearchCardController', () => {
|
||||
it('reads the credential state for the reference the section names', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const credentials = credentialsApi(true)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() })
|
||||
|
||||
host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' } })
|
||||
await vi.waitFor(() => {
|
||||
expect(controller.store.getSnapshot().apiKeyConfigured).toBe(true)
|
||||
})
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
baseURL: { value: 'https://search.test/v1', overridden: false },
|
||||
apiKeyRef: 'DEEPSEEK_API_KEY',
|
||||
})
|
||||
})
|
||||
|
||||
it('writes the key through the credentials domain, never the settings section', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const credentials = credentialsApi(false)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
host.publish({ status: 'ready', writable: true, value: {} })
|
||||
|
||||
controller.inject().setApiKey('ds-secret')
|
||||
await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() })
|
||||
|
||||
expect(credentials.set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'ds-secret' })
|
||||
expect(host.set).not.toHaveBeenCalledWith('apiKey', expect.anything())
|
||||
})
|
||||
|
||||
it('addresses the reference the section declares rather than the default', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const credentials = credentialsApi(false)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
host.publish({ status: 'ready', writable: true, value: { apiKeyEnv: 'SEARCH_KEY' } })
|
||||
|
||||
controller.inject().setApiKey('ds-secret')
|
||||
await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() })
|
||||
|
||||
expect(credentials.set).toHaveBeenCalledWith({ ref: 'SEARCH_KEY', value: 'ds-secret' })
|
||||
})
|
||||
|
||||
it('keeps the card usable when the credential read fails', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const describe = vi.fn(() => Promise.reject(new Error('offline')))
|
||||
const controller = new WebSearchCardController(
|
||||
host.scope,
|
||||
{ credentials: { describe, set: vi.fn() } } as never,
|
||||
)
|
||||
await vi.waitFor(() => { expect(describe).toHaveBeenCalled() })
|
||||
|
||||
host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' } })
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
available: true,
|
||||
apiKeyConfigured: false,
|
||||
baseURL: { value: 'https://search.test/v1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('routes the endpoint and budget actions to their field writes', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const credentials = credentialsApi(true)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
host.publish({ status: 'ready', writable: true, value: {} })
|
||||
const actions = controller.inject()
|
||||
|
||||
actions.setBaseUrl('https://other.test')
|
||||
actions.resetBaseUrl()
|
||||
actions.setMaxUses(3)
|
||||
actions.resetMaxUses()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test'], ['maxUses', 3]])
|
||||
expect(host.unset.mock.calls).toEqual([['baseURL'], ['maxUses']])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user