refactor(client-ui-plugin-config): stage card edits behind an explicit save
Controls committed on blur, which turned leaving a field into a durable, revision-fenced document write the user could neither preview nor undo, and silently discarded a draft the field did not accept. A card's form now owns the staged text every control renders, and Save is the only point where drafts become writes. Reset stages the composed default the same way; an invalid draft blocks the save with its reason instead of being dropped; Discard drops the drafts; a collapsed card marks that it holds some. The Host stays the only authority on whether a value was accepted, so the save reads the section back and keeps the drafts of a save that did not land.
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
// @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.
|
||||
* Field-control behavior: what a control renders for a staged draft, how an
|
||||
* overridden field offers its reset, and that a control never writes on its own.
|
||||
*/
|
||||
|
||||
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'
|
||||
import { SecretField, ValueField } from '../src/client/fields.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -16,328 +16,138 @@ const frame = {
|
||||
hint: 'How long one command may run.',
|
||||
overriddenLabel: 'Overridden',
|
||||
resetLabel: 'Reset to default',
|
||||
invalidLabel: 'Enter a number.',
|
||||
disabled: false,
|
||||
overridden: false,
|
||||
invalid: 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')
|
||||
describe('ValueField', () => {
|
||||
it('stages every keystroke without writing', () => {
|
||||
const onEdit = vi.fn()
|
||||
render(<ValueField {...frame} text="60000" onEdit={onEdit} onReset={vi.fn()} />)
|
||||
|
||||
fireEvent.change(input, { target: { value: '9000' } })
|
||||
fireEvent.blur(input)
|
||||
fireEvent.change(screen.getByLabelText('Command timeout'), { target: { value: '9000' } })
|
||||
|
||||
expect(onCommit).toHaveBeenCalledWith(9_000)
|
||||
expect(onEdit).toHaveBeenCalledWith('9000')
|
||||
})
|
||||
|
||||
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')
|
||||
it('renders the staged text it is given rather than a draft of its own', () => {
|
||||
const { rerender } = render(<ValueField {...frame} text="60000" onEdit={vi.fn()} onReset={vi.fn()} />)
|
||||
expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '60000')
|
||||
|
||||
fireEvent.change(input, { target: { value: '1234' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
fireEvent.blur(input)
|
||||
rerender(<ValueField {...frame} text="9000" onEdit={vi.fn()} onReset={vi.fn()} />)
|
||||
|
||||
expect(onCommit).toHaveBeenCalledWith(1_234)
|
||||
expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '9000')
|
||||
})
|
||||
|
||||
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', () => {
|
||||
it('offers the reset only while an override would stand', () => {
|
||||
const onReset = vi.fn()
|
||||
const { rerender } = render(
|
||||
<NumberField {...frame} overridden={false} onReset={onReset} value={9_000} onCommit={vi.fn()} />,
|
||||
)
|
||||
const { rerender } = render(<ValueField {...frame} text="9000" onEdit={vi.fn()} onReset={onReset} />)
|
||||
expect(screen.queryByRole('button', { name: 'Reset to default' })).toBeNull()
|
||||
|
||||
rerender(
|
||||
<NumberField {...frame} overridden onReset={onReset} value={9_000} onCommit={vi.fn()} />,
|
||||
)
|
||||
rerender(<ValueField {...frame} overridden text="9000" onEdit={vi.fn()} onReset={onReset} />)
|
||||
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')
|
||||
it('replaces the hint with the reason an invalid draft cannot be saved', () => {
|
||||
render(<ValueField {...frame} invalid text="soon" onEdit={vi.fn()} onReset={vi.fn()} />)
|
||||
|
||||
rerender(
|
||||
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={60_000} onCommit={vi.fn()} />,
|
||||
)
|
||||
|
||||
expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '60000')
|
||||
expect(screen.getByText('Enter a number.')).toBeTruthy()
|
||||
expect(screen.queryByText('How long one command may run.')).toBeNull()
|
||||
expect(screen.getByLabelText('Command timeout').getAttribute('aria-invalid')).toBe('true')
|
||||
})
|
||||
|
||||
it('ignores a keystroke that is not Enter', () => {
|
||||
const onCommit = vi.fn()
|
||||
it('hints a numeric keypad and renders a placeholder when asked', () => {
|
||||
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('renders an absent value as empty rather than as a number nobody chose', () => {
|
||||
const onCommit = vi.fn()
|
||||
render(
|
||||
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={undefined} onCommit={onCommit} />,
|
||||
)
|
||||
const input = screen.getByLabelText('Command timeout')
|
||||
expect(input).toHaveProperty('value', '')
|
||||
|
||||
// A draft typed and then cleared restores the same emptiness, not a zero.
|
||||
fireEvent.change(input, { target: { value: 'abc' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(input).toHaveProperty('value', '')
|
||||
expect(onCommit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('suppresses every interaction while disabled', () => {
|
||||
const onCommit = vi.fn()
|
||||
const onReset = vi.fn()
|
||||
render(
|
||||
<NumberField
|
||||
<ValueField
|
||||
{...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"
|
||||
numeric
|
||||
placeholder="https://api.deepseek.com"
|
||||
overridden={false}
|
||||
text=""
|
||||
onEdit={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
value=""
|
||||
onCommit={onCommit}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByLabelText('Endpoint')
|
||||
const input = screen.getByLabelText('Command timeout')
|
||||
|
||||
expect(input.getAttribute('inputmode')).toBe('numeric')
|
||||
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')
|
||||
it('disables the control and its reset while the document is read-only', () => {
|
||||
render(<ValueField {...frame} disabled overridden text="9000" onEdit={vi.fn()} onReset={vi.fn()} />)
|
||||
|
||||
fireEvent.keyDown(input, { key: 'a' })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onCommit).not.toHaveBeenCalled()
|
||||
expect(screen.getByLabelText('Command timeout')).toHaveProperty('disabled', true)
|
||||
expect(screen.getByRole('button', { name: 'Reset to default' })).toHaveProperty('disabled', true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SecretField', () => {
|
||||
it('commits a non-empty draft and clears the control after writing', () => {
|
||||
const onCommit = vi.fn()
|
||||
const secret = {
|
||||
id: 'key',
|
||||
label: 'API key',
|
||||
hint: 'Stored outside the settings file.',
|
||||
disabled: false,
|
||||
}
|
||||
|
||||
it('stages the draft and never renders it', () => {
|
||||
const onEdit = vi.fn()
|
||||
render(
|
||||
<SecretField
|
||||
{...frame}
|
||||
label="API key"
|
||||
{...secret}
|
||||
text=""
|
||||
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}
|
||||
onEdit={onEdit}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByLabelText('API key')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'ds-secret' } })
|
||||
fireEvent.keyDown(input, { key: 'Tab' })
|
||||
|
||||
expect(onCommit).not.toHaveBeenCalled()
|
||||
expect(onEdit).toHaveBeenCalledWith('ds-secret')
|
||||
expect(input).toHaveProperty('type', 'password')
|
||||
})
|
||||
|
||||
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()
|
||||
it('reports the configured state the Host holds', () => {
|
||||
const { rerender } = render(
|
||||
<SecretField
|
||||
{...frame}
|
||||
label="API key"
|
||||
{...secret}
|
||||
text=""
|
||||
configured={false}
|
||||
stateLabel="No key is configured."
|
||||
onCommit={onCommit}
|
||||
onEdit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
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')
|
||||
expect(screen.getByText('No key is configured.')).toBeTruthy()
|
||||
|
||||
rerender(
|
||||
<SecretField
|
||||
{...frame}
|
||||
disabled
|
||||
label="API key"
|
||||
{...secret}
|
||||
text="ds-secret"
|
||||
configured
|
||||
stateLabel="A key is configured."
|
||||
onCommit={onCommit}
|
||||
onEdit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('A key is configured.')).toBeTruthy()
|
||||
expect(screen.getByLabelText('API key')).toHaveProperty('value', 'ds-secret')
|
||||
})
|
||||
|
||||
it('disables the control when it is told to', () => {
|
||||
render(
|
||||
<SecretField
|
||||
{...secret}
|
||||
disabled
|
||||
text=""
|
||||
configured
|
||||
stateLabel="A key is configured."
|
||||
onEdit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* 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.
|
||||
* unavailable, and the save footer that decides when staged edits are written.
|
||||
*/
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
@@ -19,6 +19,7 @@ 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'
|
||||
|
||||
@@ -26,6 +27,25 @@ 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,
|
||||
@@ -37,23 +57,13 @@ function renderSection(cardCount: number, cards = 'cards') {
|
||||
|
||||
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 },
|
||||
...settled,
|
||||
timeoutMs: field('60000'),
|
||||
maxOutputBytes: field('64000'),
|
||||
...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
|
||||
const actions = cardActions()
|
||||
const props = { ...actions, t, useBashCard: bindSnapshotSelector(store) } as unknown as BashCardProps
|
||||
render(<BashCard {...props} />)
|
||||
return actions
|
||||
}
|
||||
@@ -101,26 +111,86 @@ describe('BashCard', () => {
|
||||
expect(screen.getByLabelText(en.bashMaxOutputBytes)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('commits an edited field through its action', () => {
|
||||
it('stages an edit instead of writing it', () => {
|
||||
const actions = renderBash()
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
const input = screen.getByLabelText(en.bashTimeoutMs)
|
||||
fireEvent.change(input, { target: { value: '9000' } })
|
||||
fireEvent.blur(input)
|
||||
fireEvent.change(screen.getByLabelText(en.bashTimeoutMs), { target: { value: '9000' } })
|
||||
|
||||
expect(actions.setTimeoutMs).toHaveBeenCalledWith(9_000)
|
||||
expect(actions.edit).toHaveBeenCalledWith('timeoutMs', '9000')
|
||||
expect(actions.save).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('offers the reset for an overridden field only', () => {
|
||||
const actions = renderBash({ timeoutMs: { value: 9_000, overridden: true } })
|
||||
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.resetTimeoutMs).toHaveBeenCalledOnce()
|
||||
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', () => {
|
||||
@@ -130,56 +200,73 @@ describe('BashCard', () => {
|
||||
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('edits the only field it owns', () => {
|
||||
it('stages and saves the only field it owns', () => {
|
||||
const store = createSnapshotStore<AgentLoopCardState>({
|
||||
available: true,
|
||||
writable: true,
|
||||
maxParallelToolCalls: { value: 10, overridden: false },
|
||||
...settled,
|
||||
dirty: true,
|
||||
maxParallelToolCalls: field('10'),
|
||||
})
|
||||
const setMaxParallelToolCalls = vi.fn()
|
||||
const actions = cardActions()
|
||||
const props = {
|
||||
...actions,
|
||||
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)
|
||||
fireEvent.change(screen.getByLabelText(en.agentLoopMaxParallel), { target: { value: '2' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.save }))
|
||||
|
||||
expect(setMaxParallelToolCalls).toHaveBeenCalledWith(2)
|
||||
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>({
|
||||
available: true,
|
||||
writable: true,
|
||||
baseURL: { value: '', overridden: false },
|
||||
maxUses: { value: 5, overridden: false },
|
||||
apiKeyRef: 'DEEPSEEK_API_KEY',
|
||||
...settled,
|
||||
baseURL: field(''),
|
||||
maxUses: field('5'),
|
||||
apiKey: field(''),
|
||||
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
|
||||
const actions = cardActions()
|
||||
const props = { ...actions, t, useWebSearchCard: bindSnapshotSelector(store) } as unknown as WebSearchCardProps
|
||||
render(<WebSearchCard {...props} />)
|
||||
return actions
|
||||
}
|
||||
@@ -201,23 +288,27 @@ describe('WebSearchCard', () => {
|
||||
expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', true)
|
||||
|
||||
fireEvent.change(key, { target: { value: 'ds-secret' } })
|
||||
fireEvent.blur(key)
|
||||
|
||||
expect(actions.setApiKey).toHaveBeenCalledWith('ds-secret')
|
||||
expect(actions.edit).toHaveBeenCalledWith('apiKey', 'ds-secret')
|
||||
})
|
||||
|
||||
it('commits the endpoint and the search budget', () => {
|
||||
const actions = renderWebSearch()
|
||||
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))
|
||||
|
||||
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)
|
||||
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.setBaseUrl).toHaveBeenCalledWith('https://search.test/v1')
|
||||
expect(actions.setMaxUses).toHaveBeenCalledWith(3)
|
||||
expect(actions.edit.mock.calls).toEqual([
|
||||
['baseURL', 'https://other.test'],
|
||||
['maxUses', '4'],
|
||||
])
|
||||
expect(actions.resetField.mock.calls).toEqual([['baseURL'], ['maxUses']])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
/**
|
||||
* Card controllers: how a scope snapshot becomes card state, and which wire
|
||||
* call each action reaches.
|
||||
* The staged card form: what a draft shows before it is written, which wire
|
||||
* call a save reaches, and what happens to drafts the Host did not accept.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { CardForm, numberField, textField } from '../src/client/card-store.ts'
|
||||
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'
|
||||
|
||||
/** Make the stub behave like a Host that accepts every write. */
|
||||
function acceptWrites<T>(host: StubSettingsScope<T>): void {
|
||||
const section = (): Record<string, unknown> => ({ ...host.scope.getSnapshot().value as object })
|
||||
const layer = (): Record<string, unknown> => ({ ...host.scope.getSnapshot().user as object })
|
||||
host.set.mockImplementation((field: string, value: unknown) => {
|
||||
host.publish({ value: { ...section(), [field]: value } as T, user: { ...layer(), [field]: value } })
|
||||
})
|
||||
host.unset.mockImplementation((field: string) => {
|
||||
const user = Object.fromEntries(Object.entries(layer()).filter(([key]) => key !== field))
|
||||
const base = host.scope.getSnapshot().base as Record<string, unknown> | undefined
|
||||
host.publish({ value: { ...section(), [field]: base?.[field] } as T, user })
|
||||
})
|
||||
}
|
||||
|
||||
function credentialsApi(configured: boolean) {
|
||||
const describe = vi.fn(() => Promise.resolve({
|
||||
rpcId: 'c-1' as never,
|
||||
@@ -18,89 +33,340 @@ function credentialsApi(configured: boolean) {
|
||||
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)
|
||||
|
||||
describe('CardForm', () => {
|
||||
function form() {
|
||||
const host = stubSettingsScope<Record<string, unknown>>()
|
||||
const subject = new CardForm(host.scope, [numberField('timeoutMs'), textField('baseURL')])
|
||||
host.publish({
|
||||
status: 'ready',
|
||||
writable: true,
|
||||
value: { timeoutMs: 60_000, baseURL: 'https://search.test/v1' },
|
||||
base: { timeoutMs: 60_000, baseURL: 'https://search.test/v1' },
|
||||
user: {},
|
||||
})
|
||||
return { host, subject }
|
||||
}
|
||||
|
||||
it('shows the effective value and stays clean until something is staged', () => {
|
||||
const { subject } = form()
|
||||
|
||||
expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false })
|
||||
expect(subject.shell()).toMatchObject({ available: true, writable: true, dirty: false, invalid: false })
|
||||
})
|
||||
|
||||
it('marks a field the user layer carries as overridden', () => {
|
||||
const { host, subject } = form()
|
||||
|
||||
host.publish({ value: { timeoutMs: 60_000 }, user: { timeoutMs: 60_000 } })
|
||||
|
||||
// An override equal to the composition default is still an override.
|
||||
expect(subject.field('timeoutMs').overridden).toBe(true)
|
||||
})
|
||||
|
||||
it('writes nothing until the form is saved', async () => {
|
||||
const { host, subject } = form()
|
||||
acceptWrites(host)
|
||||
|
||||
subject.actions().edit('timeoutMs', '9000')
|
||||
|
||||
expect(subject.field('timeoutMs')).toEqual({ text: '9000', overridden: true, invalid: false })
|
||||
expect(subject.shell().dirty).toBe(true)
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
|
||||
await subject.save()
|
||||
|
||||
expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000]])
|
||||
expect(subject.shell()).toMatchObject({ dirty: false, failed: false, saving: false })
|
||||
})
|
||||
|
||||
it('drops a draft that settles back on the value already shown', async () => {
|
||||
const { host, subject } = form()
|
||||
|
||||
subject.actions().edit('timeoutMs', '9000')
|
||||
subject.actions().edit('timeoutMs', '60000')
|
||||
|
||||
expect(subject.shell().dirty).toBe(false)
|
||||
await subject.save()
|
||||
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to save while a draft is not a value the field accepts', async () => {
|
||||
const { host, subject } = form()
|
||||
|
||||
subject.actions().edit('timeoutMs', 'soon')
|
||||
|
||||
expect(subject.field('timeoutMs')).toEqual({ text: 'soon', overridden: false, invalid: true })
|
||||
expect(subject.shell()).toMatchObject({ dirty: true, invalid: true })
|
||||
|
||||
await subject.save()
|
||||
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
expect(subject.field('timeoutMs').text).toBe('soon')
|
||||
})
|
||||
|
||||
it('stages a reset that clears the field only once saved', async () => {
|
||||
const { host, subject } = form()
|
||||
acceptWrites(host)
|
||||
host.publish({ value: { timeoutMs: 9_000 }, user: { timeoutMs: 9_000 } })
|
||||
|
||||
subject.actions().resetField('timeoutMs')
|
||||
|
||||
// The badge previews the save: the field will no longer be overridden.
|
||||
expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false })
|
||||
expect(host.unset).not.toHaveBeenCalled()
|
||||
|
||||
await subject.save()
|
||||
|
||||
expect(host.unset.mock.calls).toEqual([['timeoutMs']])
|
||||
expect(subject.shell()).toMatchObject({ dirty: false, failed: false })
|
||||
})
|
||||
|
||||
it('treats resetting an inherited field as no change at all', async () => {
|
||||
const { host, subject } = form()
|
||||
|
||||
subject.actions().resetField('timeoutMs')
|
||||
|
||||
expect(subject.shell().dirty).toBe(false)
|
||||
await subject.save()
|
||||
|
||||
expect(host.unset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears a number field by emptying it', async () => {
|
||||
const { host, subject } = form()
|
||||
acceptWrites(host)
|
||||
host.publish({ user: { timeoutMs: 9_000 } })
|
||||
|
||||
subject.actions().edit('timeoutMs', '')
|
||||
|
||||
expect(subject.field('timeoutMs')).toEqual({ text: '', overridden: false, invalid: false })
|
||||
await subject.save()
|
||||
|
||||
expect(host.unset.mock.calls).toEqual([['timeoutMs']])
|
||||
})
|
||||
|
||||
it('clears a text field by emptying it', async () => {
|
||||
const { host, subject } = form()
|
||||
acceptWrites(host)
|
||||
host.publish({ user: { baseURL: 'https://search.test/v1' } })
|
||||
|
||||
subject.actions().edit('baseURL', ' ')
|
||||
await subject.save()
|
||||
|
||||
expect(host.unset.mock.calls).toEqual([['baseURL']])
|
||||
})
|
||||
|
||||
it('writes the trimmed text of a text field', async () => {
|
||||
const { host, subject } = form()
|
||||
acceptWrites(host)
|
||||
|
||||
subject.actions().edit('baseURL', ' https://other.test ')
|
||||
await subject.save()
|
||||
|
||||
expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test']])
|
||||
})
|
||||
|
||||
it('keeps the drafts a save did not land, and reports the failure', async () => {
|
||||
const { host, subject } = form()
|
||||
|
||||
subject.actions().edit('timeoutMs', '9000')
|
||||
await subject.save()
|
||||
|
||||
// The stub Host accepted the call without storing it, exactly as a
|
||||
// validator that refuses the value does.
|
||||
expect(host.set).toHaveBeenCalledWith('timeoutMs', 9_000)
|
||||
expect(subject.shell()).toMatchObject({ dirty: true, failed: true, saving: false })
|
||||
expect(subject.field('timeoutMs').text).toBe('9000')
|
||||
})
|
||||
|
||||
it('reports a reset the Host did not apply as a failure', async () => {
|
||||
const { host, subject } = form()
|
||||
host.publish({ user: { timeoutMs: 9_000 } })
|
||||
|
||||
subject.actions().resetField('timeoutMs')
|
||||
await subject.save()
|
||||
|
||||
expect(host.unset).toHaveBeenCalledWith('timeoutMs')
|
||||
expect(subject.shell().failed).toBe(true)
|
||||
})
|
||||
|
||||
it('clears the failure as soon as the user edits again', async () => {
|
||||
const { subject } = form()
|
||||
|
||||
subject.actions().edit('timeoutMs', '9000')
|
||||
await subject.save()
|
||||
expect(subject.shell().failed).toBe(true)
|
||||
|
||||
subject.actions().edit('timeoutMs', '9001')
|
||||
|
||||
expect(subject.shell().failed).toBe(false)
|
||||
})
|
||||
|
||||
it('discards every staged edit', async () => {
|
||||
const { host, subject } = form()
|
||||
|
||||
subject.actions().edit('timeoutMs', '9000')
|
||||
subject.actions().discard()
|
||||
|
||||
expect(subject.field('timeoutMs').text).toBe('60000')
|
||||
expect(subject.shell()).toMatchObject({ dirty: false, failed: false })
|
||||
|
||||
// A discard with nothing staged publishes nothing.
|
||||
const before = subject.shell()
|
||||
subject.actions().discard()
|
||||
expect(subject.shell()).toEqual(before)
|
||||
|
||||
await subject.save()
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a second save while one is in flight', async () => {
|
||||
const { host, subject } = form()
|
||||
acceptWrites(host)
|
||||
|
||||
subject.actions().edit('timeoutMs', '9000')
|
||||
const first = subject.save()
|
||||
expect(subject.shell().saving).toBe(true)
|
||||
const second = subject.save()
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(host.set).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('publishes a projection whenever the scope or a draft changes', () => {
|
||||
const { host, subject } = form()
|
||||
const store = subject.bind(() => subject.field('timeoutMs').text)
|
||||
expect(store.getSnapshot()).toBe('60000')
|
||||
|
||||
host.publish({ value: { timeoutMs: 1_000 } })
|
||||
expect(store.getSnapshot()).toBe('1000')
|
||||
|
||||
subject.actions().edit('timeoutMs', '2000')
|
||||
expect(store.getSnapshot()).toBe('2000')
|
||||
})
|
||||
|
||||
it('refuses to address a field the card never declared', () => {
|
||||
const { subject } = form()
|
||||
|
||||
expect(() => subject.field('nope')).toThrow('plugin card has no field nope')
|
||||
})
|
||||
|
||||
it('renders an absent section value as an empty draft', () => {
|
||||
const host = stubSettingsScope<Record<string, unknown>>()
|
||||
const subject = new CardForm(host.scope, [numberField('timeoutMs'), textField('baseURL')])
|
||||
|
||||
host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: undefined })
|
||||
|
||||
expect(subject.field('timeoutMs').text).toBe('')
|
||||
expect(subject.field('baseURL').text).toBe('')
|
||||
expect(subject.shell().available).toBe(true)
|
||||
})
|
||||
|
||||
it('stays unavailable while the namespace is not served', () => {
|
||||
const host = stubSettingsScope<Record<string, unknown>>()
|
||||
const subject = new CardForm(host.scope, [numberField('timeoutMs')])
|
||||
|
||||
host.publish({ status: 'unavailable' })
|
||||
|
||||
expect(subject.shell()).toMatchObject({ available: false, writable: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('BashCardController', () => {
|
||||
it('projects both fields and saves them in one write pass', async () => {
|
||||
const host = stubSettingsScope<BashSettings>()
|
||||
acceptWrites(host)
|
||||
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 },
|
||||
})
|
||||
const face = controller.inject()
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
expect(face.hooks.bashCard.getSnapshot()).toMatchObject({
|
||||
available: true,
|
||||
writable: true,
|
||||
timeoutMs: { value: 5_000, overridden: true },
|
||||
maxOutputBytes: { value: 64_000, overridden: false },
|
||||
dirty: false,
|
||||
timeoutMs: { text: '5000', overridden: true },
|
||||
maxOutputBytes: { text: '64000', overridden: false },
|
||||
})
|
||||
|
||||
face.edit('timeoutMs', '9000')
|
||||
face.edit('maxOutputBytes', '1024')
|
||||
expect(face.hooks.bashCard.getSnapshot().dirty).toBe(true)
|
||||
|
||||
face.save()
|
||||
await vi.waitFor(() => { expect(host.set).toHaveBeenCalledTimes(2) })
|
||||
|
||||
expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000], ['maxOutputBytes', 1_024]])
|
||||
expect(face.hooks.bashCard.getSnapshot().dirty).toBe(false)
|
||||
})
|
||||
|
||||
it('treats an override equal to the composition default as an override', () => {
|
||||
it('stages a reset and applies it on save', async () => {
|
||||
const host = stubSettingsScope<BashSettings>()
|
||||
acceptWrites(host)
|
||||
const controller = new BashCardController(host.scope)
|
||||
|
||||
host.publish({
|
||||
status: 'ready',
|
||||
writable: true,
|
||||
value: { timeoutMs: 60_000 },
|
||||
value: { timeoutMs: 5_000 },
|
||||
base: { timeoutMs: 60_000 },
|
||||
user: { timeoutMs: 60_000 },
|
||||
user: { timeoutMs: 5_000 },
|
||||
})
|
||||
const face = controller.inject()
|
||||
|
||||
expect(controller.store.getSnapshot().timeoutMs).toEqual({ value: 60_000, overridden: true })
|
||||
face.resetField('timeoutMs')
|
||||
expect(face.hooks.bashCard.getSnapshot().timeoutMs.text).toBe('60000')
|
||||
|
||||
face.save()
|
||||
await vi.waitFor(() => { expect(host.unset).toHaveBeenCalledWith('timeoutMs') })
|
||||
|
||||
expect(face.hooks.bashCard.getSnapshot()).toMatchObject({
|
||||
dirty: false,
|
||||
timeoutMs: { text: '60000', overridden: false },
|
||||
})
|
||||
})
|
||||
|
||||
it('routes each action to its field write', async () => {
|
||||
it('discards staged edits without writing', () => {
|
||||
const host = stubSettingsScope<BashSettings>()
|
||||
const controller = new BashCardController(host.scope)
|
||||
host.publish({ status: 'ready', writable: true, value: { timeoutMs: 5_000 } })
|
||||
const actions = controller.inject()
|
||||
host.publish({ status: 'ready', writable: true, value: { timeoutMs: 5_000 }, user: {} })
|
||||
const face = controller.inject()
|
||||
|
||||
actions.setTimeoutMs(9_000)
|
||||
actions.resetTimeoutMs()
|
||||
actions.setMaxOutputBytes(1_024)
|
||||
actions.resetMaxOutputBytes()
|
||||
await Promise.resolve()
|
||||
face.edit('timeoutMs', '9000')
|
||||
face.discard()
|
||||
|
||||
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)
|
||||
expect(face.hooks.bashCard.getSnapshot().timeoutMs.text).toBe('5000')
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentLoopCardController', () => {
|
||||
it('publishes the cap and routes its two actions', async () => {
|
||||
it('saves the only field it owns', async () => {
|
||||
const host = stubSettingsScope<AgentLoopSettings>()
|
||||
acceptWrites(host)
|
||||
const controller = new AgentLoopCardController(host.scope)
|
||||
host.publish({
|
||||
status: 'ready',
|
||||
writable: true,
|
||||
value: { maxParallelToolCalls: 2 },
|
||||
value: { maxParallelToolCalls: 10 },
|
||||
base: { maxParallelToolCalls: 10 },
|
||||
user: { maxParallelToolCalls: 2 },
|
||||
user: {},
|
||||
})
|
||||
expect(controller.store.getSnapshot().maxParallelToolCalls).toEqual({ value: 2, overridden: true })
|
||||
const face = controller.inject()
|
||||
|
||||
const actions = controller.inject()
|
||||
actions.setMaxParallelToolCalls(4)
|
||||
actions.resetMaxParallelToolCalls()
|
||||
await Promise.resolve()
|
||||
face.edit('maxParallelToolCalls', '4')
|
||||
face.save()
|
||||
await vi.waitFor(() => { expect(host.set).toHaveBeenCalledWith('maxParallelToolCalls', 4) })
|
||||
|
||||
expect(host.set).toHaveBeenCalledWith('maxParallelToolCalls', 4)
|
||||
expect(host.unset).toHaveBeenCalledWith('maxParallelToolCalls')
|
||||
expect(face.hooks.agentLoopCard.getSnapshot()).toMatchObject({
|
||||
dirty: false,
|
||||
maxParallelToolCalls: { text: '4', overridden: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a read-only document so the card can disable its controls', () => {
|
||||
@@ -109,7 +375,7 @@ describe('AgentLoopCardController', () => {
|
||||
|
||||
host.publish({ status: 'ready', writable: false, value: { maxParallelToolCalls: 10 } })
|
||||
|
||||
expect(controller.store.getSnapshot().writable).toBe(false)
|
||||
expect(controller.inject().hooks.agentLoopCard.getSnapshot().writable).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -118,76 +384,133 @@ describe('WebSearchCardController', () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const credentials = credentialsApi(true)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
const state = () => controller.inject().hooks.webSearchCard.getSnapshot()
|
||||
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)
|
||||
})
|
||||
host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' }, user: {} })
|
||||
await vi.waitFor(() => { expect(state().apiKeyConfigured).toBe(true) })
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
baseURL: { value: 'https://search.test/v1', overridden: false },
|
||||
apiKeyRef: 'DEEPSEEK_API_KEY',
|
||||
expect(state()).toMatchObject({
|
||||
baseURL: { text: 'https://search.test/v1', overridden: false },
|
||||
apiKey: { text: '', overridden: false },
|
||||
})
|
||||
})
|
||||
|
||||
it('writes the key through the credentials domain, never the settings section', async () => {
|
||||
it('writes the staged 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: {} })
|
||||
host.publish({ status: 'ready', writable: true, value: {}, user: {} })
|
||||
const face = controller.inject()
|
||||
|
||||
controller.inject().setApiKey('ds-secret')
|
||||
face.edit('apiKey', ' ds-secret ')
|
||||
expect(face.hooks.webSearchCard.getSnapshot().dirty).toBe(true)
|
||||
expect(credentials.set).not.toHaveBeenCalled()
|
||||
|
||||
credentials.describe.mockImplementation(() => Promise.resolve({
|
||||
rpcId: 'c-1' as never,
|
||||
result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured: true, writable: true } } } },
|
||||
}))
|
||||
face.save()
|
||||
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())
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => {
|
||||
expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ dirty: false, apiKeyConfigured: true })
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the stored key when the draft is left blank', () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const credentials = credentialsApi(true)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
host.publish({ status: 'ready', writable: true, value: {}, user: {} })
|
||||
const face = controller.inject()
|
||||
|
||||
face.edit('apiKey', ' ')
|
||||
|
||||
expect(face.hooks.webSearchCard.getSnapshot().dirty).toBe(false)
|
||||
face.save()
|
||||
|
||||
expect(credentials.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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' } })
|
||||
host.publish({ status: 'ready', writable: true, value: { apiKeyEnv: 'SEARCH_KEY' }, user: {} })
|
||||
const face = controller.inject()
|
||||
|
||||
controller.inject().setApiKey('ds-secret')
|
||||
face.edit('apiKey', 'ds-secret')
|
||||
face.save()
|
||||
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 () => {
|
||||
it('reports a key the Host did not store as a failed save', 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() })
|
||||
const credentials = credentialsApi(false)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
host.publish({ status: 'ready', writable: true, value: {}, user: {} })
|
||||
const face = controller.inject()
|
||||
|
||||
host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' } })
|
||||
face.edit('apiKey', 'ds-secret')
|
||||
face.save()
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
available: true,
|
||||
apiKeyConfigured: false,
|
||||
baseURL: { value: 'https://search.test/v1' },
|
||||
await vi.waitFor(() => {
|
||||
expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ failed: true, dirty: true })
|
||||
})
|
||||
})
|
||||
|
||||
it('routes the endpoint and budget actions to their field writes', async () => {
|
||||
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 set = vi.fn(() => Promise.reject(new Error('offline')))
|
||||
const controller = new WebSearchCardController(host.scope, { credentials: { describe, set } } as never)
|
||||
const face = controller.inject()
|
||||
await vi.waitFor(() => { expect(describe).toHaveBeenCalled() })
|
||||
|
||||
host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' }, user: {} })
|
||||
face.edit('apiKey', 'ds-secret')
|
||||
face.save()
|
||||
await vi.waitFor(() => { expect(set).toHaveBeenCalled() })
|
||||
|
||||
expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({
|
||||
available: true,
|
||||
apiKeyConfigured: false,
|
||||
baseURL: { text: 'https://search.test/v1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores a credential read the Host refused', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const describe = vi.fn(() => Promise.resolve({
|
||||
rpcId: 'c-1' as never,
|
||||
result: { ok: false as const, error: { code: 'credentials-unavailable', message: 'no provider' } },
|
||||
}))
|
||||
const controller = new WebSearchCardController(host.scope, { credentials: { describe, set: vi.fn() } } as never)
|
||||
await vi.waitFor(() => { expect(describe).toHaveBeenCalled() })
|
||||
|
||||
expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(false)
|
||||
})
|
||||
|
||||
it('saves the endpoint and the search budget together', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
acceptWrites(host)
|
||||
const credentials = credentialsApi(true)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
host.publish({ status: 'ready', writable: true, value: {} })
|
||||
const actions = controller.inject()
|
||||
host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: {} })
|
||||
const face = controller.inject()
|
||||
|
||||
actions.setBaseUrl('https://other.test')
|
||||
actions.resetBaseUrl()
|
||||
actions.setMaxUses(3)
|
||||
actions.resetMaxUses()
|
||||
await Promise.resolve()
|
||||
face.edit('baseURL', 'https://other.test')
|
||||
face.edit('maxUses', '3')
|
||||
face.save()
|
||||
await vi.waitFor(() => { expect(host.set).toHaveBeenCalledTimes(2) })
|
||||
|
||||
expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test'], ['maxUses', 3]])
|
||||
expect(host.unset.mock.calls).toEqual([['baseURL'], ['maxUses']])
|
||||
expect(credentials.set).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user