// @vitest-environment jsdom /** * What the section and its cards show: the empty line when no plugin * contributed one, a card that renders nothing while its namespace is * unavailable, and the save footer that decides when staged edits are written. */ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { AgentLoopCard } from '../src/client/AgentLoopCard.tsx' import type { AgentLoopCardProps } from '../src/client/AgentLoopCard.tsx' import { BashCard } from '../src/client/BashCard.tsx' import type { BashCardProps } from '../src/client/BashCard.tsx' import { PluginConfigSection } from '../src/client/PluginConfigSection.tsx' import type { PluginConfigSectionProps } from '../src/client/PluginConfigSection.tsx' import { WebSearchCard } from '../src/client/WebSearchCard.tsx' import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx' import type { AgentLoopCardState } from '../src/client/agent-loop-store.ts' import type { BashCardState } from '../src/client/bash-store.ts' import type { CardFieldState, CardShell } from '../src/client/card-store.ts' import type { WebSearchCardState } from '../src/client/web-search-store.ts' import { en } from '../src/client/locales.ts' afterEach(cleanup) const t = (key: keyof typeof en) => en[key] /** A settled form: nothing staged, everything served. */ const settled: CardShell = { available: true, writable: true, dirty: false, invalid: false, saving: false, failed: false, } /** One control's state, defaulting to an inherited value. */ function field(text: string, rest: Partial = {}): CardFieldState { return { text, overridden: false, invalid: false, ...rest } } function cardActions() { return { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() } } function renderSection(cardCount: number, cards = 'cards') { const props = { t, cardCount, renderSlot: () =>
  • {cards}
  • , } as unknown as PluginConfigSectionProps render() } function renderBash(state: Partial = {}) { const store = createSnapshotStore({ ...settled, timeoutMs: field('60000'), maxOutputBytes: field('64000'), ...state, }) const actions = cardActions() const props = { ...actions, t, useBashCard: bindSnapshotSelector(store) } as unknown as BashCardProps render() return actions } describe('PluginConfigSection', () => { it('says so when no plugin contributed a card', () => { renderSection(0) expect(screen.getByText(en.empty)).toBeTruthy() expect(screen.queryByText('cards')).toBeNull() }) it('renders the card list once a plugin contributed one', () => { renderSection(1) expect(screen.getByText('cards')).toBeTruthy() expect(screen.queryByText(en.empty)).toBeNull() }) it('leads with its own heading and intro', () => { renderSection(1) expect(screen.getByRole('heading', { name: en.title })).toBeTruthy() expect(screen.getByText(en.intro)).toBeTruthy() }) }) describe('BashCard', () => { it('renders nothing while its namespace is unavailable', () => { const { container } = render(
    ) 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({ ...settled, dirty: true, maxParallelToolCalls: field('10'), }) const actions = cardActions() const props = { ...actions, t, useAgentLoopCard: bindSnapshotSelector(store), } as unknown as AgentLoopCardProps render() 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({ ...settled, maxParallelToolCalls: field('2', { overridden: true }), }) const actions = cardActions() const props = { ...actions, t, useAgentLoopCard: bindSnapshotSelector(store), } as unknown as AgentLoopCardProps render() 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 = {}) { const store = createSnapshotStore({ ...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() 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']]) }) })