section.client.spec.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. // @vitest-environment jsdom
  2. import { cleanup, fireEvent, render, screen } from '@testing-library/react'
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
  5. import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
  6. import { AgentLoopCard } from '../src/client/AgentLoopCard.tsx'
  7. import type { AgentLoopCardProps } from '../src/client/AgentLoopCard.tsx'
  8. import { BashCard } from '../src/client/BashCard.tsx'
  9. import type { BashCardProps } from '../src/client/BashCard.tsx'
  10. import { ConfigurablePluginsTab } from '../src/client/ConfigurablePluginsTab.tsx'
  11. import type { ConfigurablePluginsTabProps } from '../src/client/ConfigurablePluginsTab.tsx'
  12. import { PluginsSettingsSection } from '../src/client/PluginsSettingsSection.tsx'
  13. import type { PluginsSettingsSectionProps, PluginsSettingsTabEntry } from '../src/client/PluginsSettingsSection.tsx'
  14. import { WebSearchCard } from '../src/client/WebSearchCard.tsx'
  15. import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx'
  16. import type { AgentLoopCardState } from '../src/client/agent-loop-card-controller.ts'
  17. import type { BashCardState } from '../src/client/bash-card-controller.ts'
  18. import type { CardFieldState, CardShell } from '../src/client/card-form.ts'
  19. import type { ConfigurablePluginsTabState } from '../src/client/tab-store.ts'
  20. import type { WebSearchCardState } from '../src/client/web-search-card-controller.ts'
  21. import { en } from '../src/client/locales.ts'
  22. afterEach(cleanup)
  23. const t = (key: keyof typeof en) => en[key]
  24. const settled: CardShell = {
  25. available: true,
  26. writable: true,
  27. dirty: false,
  28. invalid: false,
  29. saving: false,
  30. failed: false,
  31. }
  32. function field(text: string, rest: Partial<CardFieldState> = {}): CardFieldState {
  33. return { text, overridden: false, invalid: false, ...rest }
  34. }
  35. function cardActions() {
  36. return { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() }
  37. }
  38. function renderSection(rows: readonly PluginsSettingsTabEntry[]) {
  39. const props = {
  40. t,
  41. useTabs: (selector: (value: readonly PluginsSettingsTabEntry[]) => unknown) => selector(rows),
  42. renderSlot: (_name: string, _owner: unknown, options: { only?: string }) => (
  43. <span>{options.only}</span>
  44. ),
  45. } as unknown as PluginsSettingsSectionProps
  46. render(<PluginsSettingsSection {...props} />)
  47. }
  48. function renderConfigurable(namespaces: string[], cards: Record<string, string> = {}, loaded = true) {
  49. const store = createSnapshotStore<ConfigurablePluginsTabState>({ loaded, namespaces })
  50. const props = {
  51. t,
  52. useConfigurablePlugins: bindSnapshotSelector(store),
  53. renderSlot: (_name: string, _owner: object, opts?: { entryKey?: string }) => {
  54. const card = opts?.entryKey === undefined ? undefined : cards[opts.entryKey]
  55. return card === undefined ? null : <li>{card}</li>
  56. },
  57. } as unknown as ConfigurablePluginsTabProps
  58. render(<ConfigurablePluginsTab {...props} />)
  59. }
  60. function renderBash(state: Partial<BashCardState> = {}) {
  61. const store = createSnapshotStore<BashCardState>({
  62. ...settled,
  63. timeoutMs: field('60000'),
  64. maxOutputBytes: field('64000'),
  65. ...state,
  66. })
  67. const actions = cardActions()
  68. const props = { ...actions, t, useBashCard: bindSnapshotSelector(store) } as unknown as BashCardProps
  69. render(<BashCard {...props} />)
  70. return actions
  71. }
  72. describe('PluginsSettingsSection', () => {
  73. it('says so when no plugin contributed a tab', () => {
  74. renderSection([])
  75. expect(screen.getByText(en.empty)).toBeTruthy()
  76. expect(screen.queryByRole('tab')).toBeNull()
  77. })
  78. it('defaults to the first ordered tab and mounts another only after selection', () => {
  79. renderSection([
  80. { id: 'configurable', order: 0, label: en.configurableTab },
  81. { id: 'all', order: 10, label: 'Plugin list' },
  82. ])
  83. const configurable = screen.getByRole('tab', { name: en.configurableTab })
  84. const all = screen.getByRole('tab', { name: 'Plugin list' })
  85. expect(configurable.getAttribute('aria-selected')).toBe('true')
  86. expect(screen.getByText('configurable')).toBeTruthy()
  87. expect(screen.queryByText('all')).toBeNull()
  88. fireEvent.click(all)
  89. expect(all.getAttribute('aria-selected')).toBe('true')
  90. expect(screen.getByText('all')).toBeTruthy()
  91. expect(screen.getByText('configurable').closest('[role="tabpanel"]')).toHaveProperty('hidden', true)
  92. fireEvent.click(configurable)
  93. expect(configurable.getAttribute('aria-selected')).toBe('true')
  94. expect(screen.getByText('all').closest('[role="tabpanel"]')).toHaveProperty('hidden', true)
  95. })
  96. it('leads with its own heading and intro', () => {
  97. renderSection([{ id: 'configurable', order: 0, label: en.configurableTab }])
  98. expect(screen.getByRole('heading', { name: en.title })).toBeTruthy()
  99. expect(screen.getByText(en.intro)).toBeTruthy()
  100. })
  101. it('moves focus and selection with standard horizontal tab keys', () => {
  102. renderSection([
  103. { id: 'configurable', order: 0, label: en.configurableTab },
  104. { id: 'all', order: 10, label: 'Plugin list' },
  105. { id: 'diagnostics', order: 20, label: 'Diagnostics' },
  106. ])
  107. const configurable = screen.getByRole('tab', { name: en.configurableTab })
  108. const all = screen.getByRole('tab', { name: 'Plugin list' })
  109. const diagnostics = screen.getByRole('tab', { name: 'Diagnostics' })
  110. expect(configurable.getAttribute('tabindex')).toBe('0')
  111. expect(all.getAttribute('tabindex')).toBe('-1')
  112. configurable.focus()
  113. fireEvent.keyDown(configurable, { key: 'ArrowRight' })
  114. expect(document.activeElement).toBe(all)
  115. expect(all.getAttribute('aria-selected')).toBe('true')
  116. fireEvent.keyDown(all, { key: 'End' })
  117. expect(document.activeElement).toBe(diagnostics)
  118. fireEvent.keyDown(diagnostics, { key: 'ArrowRight' })
  119. expect(document.activeElement).toBe(configurable)
  120. fireEvent.keyDown(configurable, { key: 'ArrowLeft' })
  121. expect(document.activeElement).toBe(diagnostics)
  122. fireEvent.keyDown(diagnostics, { key: 'Home' })
  123. expect(document.activeElement).toBe(configurable)
  124. fireEvent.keyDown(configurable, { key: 'Escape' })
  125. expect(document.activeElement).toBe(configurable)
  126. expect(configurable.getAttribute('aria-selected')).toBe('true')
  127. })
  128. })
  129. describe('ConfigurablePluginsTab', () => {
  130. it('says so when no plugin contributed a card', () => {
  131. renderConfigurable([], { bash: 'shell' })
  132. expect(screen.getByText(en.empty)).toBeTruthy()
  133. expect(screen.queryByText('shell')).toBeNull()
  134. })
  135. it('withholds the empty line until the Host has answered once', () => {
  136. // An unanswered read is not the statement that this deployment configures
  137. // no plugin; saying it anyway would flash a wrong answer on every open.
  138. renderConfigurable([], { bash: 'shell' }, false)
  139. expect(screen.queryByText(en.empty)).toBeNull()
  140. })
  141. it('dispatches one card per namespace, keyed by it', () => {
  142. renderConfigurable(['bash', 'agent-loop'], { bash: 'shell', 'agent-loop': 'loop' })
  143. expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['shell', 'loop'])
  144. expect(screen.queryByText(en.empty)).toBeNull()
  145. })
  146. })
  147. describe('BashCard', () => {
  148. it('renders nothing while its namespace is unavailable', () => {
  149. const { container } = render(<div />)
  150. renderBash({ available: false })
  151. expect(container.textContent).toBe('')
  152. expect(screen.queryByText(en.bashTitle)).toBeNull()
  153. })
  154. it('shows the plugin and reveals its fields only once expanded', () => {
  155. renderBash()
  156. expect(screen.getByText(en.bashTitle)).toBeTruthy()
  157. expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull()
  158. fireEvent.click(screen.getByText(en.bashTitle))
  159. expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy()
  160. expect(screen.getByLabelText(en.bashMaxOutputBytes)).toBeTruthy()
  161. })
  162. it('stages an edit instead of writing it', () => {
  163. const actions = renderBash()
  164. fireEvent.click(screen.getByText(en.bashTitle))
  165. fireEvent.change(screen.getByLabelText(en.bashTimeoutMs), { target: { value: '9000' } })
  166. expect(actions.edit).toHaveBeenCalledWith('timeoutMs', '9000')
  167. expect(actions.save).not.toHaveBeenCalled()
  168. })
  169. it('offers the reset for an overridden field only', () => {
  170. const actions = renderBash({ timeoutMs: field('9000', { overridden: true }) })
  171. fireEvent.click(screen.getByText(en.bashTitle))
  172. // One badge and one reset: the output cap is still inherited.
  173. expect(screen.getAllByText(en.overridden)).toHaveLength(1)
  174. fireEvent.click(screen.getByRole('button', { name: en.reset }))
  175. expect(actions.resetField).toHaveBeenCalledWith('timeoutMs')
  176. })
  177. it('addresses each of its two fields separately', () => {
  178. const actions = renderBash({ maxOutputBytes: field('64000', { overridden: true }) })
  179. fireEvent.click(screen.getByText(en.bashTitle))
  180. fireEvent.change(screen.getByLabelText(en.bashMaxOutputBytes), { target: { value: '1024' } })
  181. fireEvent.click(screen.getByRole('button', { name: en.reset }))
  182. expect(actions.edit).toHaveBeenCalledWith('maxOutputBytes', '1024')
  183. expect(actions.resetField).toHaveBeenCalledWith('maxOutputBytes')
  184. })
  185. it('keeps save and discard inert until something is staged', () => {
  186. renderBash()
  187. fireEvent.click(screen.getByText(en.bashTitle))
  188. expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true)
  189. expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', true)
  190. expect(screen.queryByText(en.unsaved)).toBeNull()
  191. })
  192. it('writes the staged edits when saved, and drops them when discarded', () => {
  193. const actions = renderBash({ dirty: true, timeoutMs: field('9000', { overridden: true }) })
  194. fireEvent.click(screen.getByText(en.bashTitle))
  195. fireEvent.click(screen.getByRole('button', { name: en.save }))
  196. fireEvent.click(screen.getByRole('button', { name: en.discard }))
  197. expect(actions.save).toHaveBeenCalledOnce()
  198. expect(actions.discard).toHaveBeenCalledOnce()
  199. })
  200. it('marks a card holding unsaved edits, collapsed or not', () => {
  201. renderBash({ dirty: true })
  202. expect(screen.getByText(en.unsaved)).toBeTruthy()
  203. })
  204. it('blocks the save while a draft is invalid, and says why', () => {
  205. renderBash({ dirty: true, invalid: true, timeoutMs: field('soon', { invalid: true }) })
  206. fireEvent.click(screen.getByText(en.bashTitle))
  207. expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true)
  208. expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', false)
  209. expect(screen.getByText(en.invalidNumber)).toBeTruthy()
  210. })
  211. it('reports a save in flight and refuses another', () => {
  212. renderBash({ dirty: true, saving: true })
  213. fireEvent.click(screen.getByText(en.bashTitle))
  214. expect(screen.getByRole('button', { name: en.saving })).toHaveProperty('disabled', true)
  215. expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', true)
  216. })
  217. it('reports a save the deployment did not accept', () => {
  218. renderBash({ dirty: true, failed: true })
  219. fireEvent.click(screen.getByText(en.bashTitle))
  220. expect(screen.getByText(en.saveFailed)).toBeTruthy()
  221. })
  222. it('says the document is read-only and disables its controls', () => {
  223. renderBash({ writable: false })
  224. fireEvent.click(screen.getByText(en.bashTitle))
  225. expect(screen.getByRole('status')).toHaveProperty('textContent', en.readOnly)
  226. expect(screen.getByLabelText(en.bashTimeoutMs)).toHaveProperty('disabled', true)
  227. })
  228. it('collapses again on a second click', () => {
  229. renderBash()
  230. fireEvent.click(screen.getByText(en.bashTitle))
  231. expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy()
  232. fireEvent.click(screen.getByText(en.bashTitle))
  233. expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull()
  234. })
  235. })
  236. describe('AgentLoopCard', () => {
  237. it('stages and saves the only field it owns', () => {
  238. const store = createSnapshotStore<AgentLoopCardState>({
  239. ...settled,
  240. dirty: true,
  241. maxParallelToolCalls: field('10'),
  242. })
  243. const actions = cardActions()
  244. const props = {
  245. ...actions,
  246. t,
  247. useAgentLoopCard: bindSnapshotSelector(store),
  248. } as unknown as AgentLoopCardProps
  249. render(<AgentLoopCard {...props} />)
  250. fireEvent.click(screen.getByText(en.agentLoopTitle))
  251. fireEvent.change(screen.getByLabelText(en.agentLoopMaxParallel), { target: { value: '2' } })
  252. fireEvent.click(screen.getByRole('button', { name: en.save }))
  253. expect(actions.edit).toHaveBeenCalledWith('maxParallelToolCalls', '2')
  254. expect(actions.save).toHaveBeenCalledOnce()
  255. })
  256. it('stages a reset for the field it owns', () => {
  257. const store = createSnapshotStore<AgentLoopCardState>({
  258. ...settled,
  259. maxParallelToolCalls: field('2', { overridden: true }),
  260. })
  261. const actions = cardActions()
  262. const props = {
  263. ...actions,
  264. t,
  265. useAgentLoopCard: bindSnapshotSelector(store),
  266. } as unknown as AgentLoopCardProps
  267. render(<AgentLoopCard {...props} />)
  268. fireEvent.click(screen.getByText(en.agentLoopTitle))
  269. fireEvent.click(screen.getByRole('button', { name: en.reset }))
  270. expect(actions.resetField).toHaveBeenCalledWith('maxParallelToolCalls')
  271. })
  272. })
  273. describe('WebSearchCard', () => {
  274. function renderWebSearch(state: Partial<WebSearchCardState> = {}) {
  275. const store = createSnapshotStore<WebSearchCardState>({
  276. ...settled,
  277. baseURL: field(''),
  278. maxUses: field('5'),
  279. apiKey: field(''),
  280. apiKeyConfigured: false,
  281. apiKeyWritable: true,
  282. ...state,
  283. })
  284. const actions = cardActions()
  285. const props = { ...actions, t, useWebSearchCard: bindSnapshotSelector(store) } as unknown as WebSearchCardProps
  286. render(<WebSearchCard {...props} />)
  287. return actions
  288. }
  289. it('reports whether a key is configured without ever showing one', () => {
  290. renderWebSearch({ apiKeyConfigured: true })
  291. fireEvent.click(screen.getByText(en.webSearchTitle))
  292. expect(screen.getByText(en.webSearchApiKeySet)).toBeTruthy()
  293. expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('type', 'password')
  294. })
  295. it('keeps the key control usable while the settings document is read-only', () => {
  296. const actions = renderWebSearch({ writable: false })
  297. fireEvent.click(screen.getByText(en.webSearchTitle))
  298. const key = screen.getByLabelText(en.webSearchApiKey)
  299. expect(key).toHaveProperty('disabled', false)
  300. expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', true)
  301. fireEvent.change(key, { target: { value: 'ds-secret' } })
  302. expect(actions.edit).toHaveBeenCalledWith('apiKey', 'ds-secret')
  303. })
  304. it('disables the key control when the reference itself is not writable', () => {
  305. // A key coming from the process environment: the settings document is
  306. // writable, the credential is not.
  307. renderWebSearch({ apiKeyConfigured: true, apiKeyWritable: false })
  308. fireEvent.click(screen.getByText(en.webSearchTitle))
  309. expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('disabled', true)
  310. expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', false)
  311. })
  312. it('stages the endpoint, the search budget, and their resets', () => {
  313. const actions = renderWebSearch({
  314. baseURL: field('https://search.test/v1', { overridden: true }),
  315. maxUses: field('3', { overridden: true }),
  316. })
  317. fireEvent.click(screen.getByText(en.webSearchTitle))
  318. fireEvent.change(screen.getByLabelText(en.webSearchBaseUrl), { target: { value: 'https://other.test' } })
  319. fireEvent.change(screen.getByLabelText(en.webSearchMaxUses), { target: { value: '4' } })
  320. const resets = screen.getAllByRole('button', { name: en.reset })
  321. expect(resets).toHaveLength(2)
  322. for (const reset of resets) fireEvent.click(reset)
  323. expect(actions.edit.mock.calls).toEqual([
  324. ['baseURL', 'https://other.test'],
  325. ['maxUses', '4'],
  326. ])
  327. expect(actions.resetField.mock.calls).toEqual([['baseURL'], ['maxUses']])
  328. })
  329. })