section.spec.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  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 { PluginConfigSection } from '../src/client/PluginConfigSection.tsx'
  16. import type { PluginConfigSectionProps } from '../src/client/PluginConfigSection.tsx'
  17. import { WebSearchCard } from '../src/client/WebSearchCard.tsx'
  18. import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx'
  19. import type { AgentLoopCardState } from '../src/client/agent-loop-store.ts'
  20. import type { BashCardState } from '../src/client/bash-store.ts'
  21. import type { CardFieldState, CardShell } from '../src/client/card-store.ts'
  22. import type { WebSearchCardState } from '../src/client/web-search-store.ts'
  23. import { en } from '../src/client/locales.ts'
  24. afterEach(cleanup)
  25. const t = (key: keyof typeof en) => en[key]
  26. /** A settled form: nothing staged, everything served. */
  27. const settled: CardShell = {
  28. available: true,
  29. writable: true,
  30. dirty: false,
  31. invalid: false,
  32. saving: false,
  33. failed: false,
  34. }
  35. /** One control's state, defaulting to an inherited value. */
  36. function field(text: string, rest: Partial<CardFieldState> = {}): CardFieldState {
  37. return { text, overridden: false, invalid: false, ...rest }
  38. }
  39. function cardActions() {
  40. return { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() }
  41. }
  42. function renderSection(cardCount: number, cards = 'cards') {
  43. const props = {
  44. t,
  45. cardCount,
  46. renderSlot: () => <li>{cards}</li>,
  47. } as unknown as PluginConfigSectionProps
  48. render(<PluginConfigSection {...props} />)
  49. }
  50. function renderBash(state: Partial<BashCardState> = {}) {
  51. const store = createSnapshotStore<BashCardState>({
  52. ...settled,
  53. timeoutMs: field('60000'),
  54. maxOutputBytes: field('64000'),
  55. ...state,
  56. })
  57. const actions = cardActions()
  58. const props = { ...actions, t, useBashCard: bindSnapshotSelector(store) } as unknown as BashCardProps
  59. render(<BashCard {...props} />)
  60. return actions
  61. }
  62. describe('PluginConfigSection', () => {
  63. it('says so when no plugin contributed a card', () => {
  64. renderSection(0)
  65. expect(screen.getByText(en.empty)).toBeTruthy()
  66. expect(screen.queryByText('cards')).toBeNull()
  67. })
  68. it('renders the card list once a plugin contributed one', () => {
  69. renderSection(1)
  70. expect(screen.getByText('cards')).toBeTruthy()
  71. expect(screen.queryByText(en.empty)).toBeNull()
  72. })
  73. it('leads with its own heading and intro', () => {
  74. renderSection(1)
  75. expect(screen.getByRole('heading', { name: en.title })).toBeTruthy()
  76. expect(screen.getByText(en.intro)).toBeTruthy()
  77. })
  78. })
  79. describe('BashCard', () => {
  80. it('renders nothing while its namespace is unavailable', () => {
  81. const { container } = render(<div />)
  82. renderBash({ available: false })
  83. expect(container.textContent).toBe('')
  84. expect(screen.queryByText(en.bashTitle)).toBeNull()
  85. })
  86. it('shows the plugin and reveals its fields only once expanded', () => {
  87. renderBash()
  88. expect(screen.getByText(en.bashTitle)).toBeTruthy()
  89. expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull()
  90. fireEvent.click(screen.getByText(en.bashTitle))
  91. expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy()
  92. expect(screen.getByLabelText(en.bashMaxOutputBytes)).toBeTruthy()
  93. })
  94. it('stages an edit instead of writing it', () => {
  95. const actions = renderBash()
  96. fireEvent.click(screen.getByText(en.bashTitle))
  97. fireEvent.change(screen.getByLabelText(en.bashTimeoutMs), { target: { value: '9000' } })
  98. expect(actions.edit).toHaveBeenCalledWith('timeoutMs', '9000')
  99. expect(actions.save).not.toHaveBeenCalled()
  100. })
  101. it('offers the reset for an overridden field only', () => {
  102. const actions = renderBash({ timeoutMs: field('9000', { overridden: true }) })
  103. fireEvent.click(screen.getByText(en.bashTitle))
  104. // One badge and one reset: the output cap is still inherited.
  105. expect(screen.getAllByText(en.overridden)).toHaveLength(1)
  106. fireEvent.click(screen.getByRole('button', { name: en.reset }))
  107. expect(actions.resetField).toHaveBeenCalledWith('timeoutMs')
  108. })
  109. it('addresses each of its two fields separately', () => {
  110. const actions = renderBash({ maxOutputBytes: field('64000', { overridden: true }) })
  111. fireEvent.click(screen.getByText(en.bashTitle))
  112. fireEvent.change(screen.getByLabelText(en.bashMaxOutputBytes), { target: { value: '1024' } })
  113. fireEvent.click(screen.getByRole('button', { name: en.reset }))
  114. expect(actions.edit).toHaveBeenCalledWith('maxOutputBytes', '1024')
  115. expect(actions.resetField).toHaveBeenCalledWith('maxOutputBytes')
  116. })
  117. it('keeps save and discard inert until something is staged', () => {
  118. renderBash()
  119. fireEvent.click(screen.getByText(en.bashTitle))
  120. expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true)
  121. expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', true)
  122. expect(screen.queryByText(en.unsaved)).toBeNull()
  123. })
  124. it('writes the staged edits when saved, and drops them when discarded', () => {
  125. const actions = renderBash({ dirty: true, timeoutMs: field('9000', { overridden: true }) })
  126. fireEvent.click(screen.getByText(en.bashTitle))
  127. fireEvent.click(screen.getByRole('button', { name: en.save }))
  128. fireEvent.click(screen.getByRole('button', { name: en.discard }))
  129. expect(actions.save).toHaveBeenCalledOnce()
  130. expect(actions.discard).toHaveBeenCalledOnce()
  131. })
  132. it('marks a card holding unsaved edits, collapsed or not', () => {
  133. renderBash({ dirty: true })
  134. expect(screen.getByText(en.unsaved)).toBeTruthy()
  135. })
  136. it('blocks the save while a draft is invalid, and says why', () => {
  137. renderBash({ dirty: true, invalid: true, timeoutMs: field('soon', { invalid: true }) })
  138. fireEvent.click(screen.getByText(en.bashTitle))
  139. expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true)
  140. expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', false)
  141. expect(screen.getByText(en.invalidNumber)).toBeTruthy()
  142. })
  143. it('reports a save in flight and refuses another', () => {
  144. renderBash({ dirty: true, saving: true })
  145. fireEvent.click(screen.getByText(en.bashTitle))
  146. expect(screen.getByRole('button', { name: en.saving })).toHaveProperty('disabled', true)
  147. expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', true)
  148. })
  149. it('reports a save the deployment did not accept', () => {
  150. renderBash({ dirty: true, failed: true })
  151. fireEvent.click(screen.getByText(en.bashTitle))
  152. expect(screen.getByText(en.saveFailed)).toBeTruthy()
  153. })
  154. it('says the document is read-only and disables its controls', () => {
  155. renderBash({ writable: false })
  156. fireEvent.click(screen.getByText(en.bashTitle))
  157. expect(screen.getByRole('status')).toHaveProperty('textContent', en.readOnly)
  158. expect(screen.getByLabelText(en.bashTimeoutMs)).toHaveProperty('disabled', true)
  159. })
  160. it('collapses again on a second click', () => {
  161. renderBash()
  162. fireEvent.click(screen.getByText(en.bashTitle))
  163. expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy()
  164. fireEvent.click(screen.getByText(en.bashTitle))
  165. expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull()
  166. })
  167. })
  168. describe('AgentLoopCard', () => {
  169. it('stages and saves the only field it owns', () => {
  170. const store = createSnapshotStore<AgentLoopCardState>({
  171. ...settled,
  172. dirty: true,
  173. maxParallelToolCalls: field('10'),
  174. })
  175. const actions = cardActions()
  176. const props = {
  177. ...actions,
  178. t,
  179. useAgentLoopCard: bindSnapshotSelector(store),
  180. } as unknown as AgentLoopCardProps
  181. render(<AgentLoopCard {...props} />)
  182. fireEvent.click(screen.getByText(en.agentLoopTitle))
  183. fireEvent.change(screen.getByLabelText(en.agentLoopMaxParallel), { target: { value: '2' } })
  184. fireEvent.click(screen.getByRole('button', { name: en.save }))
  185. expect(actions.edit).toHaveBeenCalledWith('maxParallelToolCalls', '2')
  186. expect(actions.save).toHaveBeenCalledOnce()
  187. })
  188. it('stages a reset for the field it owns', () => {
  189. const store = createSnapshotStore<AgentLoopCardState>({
  190. ...settled,
  191. maxParallelToolCalls: field('2', { overridden: true }),
  192. })
  193. const actions = cardActions()
  194. const props = {
  195. ...actions,
  196. t,
  197. useAgentLoopCard: bindSnapshotSelector(store),
  198. } as unknown as AgentLoopCardProps
  199. render(<AgentLoopCard {...props} />)
  200. fireEvent.click(screen.getByText(en.agentLoopTitle))
  201. fireEvent.click(screen.getByRole('button', { name: en.reset }))
  202. expect(actions.resetField).toHaveBeenCalledWith('maxParallelToolCalls')
  203. })
  204. })
  205. describe('WebSearchCard', () => {
  206. function renderWebSearch(state: Partial<WebSearchCardState> = {}) {
  207. const store = createSnapshotStore<WebSearchCardState>({
  208. ...settled,
  209. baseURL: field(''),
  210. maxUses: field('5'),
  211. apiKey: field(''),
  212. apiKeyConfigured: false,
  213. apiKeyWritable: true,
  214. ...state,
  215. })
  216. const actions = cardActions()
  217. const props = { ...actions, t, useWebSearchCard: bindSnapshotSelector(store) } as unknown as WebSearchCardProps
  218. render(<WebSearchCard {...props} />)
  219. return actions
  220. }
  221. it('reports whether a key is configured without ever showing one', () => {
  222. renderWebSearch({ apiKeyConfigured: true })
  223. fireEvent.click(screen.getByText(en.webSearchTitle))
  224. expect(screen.getByText(en.webSearchApiKeySet)).toBeTruthy()
  225. expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('type', 'password')
  226. })
  227. it('keeps the key control usable while the settings document is read-only', () => {
  228. const actions = renderWebSearch({ writable: false })
  229. fireEvent.click(screen.getByText(en.webSearchTitle))
  230. const key = screen.getByLabelText(en.webSearchApiKey)
  231. expect(key).toHaveProperty('disabled', false)
  232. expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', true)
  233. fireEvent.change(key, { target: { value: 'ds-secret' } })
  234. expect(actions.edit).toHaveBeenCalledWith('apiKey', 'ds-secret')
  235. })
  236. it('disables the key control when the reference itself is not writable', () => {
  237. // A key coming from the process environment: the settings document is
  238. // writable, the credential is not.
  239. renderWebSearch({ apiKeyConfigured: true, apiKeyWritable: false })
  240. fireEvent.click(screen.getByText(en.webSearchTitle))
  241. expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('disabled', true)
  242. expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', false)
  243. })
  244. it('stages the endpoint, the search budget, and their resets', () => {
  245. const actions = renderWebSearch({
  246. baseURL: field('https://search.test/v1', { overridden: true }),
  247. maxUses: field('3', { overridden: true }),
  248. })
  249. fireEvent.click(screen.getByText(en.webSearchTitle))
  250. fireEvent.change(screen.getByLabelText(en.webSearchBaseUrl), { target: { value: 'https://other.test' } })
  251. fireEvent.change(screen.getByLabelText(en.webSearchMaxUses), { target: { value: '4' } })
  252. const resets = screen.getAllByRole('button', { name: en.reset })
  253. expect(resets).toHaveLength(2)
  254. for (const reset of resets) fireEvent.click(reset)
  255. expect(actions.edit.mock.calls).toEqual([
  256. ['baseURL', 'https://other.test'],
  257. ['maxUses', '4'],
  258. ])
  259. expect(actions.resetField.mock.calls).toEqual([['baseURL'], ['maxUses']])
  260. })
  261. })