section.client.spec.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571
  1. // @vitest-environment jsdom
  2. import { act, 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 { SubagentModelSelectionCard } from '../src/client/SubagentModelSelectionCard.tsx'
  15. import type { SubagentModelSelectionCardProps } from '../src/client/SubagentModelSelectionCard.tsx'
  16. import { WebSearchCard } from '../src/client/WebSearchCard.tsx'
  17. import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx'
  18. import type { AgentLoopCardState } from '../src/client/agent-loop-card-controller.ts'
  19. import type { BashCardState } from '../src/client/bash-card-controller.ts'
  20. import type { CardFieldState, CardShell } from '../src/client/card-form.ts'
  21. import type { ConfigurablePluginsTabState } from '../src/client/tab-store.ts'
  22. import type { WebSearchCardState } from '../src/client/web-search-card-controller.ts'
  23. import type { SubagentModelSelectionCardState } from '../src/client/subagent-model-selection-card-controller.ts'
  24. import { en } from '../src/client/locales.ts'
  25. afterEach(cleanup)
  26. const t = (key: keyof typeof en) => en[key]
  27. const settled: CardShell = {
  28. available: true,
  29. writable: true,
  30. dirty: false,
  31. invalid: false,
  32. saving: false,
  33. failed: false,
  34. }
  35. function field(text: string, rest: Partial<CardFieldState> = {}): CardFieldState {
  36. return { text, overridden: false, invalid: false, ...rest }
  37. }
  38. function cardActions() {
  39. return { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() }
  40. }
  41. function renderSection(rows: readonly PluginsSettingsTabEntry[]) {
  42. const props = {
  43. t,
  44. useTabs: (selector: (value: readonly PluginsSettingsTabEntry[]) => unknown) => selector(rows),
  45. renderSlot: (_name: string, _owner: unknown, options: { only?: string }) => (
  46. <span>{options.only}</span>
  47. ),
  48. } as unknown as PluginsSettingsSectionProps
  49. render(<PluginsSettingsSection {...props} />)
  50. }
  51. function renderConfigurable(namespaces: string[], cards: Record<string, string> = {}, loaded = true) {
  52. const store = createSnapshotStore<ConfigurablePluginsTabState>({ loaded, namespaces })
  53. const props = {
  54. t,
  55. useConfigurablePlugins: bindSnapshotSelector(store),
  56. renderSlot: (_name: string, _owner: object, opts?: { entryKey?: string }) => {
  57. const card = opts?.entryKey === undefined ? undefined : cards[opts.entryKey]
  58. return card === undefined ? null : <li>{card}</li>
  59. },
  60. } as unknown as ConfigurablePluginsTabProps
  61. render(<ConfigurablePluginsTab {...props} />)
  62. }
  63. function renderBashCard(state: Partial<BashCardState> = {}) {
  64. const store = createSnapshotStore<BashCardState>({
  65. ...settled,
  66. timeoutMs: field('60000'),
  67. maxOutputBytes: field('64000'),
  68. ...state,
  69. })
  70. const actions = cardActions()
  71. const props = { ...actions, t, useBashCard: bindSnapshotSelector(store) } as unknown as BashCardProps
  72. render(<BashCard {...props} />)
  73. return { actions, store }
  74. }
  75. function renderBash(state: Partial<BashCardState> = {}) {
  76. return renderBashCard(state).actions
  77. }
  78. function renderSubagentModelSelection(state: Partial<SubagentModelSelectionCardState> = {}) {
  79. const store = createSnapshotStore<SubagentModelSelectionCardState>({
  80. ...settled,
  81. enabled: false,
  82. candidates: [],
  83. catalogStatus: 'idle',
  84. catalogPartial: false,
  85. conflicted: false,
  86. ...state,
  87. })
  88. const actions = {
  89. toggleEnabled: vi.fn(),
  90. toggleModel: vi.fn(),
  91. retryCatalog: vi.fn(),
  92. save: vi.fn(),
  93. discard: vi.fn(),
  94. }
  95. const props = {
  96. ...actions,
  97. t,
  98. useSubagentModelSelectionCard: bindSnapshotSelector(store),
  99. } as unknown as SubagentModelSelectionCardProps
  100. render(<SubagentModelSelectionCard {...props} />)
  101. return actions
  102. }
  103. describe('PluginsSettingsSection', () => {
  104. it('says so when no plugin contributed a tab', () => {
  105. renderSection([])
  106. expect(screen.getByText(en.empty)).toBeTruthy()
  107. expect(screen.queryByRole('tab')).toBeNull()
  108. })
  109. it('defaults to the first ordered tab and mounts another only after selection', () => {
  110. renderSection([
  111. { id: 'configurable', order: 0, label: en.configurableTab },
  112. { id: 'all', order: 10, label: 'Plugin list' },
  113. ])
  114. const configurable = screen.getByRole('tab', { name: en.configurableTab })
  115. const all = screen.getByRole('tab', { name: 'Plugin list' })
  116. expect(configurable.getAttribute('aria-selected')).toBe('true')
  117. expect(screen.getByText('configurable')).toBeTruthy()
  118. expect(screen.queryByText('all')).toBeNull()
  119. fireEvent.click(all)
  120. expect(all.getAttribute('aria-selected')).toBe('true')
  121. expect(screen.getByText('all')).toBeTruthy()
  122. expect(screen.getByText('configurable').closest('[role="tabpanel"]')).toHaveProperty('hidden', true)
  123. fireEvent.click(configurable)
  124. expect(configurable.getAttribute('aria-selected')).toBe('true')
  125. expect(screen.getByText('all').closest('[role="tabpanel"]')).toHaveProperty('hidden', true)
  126. })
  127. it('leads with its own heading and intro', () => {
  128. renderSection([{ id: 'configurable', order: 0, label: en.configurableTab }])
  129. expect(screen.getByRole('heading', { name: en.title })).toBeTruthy()
  130. expect(screen.getByText(en.intro)).toBeTruthy()
  131. })
  132. it('moves focus and selection with standard horizontal tab keys', () => {
  133. renderSection([
  134. { id: 'configurable', order: 0, label: en.configurableTab },
  135. { id: 'all', order: 10, label: 'Plugin list' },
  136. { id: 'diagnostics', order: 20, label: 'Diagnostics' },
  137. ])
  138. const configurable = screen.getByRole('tab', { name: en.configurableTab })
  139. const all = screen.getByRole('tab', { name: 'Plugin list' })
  140. const diagnostics = screen.getByRole('tab', { name: 'Diagnostics' })
  141. expect(configurable.getAttribute('tabindex')).toBe('0')
  142. expect(all.getAttribute('tabindex')).toBe('-1')
  143. configurable.focus()
  144. fireEvent.keyDown(configurable, { key: 'ArrowRight' })
  145. expect(document.activeElement).toBe(all)
  146. expect(all.getAttribute('aria-selected')).toBe('true')
  147. fireEvent.keyDown(all, { key: 'End' })
  148. expect(document.activeElement).toBe(diagnostics)
  149. fireEvent.keyDown(diagnostics, { key: 'ArrowRight' })
  150. expect(document.activeElement).toBe(configurable)
  151. fireEvent.keyDown(configurable, { key: 'ArrowLeft' })
  152. expect(document.activeElement).toBe(diagnostics)
  153. fireEvent.keyDown(diagnostics, { key: 'Home' })
  154. expect(document.activeElement).toBe(configurable)
  155. fireEvent.keyDown(configurable, { key: 'Escape' })
  156. expect(document.activeElement).toBe(configurable)
  157. expect(configurable.getAttribute('aria-selected')).toBe('true')
  158. })
  159. })
  160. describe('ConfigurablePluginsTab', () => {
  161. it('says so when no plugin contributed a card', () => {
  162. renderConfigurable([], { bash: 'shell' })
  163. expect(screen.getByText(en.empty)).toBeTruthy()
  164. expect(screen.queryByText('shell')).toBeNull()
  165. })
  166. it('withholds the empty line until the Host has answered once', () => {
  167. // An unanswered read is not the statement that this deployment configures
  168. // no plugin; saying it anyway would flash a wrong answer on every open.
  169. renderConfigurable([], { bash: 'shell' }, false)
  170. expect(screen.queryByText(en.empty)).toBeNull()
  171. })
  172. it('dispatches one card per namespace, keyed by it', () => {
  173. renderConfigurable(['bash', 'agent-loop'], { bash: 'shell', 'agent-loop': 'loop' })
  174. expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['shell', 'loop'])
  175. expect(screen.queryByText(en.empty)).toBeNull()
  176. })
  177. })
  178. describe('BashCard', () => {
  179. it('renders nothing while its namespace is unavailable', () => {
  180. const { container } = render(<div />)
  181. renderBash({ available: false })
  182. expect(container.textContent).toBe('')
  183. expect(screen.queryByText(en.bashTitle)).toBeNull()
  184. })
  185. it('shows the plugin and reveals its fields only once expanded', () => {
  186. renderBash()
  187. expect(screen.getByText(en.bashTitle)).toBeTruthy()
  188. expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull()
  189. fireEvent.click(screen.getByText(en.bashTitle))
  190. expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy()
  191. expect(screen.getByLabelText(en.bashMaxOutputBytes)).toBeTruthy()
  192. })
  193. it('stages an edit instead of writing it', () => {
  194. const actions = renderBash()
  195. fireEvent.click(screen.getByText(en.bashTitle))
  196. fireEvent.change(screen.getByLabelText(en.bashTimeoutMs), { target: { value: '9000' } })
  197. expect(actions.edit).toHaveBeenCalledWith('timeoutMs', '9000')
  198. expect(actions.save).not.toHaveBeenCalled()
  199. })
  200. it('offers the reset for an overridden field only', () => {
  201. const actions = renderBash({ timeoutMs: field('9000', { overridden: true }) })
  202. fireEvent.click(screen.getByText(en.bashTitle))
  203. // One badge and one reset: the output cap is still inherited.
  204. expect(screen.getAllByText(en.overridden)).toHaveLength(1)
  205. fireEvent.click(screen.getByRole('button', { name: en.reset }))
  206. expect(actions.resetField).toHaveBeenCalledWith('timeoutMs')
  207. })
  208. it('addresses each of its two fields separately', () => {
  209. const actions = renderBash({ maxOutputBytes: field('64000', { overridden: true }) })
  210. fireEvent.click(screen.getByText(en.bashTitle))
  211. fireEvent.change(screen.getByLabelText(en.bashMaxOutputBytes), { target: { value: '1024' } })
  212. fireEvent.click(screen.getByRole('button', { name: en.reset }))
  213. expect(actions.edit).toHaveBeenCalledWith('maxOutputBytes', '1024')
  214. expect(actions.resetField).toHaveBeenCalledWith('maxOutputBytes')
  215. })
  216. it('keeps save and discard inert until something is staged', () => {
  217. renderBash()
  218. fireEvent.click(screen.getByText(en.bashTitle))
  219. expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true)
  220. expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', true)
  221. expect(screen.queryByText(en.unsaved)).toBeNull()
  222. })
  223. it('writes the staged edits when saved, and drops them when discarded', () => {
  224. const actions = renderBash({ dirty: true, timeoutMs: field('9000', { overridden: true }) })
  225. fireEvent.click(screen.getByText(en.bashTitle))
  226. fireEvent.click(screen.getByRole('button', { name: en.save }))
  227. fireEvent.click(screen.getByRole('button', { name: en.discard }))
  228. expect(actions.save).toHaveBeenCalledOnce()
  229. expect(actions.discard).toHaveBeenCalledOnce()
  230. })
  231. it('marks a card holding unsaved edits, collapsed or not', () => {
  232. renderBash({ dirty: true })
  233. expect(screen.getByText(en.unsaved)).toBeTruthy()
  234. })
  235. it('blocks the save while a draft is invalid, and says why', () => {
  236. renderBash({ dirty: true, invalid: true, timeoutMs: field('soon', { invalid: true }) })
  237. fireEvent.click(screen.getByText(en.bashTitle))
  238. expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true)
  239. expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', false)
  240. expect(screen.getByText(en.invalidNumber)).toBeTruthy()
  241. })
  242. it('reports a save in flight and refuses another', () => {
  243. renderBash({ dirty: true, saving: true })
  244. fireEvent.click(screen.getByText(en.bashTitle))
  245. expect(screen.getByRole('button', { name: en.saving })).toHaveProperty('disabled', true)
  246. expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', true)
  247. })
  248. it('reports a save the deployment did not accept', () => {
  249. renderBash({ dirty: true, failed: true })
  250. fireEvent.click(screen.getByText(en.bashTitle))
  251. expect(screen.getByText(en.saveFailed)).toBeTruthy()
  252. })
  253. it('says the document is read-only and disables its controls', () => {
  254. renderBash({ writable: false })
  255. fireEvent.click(screen.getByText(en.bashTitle))
  256. expect(screen.getByRole('status')).toHaveProperty('textContent', en.readOnly)
  257. expect(screen.getByLabelText(en.bashTimeoutMs)).toHaveProperty('disabled', true)
  258. })
  259. it('collapses again on a second click', () => {
  260. renderBash()
  261. fireEvent.click(screen.getByText(en.bashTitle))
  262. expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy()
  263. fireEvent.click(screen.getByText(en.bashTitle))
  264. expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull()
  265. })
  266. it('collapses after a successful save settles', () => {
  267. const { actions, store } = renderBashCard({ dirty: true })
  268. fireEvent.click(screen.getByText(en.bashTitle))
  269. fireEvent.click(screen.getByRole('button', { name: en.save }))
  270. expect(actions.save).toHaveBeenCalledOnce()
  271. act(() => { store.set({ ...store.getSnapshot(), saving: true }) })
  272. act(() => { store.set({ ...store.getSnapshot(), dirty: false, saving: false }) })
  273. expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull()
  274. })
  275. it('keeps a failed save open', () => {
  276. const { store } = renderBashCard({ dirty: true })
  277. fireEvent.click(screen.getByText(en.bashTitle))
  278. fireEvent.click(screen.getByRole('button', { name: en.save }))
  279. act(() => { store.set({ ...store.getSnapshot(), saving: true }) })
  280. act(() => { store.set({ ...store.getSnapshot(), failed: true, saving: false }) })
  281. expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy()
  282. expect(screen.getByText(en.saveFailed)).toBeTruthy()
  283. })
  284. })
  285. describe('SubagentModelSelectionCard', () => {
  286. it('renders the default-off preference in its staged plugin card', () => {
  287. const actions = renderSubagentModelSelection()
  288. fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
  289. const control = screen.getByRole('switch', { name: en.subagentModelSelectionToggle })
  290. expect(control.getAttribute('aria-checked')).toBe('false')
  291. fireEvent.click(control)
  292. expect(actions.toggleEnabled).toHaveBeenCalledOnce()
  293. })
  294. it('groups available adapter candidates by provider', () => {
  295. const actions = renderSubagentModelSelection({
  296. enabled: true,
  297. candidates: [
  298. {
  299. key: 'alpha\0fast',
  300. provider: 'alpha',
  301. model: 'fast',
  302. providerName: 'Alpha API',
  303. modelName: 'Fast',
  304. available: true,
  305. selected: true,
  306. },
  307. {
  308. key: 'alpha\0deep',
  309. provider: 'alpha',
  310. model: 'deep',
  311. providerName: 'Alpha API',
  312. modelName: 'Deep',
  313. available: true,
  314. selected: false,
  315. },
  316. ],
  317. catalogStatus: 'ready',
  318. })
  319. fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
  320. expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('true')
  321. expect(screen.getByText('Alpha API', { exact: true })).toBeTruthy()
  322. fireEvent.click(screen.getByRole('checkbox', { name: /Fast/ }))
  323. fireEvent.click(screen.getByRole('checkbox', { name: /Deep/ }))
  324. expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0fast')
  325. expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0deep')
  326. })
  327. it('renders directory progress, failures, unavailable routes, and validation', () => {
  328. renderSubagentModelSelection({ enabled: true, catalogStatus: 'loading', invalid: true })
  329. fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
  330. expect(screen.getByText(en.subagentModelSelectionLoading)).toBeTruthy()
  331. expect(screen.getByText(en.subagentModelSelectionRequired)).toBeTruthy()
  332. cleanup()
  333. const errorActions = renderSubagentModelSelection({ enabled: true, catalogStatus: 'error' })
  334. fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
  335. fireEvent.click(screen.getByRole('button', { name: en.subagentModelSelectionRetry }))
  336. expect(errorActions.retryCatalog).toHaveBeenCalledOnce()
  337. cleanup()
  338. renderSubagentModelSelection({
  339. enabled: true,
  340. catalogStatus: 'ready',
  341. catalogPartial: true,
  342. candidates: [{
  343. key: 'legacy\0old',
  344. provider: 'legacy',
  345. model: 'old',
  346. providerName: 'legacy',
  347. modelName: 'old',
  348. available: false,
  349. selected: true,
  350. }],
  351. })
  352. fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
  353. expect(screen.getByText(en.subagentModelSelectionPartial)).toBeTruthy()
  354. expect(screen.getByText(en.subagentModelSelectionUnavailable)).toBeTruthy()
  355. expect(screen.getByText(en.subagentModelSelectionUnavailableGroup)).toBeTruthy()
  356. cleanup()
  357. renderSubagentModelSelection({ enabled: true, catalogStatus: 'ready' })
  358. fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
  359. expect(screen.getByText(en.subagentModelSelectionEmpty)).toBeTruthy()
  360. })
  361. it('distinguishes a stale draft from a rejected save', () => {
  362. renderSubagentModelSelection({ dirty: true, conflicted: true })
  363. fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
  364. expect(screen.getByText(en.subagentModelSelectionConflict)).toBeTruthy()
  365. expect(screen.queryByText(en.saveFailed)).toBeNull()
  366. })
  367. it('stays hidden when unavailable and disables writes when read-only', () => {
  368. renderSubagentModelSelection({ available: false })
  369. expect(screen.queryByText(en.subagentModelSelectionTitle)).toBeNull()
  370. cleanup()
  371. const actions = renderSubagentModelSelection({ writable: false })
  372. fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
  373. const control = screen.getByRole('switch') as HTMLButtonElement
  374. expect(control.disabled).toBe(true)
  375. fireEvent.click(control)
  376. expect(actions.toggleEnabled).not.toHaveBeenCalled()
  377. })
  378. })
  379. describe('AgentLoopCard', () => {
  380. it('stages and saves the only field it owns', () => {
  381. const store = createSnapshotStore<AgentLoopCardState>({
  382. ...settled,
  383. dirty: true,
  384. maxParallelToolCalls: field('10'),
  385. })
  386. const actions = cardActions()
  387. const props = {
  388. ...actions,
  389. t,
  390. useAgentLoopCard: bindSnapshotSelector(store),
  391. } as unknown as AgentLoopCardProps
  392. render(<AgentLoopCard {...props} />)
  393. fireEvent.click(screen.getByText(en.agentLoopTitle))
  394. fireEvent.change(screen.getByLabelText(en.agentLoopMaxParallel), { target: { value: '2' } })
  395. fireEvent.click(screen.getByRole('button', { name: en.save }))
  396. expect(actions.edit).toHaveBeenCalledWith('maxParallelToolCalls', '2')
  397. expect(actions.save).toHaveBeenCalledOnce()
  398. })
  399. it('stages a reset for the field it owns', () => {
  400. const store = createSnapshotStore<AgentLoopCardState>({
  401. ...settled,
  402. maxParallelToolCalls: field('2', { overridden: true }),
  403. })
  404. const actions = cardActions()
  405. const props = {
  406. ...actions,
  407. t,
  408. useAgentLoopCard: bindSnapshotSelector(store),
  409. } as unknown as AgentLoopCardProps
  410. render(<AgentLoopCard {...props} />)
  411. fireEvent.click(screen.getByText(en.agentLoopTitle))
  412. fireEvent.click(screen.getByRole('button', { name: en.reset }))
  413. expect(actions.resetField).toHaveBeenCalledWith('maxParallelToolCalls')
  414. })
  415. })
  416. describe('WebSearchCard', () => {
  417. function renderWebSearch(state: Partial<WebSearchCardState> = {}) {
  418. const store = createSnapshotStore<WebSearchCardState>({
  419. ...settled,
  420. baseURL: field(''),
  421. maxUses: field('5'),
  422. apiKey: field(''),
  423. apiKeyConfigured: false,
  424. apiKeyWritable: true,
  425. ...state,
  426. })
  427. const actions = cardActions()
  428. const props = { ...actions, t, useWebSearchCard: bindSnapshotSelector(store) } as unknown as WebSearchCardProps
  429. render(<WebSearchCard {...props} />)
  430. return actions
  431. }
  432. it('reports whether a key is configured without ever showing one', () => {
  433. renderWebSearch({ apiKeyConfigured: true })
  434. fireEvent.click(screen.getByText(en.webSearchTitle))
  435. expect(screen.getByText(en.webSearchApiKeySet)).toBeTruthy()
  436. expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('type', 'password')
  437. })
  438. it('keeps the key control usable while the settings document is read-only', () => {
  439. const actions = renderWebSearch({ writable: false })
  440. fireEvent.click(screen.getByText(en.webSearchTitle))
  441. const key = screen.getByLabelText(en.webSearchApiKey)
  442. expect(key).toHaveProperty('disabled', false)
  443. expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', true)
  444. fireEvent.change(key, { target: { value: 'ds-secret' } })
  445. expect(actions.edit).toHaveBeenCalledWith('apiKey', 'ds-secret')
  446. })
  447. it('disables the key control when the reference itself is not writable', () => {
  448. // A key coming from the process environment: the settings document is
  449. // writable, the credential is not.
  450. renderWebSearch({ apiKeyConfigured: true, apiKeyWritable: false })
  451. fireEvent.click(screen.getByText(en.webSearchTitle))
  452. expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('disabled', true)
  453. expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', false)
  454. })
  455. it('stages the endpoint, the search budget, and their resets', () => {
  456. const actions = renderWebSearch({
  457. baseURL: field('https://search.test/v1', { overridden: true }),
  458. maxUses: field('3', { overridden: true }),
  459. })
  460. fireEvent.click(screen.getByText(en.webSearchTitle))
  461. fireEvent.change(screen.getByLabelText(en.webSearchBaseUrl), { target: { value: 'https://other.test' } })
  462. fireEvent.change(screen.getByLabelText(en.webSearchMaxUses), { target: { value: '4' } })
  463. const resets = screen.getAllByRole('button', { name: en.reset })
  464. expect(resets).toHaveLength(2)
  465. for (const reset of resets) fireEvent.click(reset)
  466. expect(actions.edit.mock.calls).toEqual([
  467. ['baseURL', 'https://other.test'],
  468. ['maxUses', '4'],
  469. ])
  470. expect(actions.resetField.mock.calls).toEqual([['baseURL'], ['maxUses']])
  471. })
  472. })