Merge remote-tracking branch 'origin/master' into feat/plugin-owned-settings-surface

# Conflicts:
#	packages/client/ui-input-trigger/README.i18n.yaml
#	packages/client/ui-settings-plugins/src/client/ConfigurablePluginsTab.tsx
#	packages/client/ui-settings-plugins/src/client/index.ts
#	packages/client/ui-settings-plugins/src/client/tab-store.ts
#	packages/client/ui-settings-plugins/tests/apply.client.spec.ts
#	packages/client/ui-settings-plugins/tests/section.client.spec.tsx
#	packages/client/ui-settings-plugins/tests/stores.client.spec.ts
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/tests/api-proxy-config.spec.ts
This commit is contained in:
Yichen Jiang
2026-08-14 15:46:01 +08:00
3766 changed files with 58438 additions and 99720 deletions

View File

@@ -0,0 +1,222 @@
/** What the browser half registers, and that it all leaves with the fiber. */
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
import type {
ConfigurablePluginsTabFace, PluginsSettingsSectionInjected,
} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
// The service reads its initial locale from the browser; these specs assert
// the shipped Chinese copy, so they state the browser they assume.
usePinnedBrowserLanguages('zh-CN')
/**
* @param served - namespaces the Host describes; omitted answers a failed read,
* which is what most of these specs want (no card has anything to render).
*/
async function bench(served?: string[]) {
const ctx = new Context()
await ctx.plugin(SlotRegistry).await()
const locale = new LocaleRuntime(ctx)
ctx.provide('locale', locale)
const describeCredentials = vi.fn(() => Promise.resolve({ rpcId: 'c', result: { ok: false, error: {} } }))
const describeSettings = vi.fn(() => Promise.resolve(served === undefined
? { rpcId: 's', result: { ok: false, error: {} } }
: {
rpcId: 's',
result: {
ok: true,
value: {
writable: true,
hasDocument: true,
namespaces: served.map(ns => ({
ns, schema: {}, value: {}, applies: 'live', secrets: [], revision: 0,
})),
},
},
}))
// The section binds its scopes through the Settings surface's service, and
// forwarded Host events reach it through the same `$dispatch` handoff the
// connection sink makes.
new TestRemote(ctx)
ctx.provide('connection', {
isLoopback: true,
api: {
settings: { describe: describeSettings },
credentials: { describe: describeCredentials },
},
} as never)
await ctx.plugin(SettingsScopeBinder).await()
return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings }
}
function declareRoot(slots: SlotRegistry): () => void {
return slots.register({
name: 'root',
children: { 'settings.section': { kind: 'list', scope: 'root' } },
} as never, () => null)
}
describe('ui-settings-plugins apply', () => {
it('declares the services it uses', () => {
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope'])
})
it('registers one Plugins section and declares the tab and card slots', 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: 15 })
// The nav label is a locale-following thunk; owners resolve it at read time.
expect(resolveSlotLabel(section.options.label)).toBe('插件')
expect(slots.spec('settings.plugins.tab')).toMatchObject({ kind: 'list', scope: 'root' })
const tab = slots.entries('settings.plugins.tab')[0]!
expect(tab.options).toMatchObject({ id: 'configurable', order: 0 })
expect(resolveSlotLabel(tab.options.label)).toBe('插件配置')
expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'keyed', scope: 'root' })
})
it('injects a live tab projection, the card directory, 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]!
const sectionFace = (section.inject as unknown as () => PluginsSettingsSectionInjected)()
const initialTabs = sectionFace.hooks.tabs.getSnapshot()
expect(initialTabs).toEqual([
{ id: 'configurable', order: 0, label: '插件配置' },
])
expect(sectionFace.hooks.tabs.getSnapshot()).toBe(initialTabs)
const listener = vi.fn()
const unsubscribe = sectionFace.hooks.tabs.subscribe(listener)
slots.register({ name: 'settings.plugins.tab', id: 'plain' } as never, () => null)
expect(sectionFace.hooks.tabs.getSnapshot()).toEqual([
{ id: 'configurable', order: 0, label: '插件配置' },
{ id: 'plain', order: 0, label: '' },
])
unsubscribe()
const tab = slots.entries('settings.plugins.tab')[0]!
const tabFace = (tab.inject as unknown as () => ConfigurablePluginsTabFace)()
expect(Object.keys(tabFace.hooks)).toEqual(['configurablePlugins'])
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('keys each card it ships on the settings namespace that card edits', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
expect(slots.entries('settings.plugin.item').map(entry => entry.options.key))
.toEqual(['shell', 'agent-loop', 'web-search-deepseek'])
})
it('dispatches the served namespaces its cards claim, and no others', async () => {
// ui-theme is served but belongs to another surface, and a deployment
// composing no PowerShell/POSIX executor serves no `bash` at all.
const { ctx, slots } = await bench(['agent-loop', 'ui-theme', 'web-search-deepseek'])
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
const tab = slots.entries('settings.plugins.tab')[0]!
const face = (tab.inject as unknown as () => ConfigurablePluginsTabFace)()
await vi.waitFor(() => {
expect(face.hooks.configurablePlugins.getSnapshot().namespaces)
.toEqual(['agent-loop', 'web-search-deepseek'])
})
})
it('re-reads the served namespaces when the Host commits a settings document', async () => {
// Which namespaces the Host serves is a registration fact the wire never
// announces on its own, so the tab rides the invalidation that can
// accompany a changed composition.
const { ctx, slots, describeSettings } = await bench(['bash'])
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() })
describeSettings.mockClear()
ctx.remote.$dispatch('settings/document-updated', ['bash', 1])
await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() })
})
it('re-reads the served namespaces after a reconnect', async () => {
const { ctx, slots, describeSettings } = await bench(['bash'])
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() })
describeSettings.mockClear()
ctx.emit('connection/reset')
await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() })
})
it('re-reads the credential when the Host reports the watched reference changed', async () => {
const { ctx, slots, describeCredentials } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() })
describeCredentials.mockClear()
// A key written on another surface changes no settings section, so this
// event is the only thing that reaches the card.
ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY'])
await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) })
})
it('ignores a credential change for a reference no card watches', async () => {
const { ctx, slots, describeCredentials } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() })
describeCredentials.mockClear()
ctx.remote.$dispatch('credentials/updated', ['SOME_OTHER_KEY'])
await Promise.resolve()
expect(describeCredentials).not.toHaveBeenCalled()
})
it('registers into a declaration that arrives after apply', async () => {
const { ctx, slots } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
declareRoot(slots)
await vi.waitFor(() => { expect(slots.entries('settings.section')).toHaveLength(1) })
})
it('collapses every contribution on teardown', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(slots.entries('settings.plugin.item')).toHaveLength(3)
await fiber.dispose()
expect(slots.entries('settings.section')).toHaveLength(0)
expect(slots.spec('settings.plugins.tab')).toBeUndefined()
expect(slots.spec('settings.plugin.item')).toBeUndefined()
})
})

View File

@@ -0,0 +1,156 @@
// @vitest-environment jsdom
/**
* 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 { SecretField, ValueField } 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',
invalidLabel: 'Enter a number.',
disabled: false,
overridden: false,
invalid: false,
}
describe('ValueField', () => {
it('stages every keystroke without writing', () => {
const onEdit = vi.fn()
render(<ValueField {...frame} text="60000" onEdit={onEdit} onReset={vi.fn()} />)
fireEvent.change(screen.getByLabelText('Command timeout'), { target: { value: '9000' } })
expect(onEdit).toHaveBeenCalledWith('9000')
})
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')
rerender(<ValueField {...frame} text="9000" onEdit={vi.fn()} onReset={vi.fn()} />)
expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '9000')
})
it('offers the reset only while an override would stand', () => {
const onReset = vi.fn()
const { rerender } = render(<ValueField {...frame} text="9000" onEdit={vi.fn()} onReset={onReset} />)
expect(screen.queryByRole('button', { name: 'Reset to default' })).toBeNull()
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('replaces the hint with the reason an invalid draft cannot be saved', () => {
render(<ValueField {...frame} invalid text="soon" onEdit={vi.fn()} onReset={vi.fn()} />)
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('hints a numeric keypad and renders a placeholder when asked', () => {
render(
<ValueField
{...frame}
numeric
placeholder="https://api.deepseek.com"
text=""
onEdit={vi.fn()}
onReset={vi.fn()}
/>,
)
const input = screen.getByLabelText('Command timeout')
expect(input.getAttribute('inputmode')).toBe('numeric')
expect(input).toHaveProperty('placeholder', 'https://api.deepseek.com')
})
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()} />)
expect(screen.getByLabelText('Command timeout')).toHaveProperty('disabled', true)
expect(screen.getByRole('button', { name: 'Reset to default' })).toHaveProperty('disabled', true)
})
})
describe('SecretField', () => {
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
{...secret}
text=""
configured={false}
stateLabel="No key is configured."
onEdit={onEdit}
/>,
)
const input = screen.getByLabelText('API key')
fireEvent.change(input, { target: { value: 'ds-secret' } })
expect(onEdit).toHaveBeenCalledWith('ds-secret')
expect(input).toHaveProperty('type', 'password')
})
it('reports the configured state the Host holds', () => {
const { rerender } = render(
<SecretField
{...secret}
text=""
configured={false}
stateLabel="No key is configured."
onEdit={vi.fn()}
/>,
)
expect(screen.getByText('No key is configured.')).toBeTruthy()
rerender(
<SecretField
{...secret}
text="ds-secret"
configured
stateLabel="A key is configured."
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()}
/>,
)
expect(screen.getByLabelText('API key')).toHaveProperty('disabled', true)
})
})

View 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 '@deepseek-ai/cordis'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
import * as PluginConfigInvariant from '@deepseek-ai/dsh-client-ui-settings-plugins/invariant'
describe('invariant companion', () => {
it('reserves package ownership with an empty installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantRegistry, { 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-settings-plugins')
// 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')
})
})

View File

@@ -0,0 +1,419 @@
// @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 { ConfigurablePluginsTab } from '../src/client/ConfigurablePluginsTab.tsx'
import type { ConfigurablePluginsTabProps } from '../src/client/ConfigurablePluginsTab.tsx'
import { PluginsSettingsSection } from '../src/client/PluginsSettingsSection.tsx'
import type { PluginsSettingsSectionProps, PluginsSettingsTabEntry } from '../src/client/PluginsSettingsSection.tsx'
import { WebSearchCard } from '../src/client/WebSearchCard.tsx'
import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx'
import type { AgentLoopCardState } from '../src/client/agent-loop-card-controller.ts'
import type { BashCardState } from '../src/client/bash-card-controller.ts'
import type { CardFieldState, CardShell } from '../src/client/card-form.ts'
import type { ConfigurablePluginsTabState } from '../src/client/tab-store.ts'
import type { WebSearchCardState } from '../src/client/web-search-card-controller.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(rows: readonly PluginsSettingsTabEntry[]) {
const props = {
t,
useTabs: (selector: (value: readonly PluginsSettingsTabEntry[]) => unknown) => selector(rows),
renderSlot: (_name: string, _owner: unknown, options: { only?: string }) => (
<span>{options.only}</span>
),
} as unknown as PluginsSettingsSectionProps
render(<PluginsSettingsSection {...props} />)
}
/**
* Render the tab 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 renderConfigurable(namespaces: string[], cards: Record<string, string> = {}, loaded = true) {
const store = createSnapshotStore<ConfigurablePluginsTabState>({ loaded, namespaces })
const props = {
t,
useConfigurablePlugins: 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 ConfigurablePluginsTabProps
render(<ConfigurablePluginsTab {...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('PluginsSettingsSection', () => {
it('says so when no plugin contributed a tab', () => {
renderSection([])
expect(screen.getByText(en.empty)).toBeTruthy()
expect(screen.queryByRole('tab')).toBeNull()
})
it('defaults to the first ordered tab and mounts another only after selection', () => {
renderSection([
{ id: 'configurable', order: 0, label: en.configurableTab },
{ id: 'all', order: 10, label: 'Plugin list' },
])
const configurable = screen.getByRole('tab', { name: en.configurableTab })
const all = screen.getByRole('tab', { name: 'Plugin list' })
expect(configurable.getAttribute('aria-selected')).toBe('true')
expect(screen.getByText('configurable')).toBeTruthy()
expect(screen.queryByText('all')).toBeNull()
fireEvent.click(all)
expect(all.getAttribute('aria-selected')).toBe('true')
expect(screen.getByText('all')).toBeTruthy()
expect(screen.getByText('configurable').closest('[role="tabpanel"]')).toHaveProperty('hidden', true)
fireEvent.click(configurable)
expect(configurable.getAttribute('aria-selected')).toBe('true')
expect(screen.getByText('all').closest('[role="tabpanel"]')).toHaveProperty('hidden', true)
})
it('leads with its own heading and intro', () => {
renderSection([{ id: 'configurable', order: 0, label: en.configurableTab }])
expect(screen.getByRole('heading', { name: en.title })).toBeTruthy()
expect(screen.getByText(en.intro)).toBeTruthy()
})
it('moves focus and selection with standard horizontal tab keys', () => {
renderSection([
{ id: 'configurable', order: 0, label: en.configurableTab },
{ id: 'all', order: 10, label: 'Plugin list' },
{ id: 'diagnostics', order: 20, label: 'Diagnostics' },
])
const configurable = screen.getByRole('tab', { name: en.configurableTab })
const all = screen.getByRole('tab', { name: 'Plugin list' })
const diagnostics = screen.getByRole('tab', { name: 'Diagnostics' })
expect(configurable.getAttribute('tabindex')).toBe('0')
expect(all.getAttribute('tabindex')).toBe('-1')
configurable.focus()
fireEvent.keyDown(configurable, { key: 'ArrowRight' })
expect(document.activeElement).toBe(all)
expect(all.getAttribute('aria-selected')).toBe('true')
fireEvent.keyDown(all, { key: 'End' })
expect(document.activeElement).toBe(diagnostics)
fireEvent.keyDown(diagnostics, { key: 'ArrowRight' })
expect(document.activeElement).toBe(configurable)
fireEvent.keyDown(configurable, { key: 'ArrowLeft' })
expect(document.activeElement).toBe(diagnostics)
fireEvent.keyDown(diagnostics, { key: 'Home' })
expect(document.activeElement).toBe(configurable)
fireEvent.keyDown(configurable, { key: 'Escape' })
expect(document.activeElement).toBe(configurable)
expect(configurable.getAttribute('aria-selected')).toBe('true')
})
})
describe('ConfigurablePluginsTab', () => {
it('says so when no plugin contributed a card', () => {
renderConfigurable([], { 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.
renderConfigurable([], { bash: 'shell' }, false)
expect(screen.queryByText(en.empty)).toBeNull()
})
it('dispatches one card per namespace, keyed by it', () => {
renderConfigurable(['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()
})
})
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']])
})
})

View File

@@ -0,0 +1,669 @@
/**
* 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, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { CardForm, numberField, textField } from '../src/client/card-form.ts'
import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-card-controller.ts'
import { BashCardController, type BashSettings } from '../src/client/bash-card-controller.ts'
import { ConfigurablePluginsTabController } from '../src/client/tab-store.ts'
import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-card-controller.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,
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('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,
value: { timeoutMs: 5_000, maxOutputBytes: 64_000 },
base: { timeoutMs: 60_000, maxOutputBytes: 64_000 },
user: { timeoutMs: 5_000 },
})
const face = controller.inject()
expect(face.hooks.bashCard.getSnapshot()).toMatchObject({
available: true,
writable: true,
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('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: 5_000 },
base: { timeoutMs: 60_000 },
user: { timeoutMs: 5_000 },
})
const face = controller.inject()
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('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 }, user: {} })
const face = controller.inject()
face.edit('timeoutMs', '9000')
face.discard()
expect(face.hooks.bashCard.getSnapshot().timeoutMs.text).toBe('5000')
expect(host.set).not.toHaveBeenCalled()
})
})
describe('AgentLoopCardController', () => {
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: 10 },
base: { maxParallelToolCalls: 10 },
user: {},
})
const face = controller.inject()
face.edit('maxParallelToolCalls', '4')
face.save()
await vi.waitFor(() => { expect(host.set).toHaveBeenCalledWith('maxParallelToolCalls', 4) })
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', () => {
const host = stubSettingsScope<AgentLoopSettings>()
const controller = new AgentLoopCardController(host.scope)
host.publish({ status: 'ready', writable: false, value: { maxParallelToolCalls: 10 } })
expect(controller.inject().hooks.agentLoopCard.getSnapshot().writable).toBe(false)
})
})
describe('WebSearchCardController', () => {
it('reads the credential state for the reference the tab names', async () => {
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' }, user: {} })
await vi.waitFor(() => { expect(state().apiKeyConfigured).toBe(true) })
expect(state()).toMatchObject({
baseURL: { text: 'https://search.test/v1', overridden: false },
apiKey: { text: '', overridden: false },
})
})
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: {}, user: {} })
const face = controller.inject()
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.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('re-reads when the Host reports the watched reference changed', async () => {
const host = stubSettingsScope<WebSearchSettings>()
const credentials = credentialsApi(false)
const controller = new WebSearchCardController(host.scope, credentials.api)
host.publish({ status: 'ready', writable: true, value: {}, user: {} })
await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() })
credentials.describe.mockClear()
// Another reference is not this card's business.
controller.refreshCredential('OTHER_KEY')
expect(credentials.describe).not.toHaveBeenCalled()
// A key written on another surface reaches this card only through this signal.
credentials.describe.mockImplementation(() => Promise.resolve({
rpcId: 'c-1' as never,
result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured: true, writable: true } } } },
}))
controller.refreshCredential('DEEPSEEK_API_KEY')
await vi.waitFor(() => {
expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(true)
})
})
it('addresses the reference the tab 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' }, user: {} })
const face = controller.inject()
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('reports a key the Host did not store as a failed save', async () => {
const host = stubSettingsScope<WebSearchSettings>()
const credentials = credentialsApi(false)
const controller = new WebSearchCardController(host.scope, credentials.api)
host.publish({ status: 'ready', writable: true, value: {}, user: {} })
const face = controller.inject()
face.edit('apiKey', 'ds-secret')
face.save()
await vi.waitFor(() => {
expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ failed: true, dirty: true })
})
})
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: {}, base: {}, user: {} })
const face = controller.inject()
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(credentials.set).not.toHaveBeenCalled()
})
})
describe('ConfigurablePluginsTabController', () => {
function settingsApi(namespaces: string[]) {
const describe = vi.fn(() => Promise.resolve({
rpcId: 's-1' as never,
result: {
ok: true as const,
value: {
writable: true,
hasDocument: true,
namespaces: namespaces.map(ns => ({
ns, schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0,
})),
},
},
}))
return { api: { settings: { describe } } as never, describe }
}
/** Slot ledger stand-in: one stored entry per registered card key. */
function ledger(...keys: string[]) {
return keys.map(key => ({ component: null, options: { key } }))
}
it('dispatches the served namespaces a card claims, in card registration order', async () => {
const settings = settingsApi(['bash', 'ui-theme', 'agent-loop'])
const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('agent-loop', 'bash'))
await controller.load()
// ui-theme is served but claimed by no card here — another surface owns
// it. The order is the cards', not the Host's: plugin activation can
// reorder the description between boots.
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces)
.toEqual(['agent-loop', 'bash'])
})
it('never dispatches a card whose namespace this deployment does not serve', async () => {
const settings = settingsApi(['bash'])
const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash', 'web-search-deepseek'))
await controller.load()
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
})
it('takes a card registered after the read without asking the Host again', async () => {
const settings = settingsApi(['bash'])
let entries = ledger()
const controller = new ConfigurablePluginsTabController(settings.api, () => entries)
await controller.load()
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual([])
entries = ledger('bash')
controller.refresh()
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
expect(settings.describe).toHaveBeenCalledOnce()
})
it('keeps the namespaces it knew when a read fails', async () => {
const settings = settingsApi(['bash'])
const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash'))
await controller.load()
settings.describe.mockRejectedValueOnce(new Error('offline'))
await controller.load()
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
})
it('publishes nothing once disposed, and never claims it was answered', async () => {
const settings = settingsApi(['bash'])
const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash'))
controller.dispose()
await controller.load()
expect(controller.inject().hooks.configurablePlugins.getSnapshot())
.toEqual({ loaded: false, namespaces: [] })
expect(settings.describe).not.toHaveBeenCalled()
})
it('ignores a slot-ledger change that arrives after disposal', async () => {
const settings = settingsApi(['bash'])
let entries = ledger()
const controller = new ConfigurablePluginsTabController(settings.api, () => entries)
await controller.load()
controller.dispose()
entries = ledger('bash')
controller.refresh()
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual([])
})
it('drops a read a newer one superseded', async () => {
// The section re-reads on every settings-document invalidation, so a slow
// first answer must not overwrite the newer one that already landed.
const settings = settingsApi(['bash'])
const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash', 'agent-loop'))
const slow = Promise.withResolvers<unknown>()
settings.describe.mockReturnValueOnce(slow.promise as never)
const stale = controller.load()
await controller.load()
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
slow.resolve({
rpcId: 's-0',
result: { ok: true, value: { writable: true, hasDocument: true, namespaces: [
{ ns: 'agent-loop', schema: {}, value: {}, applies: 'live', secrets: [], revision: 0 },
] } },
})
await stale
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
})
it('reports the Host answered even when it serves nothing this tab shows', async () => {
const settings = settingsApi(['ui-theme'])
const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash'))
await controller.load()
expect(controller.inject().hooks.configurablePlugins.getSnapshot())
.toEqual({ loaded: true, namespaces: [] })
})
})