Three findings from review survived against the staged-save head: The search provider read its options thunk per property, so a settings write landing inside credential resolution sent the key resolved from the old section to the endpoint named by the new one. Each operation now snapshots once at its entry and threads that snapshot into credential resolution; a regression test drives a commit into the middle of a search and pins that the endpoint, model, and key all come from the section the search started on. The /client entry exported components, controllers, and namespace constants with no consumer, which the client export discipline allows only with sign-off. Only types remain. The duplicate per-card Injected/Face interface pairs are one declaration each now, so a member added to one side cannot silently miss the other. The credential state carries the reference it describes and its writability: a reference change no longer projects the old answer onto the new name, an out-of-order response for a stale reference is dropped, and a key that a deployment sources from the process environment disables the control instead of inviting a write the Host must refuse. Also corrected three prose claims against the code they describe: the card's fields do not differ by platform (the served schema does), the section's empty line counts registered rather than visible cards and is read once, and the search README overstated what a configuration surface learns about a key.
326 lines
12 KiB
TypeScript
326 lines
12 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 { 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() }
|
|
}
|
|
|
|
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>({
|
|
...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(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('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']])
|
|
})
|
|
})
|