section.client.spec.tsx 15 KB

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