section.client.spec.tsx 26 KB

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