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.
345 lines
13 KiB
TypeScript
345 lines
13 KiB
TypeScript
// @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 save footer that decides when staged edits are written.
|
|
*/
|
|
|
|
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 { CardFieldState, CardShell } from '../src/client/card-store.ts'
|
|
import type { PluginConfigSectionState } from '../src/client/section-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]
|
|
|
|
/** A settled form: nothing staged, everything served. */
|
|
const settled: CardShell = {
|
|
available: true,
|
|
writable: true,
|
|
dirty: false,
|
|
invalid: false,
|
|
saving: false,
|
|
failed: false,
|
|
}
|
|
|
|
/** One control's state, defaulting to an inherited value. */
|
|
function field(text: string, rest: Partial<CardFieldState> = {}): CardFieldState {
|
|
return { text, overridden: false, invalid: false, ...rest }
|
|
}
|
|
|
|
function cardActions() {
|
|
return { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() }
|
|
}
|
|
|
|
/**
|
|
* Render the section over the namespaces it was told to dispatch, with `cards`
|
|
* standing in for the slot ledger: a key it names renders that text, and one
|
|
* it does not renders nothing, exactly as an unclaimed key does.
|
|
*/
|
|
function renderSection(namespaces: string[], cards: Record<string, string> = {}, loaded = true) {
|
|
const store = createSnapshotStore<PluginConfigSectionState>({ loaded, namespaces })
|
|
const props = {
|
|
t,
|
|
usePluginConfigSection: bindSnapshotSelector(store),
|
|
renderSlot: (_name: string, _owner: object, opts?: { entryKey?: string }) => {
|
|
const card = opts?.entryKey === undefined ? undefined : cards[opts.entryKey]
|
|
return card === undefined ? null : <li>{card}</li>
|
|
},
|
|
} as unknown as PluginConfigSectionProps
|
|
render(<PluginConfigSection {...props} />)
|
|
}
|
|
|
|
function renderBash(state: Partial<BashCardState> = {}) {
|
|
const store = createSnapshotStore<BashCardState>({
|
|
...settled,
|
|
timeoutMs: field('60000'),
|
|
maxOutputBytes: field('64000'),
|
|
...state,
|
|
})
|
|
const actions = cardActions()
|
|
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([], { bash: 'shell' })
|
|
|
|
expect(screen.getByText(en.empty)).toBeTruthy()
|
|
expect(screen.queryByText('shell')).toBeNull()
|
|
})
|
|
|
|
it('withholds the empty line until the Host has answered once', () => {
|
|
// An unanswered read is not the statement that this deployment configures
|
|
// no plugin; saying it anyway would flash a wrong answer on every open.
|
|
renderSection([], { bash: 'shell' }, false)
|
|
|
|
expect(screen.queryByText(en.empty)).toBeNull()
|
|
expect(screen.getByRole('heading', { name: en.title })).toBeTruthy()
|
|
})
|
|
|
|
it('dispatches one card per namespace, keyed by it', () => {
|
|
renderSection(['bash', 'agent-loop'], { bash: 'shell', 'agent-loop': 'loop' })
|
|
|
|
expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['shell', 'loop'])
|
|
expect(screen.queryByText(en.empty)).toBeNull()
|
|
})
|
|
|
|
it('leads with its own heading and intro', () => {
|
|
renderSection(['bash'], { bash: 'shell' })
|
|
|
|
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('stages an edit instead of writing it', () => {
|
|
const actions = renderBash()
|
|
fireEvent.click(screen.getByText(en.bashTitle))
|
|
|
|
fireEvent.change(screen.getByLabelText(en.bashTimeoutMs), { target: { value: '9000' } })
|
|
|
|
expect(actions.edit).toHaveBeenCalledWith('timeoutMs', '9000')
|
|
expect(actions.save).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('offers the reset for an overridden field only', () => {
|
|
const actions = renderBash({ timeoutMs: field('9000', { 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.resetField).toHaveBeenCalledWith('timeoutMs')
|
|
})
|
|
|
|
it('addresses each of its two fields separately', () => {
|
|
const actions = renderBash({ maxOutputBytes: field('64000', { overridden: true }) })
|
|
fireEvent.click(screen.getByText(en.bashTitle))
|
|
|
|
fireEvent.change(screen.getByLabelText(en.bashMaxOutputBytes), { target: { value: '1024' } })
|
|
fireEvent.click(screen.getByRole('button', { name: en.reset }))
|
|
|
|
expect(actions.edit).toHaveBeenCalledWith('maxOutputBytes', '1024')
|
|
expect(actions.resetField).toHaveBeenCalledWith('maxOutputBytes')
|
|
})
|
|
|
|
it('keeps save and discard inert until something is staged', () => {
|
|
renderBash()
|
|
fireEvent.click(screen.getByText(en.bashTitle))
|
|
|
|
expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true)
|
|
expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', true)
|
|
expect(screen.queryByText(en.unsaved)).toBeNull()
|
|
})
|
|
|
|
it('writes the staged edits when saved, and drops them when discarded', () => {
|
|
const actions = renderBash({ dirty: true, timeoutMs: field('9000', { overridden: true }) })
|
|
fireEvent.click(screen.getByText(en.bashTitle))
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: en.save }))
|
|
fireEvent.click(screen.getByRole('button', { name: en.discard }))
|
|
|
|
expect(actions.save).toHaveBeenCalledOnce()
|
|
expect(actions.discard).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('marks a card holding unsaved edits, collapsed or not', () => {
|
|
renderBash({ dirty: true })
|
|
|
|
expect(screen.getByText(en.unsaved)).toBeTruthy()
|
|
})
|
|
|
|
it('blocks the save while a draft is invalid, and says why', () => {
|
|
renderBash({ dirty: true, invalid: true, timeoutMs: field('soon', { invalid: true }) })
|
|
fireEvent.click(screen.getByText(en.bashTitle))
|
|
|
|
expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true)
|
|
expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', false)
|
|
expect(screen.getByText(en.invalidNumber)).toBeTruthy()
|
|
})
|
|
|
|
it('reports a save in flight and refuses another', () => {
|
|
renderBash({ dirty: true, saving: true })
|
|
fireEvent.click(screen.getByText(en.bashTitle))
|
|
|
|
expect(screen.getByRole('button', { name: en.saving })).toHaveProperty('disabled', true)
|
|
expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', true)
|
|
})
|
|
|
|
it('reports a save the deployment did not accept', () => {
|
|
renderBash({ dirty: true, failed: true })
|
|
fireEvent.click(screen.getByText(en.bashTitle))
|
|
|
|
expect(screen.getByText(en.saveFailed)).toBeTruthy()
|
|
})
|
|
|
|
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)
|
|
})
|
|
|
|
it('collapses again on a second click', () => {
|
|
renderBash()
|
|
fireEvent.click(screen.getByText(en.bashTitle))
|
|
expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy()
|
|
|
|
fireEvent.click(screen.getByText(en.bashTitle))
|
|
|
|
expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('AgentLoopCard', () => {
|
|
it('stages and saves the only field it owns', () => {
|
|
const store = createSnapshotStore<AgentLoopCardState>({
|
|
...settled,
|
|
dirty: true,
|
|
maxParallelToolCalls: field('10'),
|
|
})
|
|
const actions = cardActions()
|
|
const props = {
|
|
...actions,
|
|
t,
|
|
useAgentLoopCard: bindSnapshotSelector(store),
|
|
} as unknown as AgentLoopCardProps
|
|
render(<AgentLoopCard {...props} />)
|
|
|
|
fireEvent.click(screen.getByText(en.agentLoopTitle))
|
|
fireEvent.change(screen.getByLabelText(en.agentLoopMaxParallel), { target: { value: '2' } })
|
|
fireEvent.click(screen.getByRole('button', { name: en.save }))
|
|
|
|
expect(actions.edit).toHaveBeenCalledWith('maxParallelToolCalls', '2')
|
|
expect(actions.save).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('stages a reset for the field it owns', () => {
|
|
const store = createSnapshotStore<AgentLoopCardState>({
|
|
...settled,
|
|
maxParallelToolCalls: field('2', { overridden: true }),
|
|
})
|
|
const actions = cardActions()
|
|
const props = {
|
|
...actions,
|
|
t,
|
|
useAgentLoopCard: bindSnapshotSelector(store),
|
|
} as unknown as AgentLoopCardProps
|
|
render(<AgentLoopCard {...props} />)
|
|
|
|
fireEvent.click(screen.getByText(en.agentLoopTitle))
|
|
fireEvent.click(screen.getByRole('button', { name: en.reset }))
|
|
|
|
expect(actions.resetField).toHaveBeenCalledWith('maxParallelToolCalls')
|
|
})
|
|
})
|
|
|
|
describe('WebSearchCard', () => {
|
|
function renderWebSearch(state: Partial<WebSearchCardState> = {}) {
|
|
const store = createSnapshotStore<WebSearchCardState>({
|
|
...settled,
|
|
baseURL: field(''),
|
|
maxUses: field('5'),
|
|
apiKey: field(''),
|
|
apiKeyConfigured: false,
|
|
apiKeyWritable: true,
|
|
...state,
|
|
})
|
|
const actions = cardActions()
|
|
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' } })
|
|
|
|
expect(actions.edit).toHaveBeenCalledWith('apiKey', 'ds-secret')
|
|
})
|
|
|
|
it('disables the key control when the reference itself is not writable', () => {
|
|
// A key coming from the process environment: the settings document is
|
|
// writable, the credential is not.
|
|
renderWebSearch({ apiKeyConfigured: true, apiKeyWritable: false })
|
|
fireEvent.click(screen.getByText(en.webSearchTitle))
|
|
|
|
expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('disabled', true)
|
|
expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', false)
|
|
})
|
|
|
|
it('stages the endpoint, the search budget, and their resets', () => {
|
|
const actions = renderWebSearch({
|
|
baseURL: field('https://search.test/v1', { overridden: true }),
|
|
maxUses: field('3', { overridden: true }),
|
|
})
|
|
fireEvent.click(screen.getByText(en.webSearchTitle))
|
|
|
|
fireEvent.change(screen.getByLabelText(en.webSearchBaseUrl), { target: { value: 'https://other.test' } })
|
|
fireEvent.change(screen.getByLabelText(en.webSearchMaxUses), { target: { value: '4' } })
|
|
const resets = screen.getAllByRole('button', { name: en.reset })
|
|
expect(resets).toHaveLength(2)
|
|
for (const reset of resets) fireEvent.click(reset)
|
|
|
|
expect(actions.edit.mock.calls).toEqual([
|
|
['baseURL', 'https://other.test'],
|
|
['maxUses', '4'],
|
|
])
|
|
expect(actions.resetField.mock.calls).toEqual([['baseURL'], ['maxUses']])
|
|
})
|
|
})
|