components.client.spec.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. // @vitest-environment jsdom
  2. import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import { PluginInventorySettingsTab } from '../src/client/PluginInventorySettingsTab.tsx'
  5. import type {
  6. PluginInventorySettingsTabInjected,
  7. PluginInventorySettingsTabProps,
  8. } from '../src/client/PluginInventorySettingsTab.tsx'
  9. import { en, type PluginInventoryLocaleKey } from '../src/client/locales.ts'
  10. afterEach(cleanup)
  11. type Snapshot = Awaited<ReturnType<PluginInventorySettingsTabInjected['list']>>
  12. const t = ((key: PluginInventoryLocaleKey, params?: Record<string, string>): string =>
  13. Object.entries(params ?? {}).reduce(
  14. (text, [name, value]) => text.replaceAll(`{${name}}`, value),
  15. en[key],
  16. )) as PluginInventorySettingsTabProps['t']
  17. function props(
  18. list: PluginInventorySettingsTabInjected['list'],
  19. presetName: PluginInventorySettingsTabInjected['presetName'] = preset => preset.name ?? preset.id,
  20. ): PluginInventorySettingsTabProps {
  21. return {
  22. t,
  23. list,
  24. presetName,
  25. } as PluginInventorySettingsTabProps
  26. }
  27. /** A deployment with a roster: one failed global row, two preset-provided rows. */
  28. const SNAPSHOT = {
  29. entries: [
  30. { entryId: 'telemetry', moduleName: '@fixture/telemetry', enabled: true, fiberPhase: 'failed' },
  31. { entryId: 'timer', moduleName: 'cordis:timer', enabled: true, fiberPhase: 'active' },
  32. { entryId: '8a1b2c3d', moduleName: '@deepseek-ai/cordis-plugin-hmr', enabled: true, fiberPhase: 'active' },
  33. { entryId: 'unobserved', moduleName: '@fixture/unobserved-name', enabled: true, fiberPhase: null },
  34. { entryId: 'bash-host', moduleName: '@deepseek-ai/dsh-tool-bash', enabled: false, fiberPhase: null },
  35. { entryId: 'fs-host', moduleName: '@deepseek-ai/dsh-tool-fs', enabled: false, fiberPhase: null },
  36. { entryId: 'dormant', moduleName: '@fixture/dormant', enabled: false, fiberPhase: null },
  37. ],
  38. agentPresets: [
  39. {
  40. id: 'standard',
  41. trust: 'system',
  42. name: '标准模式',
  43. isDefault: true,
  44. rows: [
  45. { entryId: 'bash', moduleName: '@deepseek-ai/dsh-tool-bash', enabled: true, fiberPhase: 'active' },
  46. { entryId: 'fs', moduleName: '@deepseek-ai/dsh-tool-fs', enabled: true, fiberPhase: null },
  47. {
  48. entryId: 'pwsh',
  49. moduleName: '@fixture/pwsh',
  50. enabled: 'conditional',
  51. condition: 'process.platform === \'win32\'',
  52. fiberPhase: null,
  53. },
  54. { entryId: 'codex', moduleName: '@fixture/codex', enabled: false, fiberPhase: null },
  55. { entryId: 'crashy', moduleName: '@fixture/crashy', enabled: true, fiberPhase: 'failed' },
  56. { entryId: null, moduleName: '@fixture/anonymous', enabled: true, fiberPhase: null },
  57. ],
  58. },
  59. {
  60. id: 'ptc',
  61. trust: 'system',
  62. isDefault: false,
  63. rows: [
  64. { entryId: 'bash', moduleName: '@deepseek-ai/dsh-tool-bash', enabled: true, fiberPhase: null },
  65. { entryId: 'bash-fork', moduleName: '@deepseek-ai/dsh-tool-bash', enabled: true, fiberPhase: null },
  66. { entryId: 'fs', moduleName: '@deepseek-ai/dsh-tool-fs', enabled: 'conditional', fiberPhase: null },
  67. ],
  68. },
  69. { id: 'shattered', trust: 'user', name: '坏预设', isDefault: false, broken: 'the composition file is missing', rows: [] },
  70. ],
  71. } as unknown as Snapshot
  72. async function renderReady(snapshot: Snapshot = SNAPSHOT): Promise<ReturnType<typeof render>> {
  73. const view = render(<PluginInventorySettingsTab {...props(async () => snapshot)} />)
  74. await screen.findByRole('searchbox', { name: en.search })
  75. return view
  76. }
  77. const globalToggle = (): HTMLElement =>
  78. screen.getByRole('button', { name: (name: string) => name.startsWith(en.globalTitle) })
  79. describe('PluginInventorySettingsTab', () => {
  80. it('shows the default preset first and keeps the global plane collapsed', async () => {
  81. const view = await renderReady()
  82. const switcher = screen.getByRole('button', { name: en.switcherLabel })
  83. expect(switcher.textContent).toBe('标准模式 (default)')
  84. fireEvent.click(switcher)
  85. expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([
  86. '标准模式 (default)',
  87. 'ptc',
  88. '坏预设 (failed to load)',
  89. ])
  90. fireEvent.keyDown(document, { key: 'Escape' })
  91. expect(screen.queryAllByRole('menuitem')).toHaveLength(0)
  92. expect(screen.getByText(en.presetSubtitle)).toBeTruthy()
  93. expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('6')
  94. // Only the preset group lists rows while the global plane stays collapsed.
  95. expect(screen.getAllByRole('listitem')).toHaveLength(6)
  96. expect(screen.getAllByText(en.enabledTag)).toHaveLength(3)
  97. expect(screen.getByText(en.conditionalTag)).toBeTruthy()
  98. expect(screen.getByText(en.disabledTag)).toBeTruthy()
  99. expect(screen.getByText(en.failedTag)).toBeTruthy()
  100. expect(screen.getByRole('img', { name: 'Running' })).toBeTruthy()
  101. // No live fiber, no dot: file-state rows carry only their enablement tag.
  102. expect(screen.queryByRole('img', { name: 'Not running' })).toBeNull()
  103. expect(globalToggle().getAttribute('aria-expanded')).toBe('false')
  104. expect(view.container.querySelector('[data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('7')
  105. expect(screen.getByText(`1 ${en.failedCountLabel}`)).toBeTruthy()
  106. // A preset row expands into its provenance facts.
  107. fireEvent.click(screen.getByRole('button', { name: 'pwsh, pwsh, Conditional' }))
  108. expect(screen.getByText(en.fromPreset)).toBeTruthy()
  109. expect(screen.getByText('标准模式')).toBeTruthy()
  110. expect(screen.getByText(en.condition)).toBeTruthy()
  111. expect(screen.getByText('process.platform === \'win32\'')).toBeTruthy()
  112. fireEvent.click(screen.getByRole('button', { name: 'pwsh, pwsh, Conditional' }))
  113. expect(screen.queryByText(en.condition)).toBeNull()
  114. // A failed preset row names its runtime state instead of a condition.
  115. fireEvent.click(screen.getByRole('button', { name: 'crashy, crashy, Failed' }))
  116. expect(screen.getByText(en.runtime)).toBeTruthy()
  117. expect(screen.getByText('Failed to start')).toBeTruthy()
  118. // A row declaring no id has no Loader identity line, only its module.
  119. fireEvent.click(screen.getByRole('button', { name: 'anonymous, Enabled' }))
  120. expect(view.container.querySelector('[data-loader-entry]')).toBeNull()
  121. expect(screen.getByText(en.moduleLabel).nextElementSibling?.textContent).toBe('@fixture/anonymous')
  122. })
  123. it('distinguishes collapsed same-module rows by stable entry id', async () => {
  124. const longId = 'include:agent-presets:tool-subagent-secondary-with-a-complete-stable-identity'
  125. const subtitle = 'agent-presets:tool-subagent-secondary-with-a-complete-stable-identity'
  126. await renderReady({
  127. entries: [],
  128. agentPresets: [{
  129. id: 'same-module',
  130. trust: 'user',
  131. isDefault: true,
  132. rows: [
  133. { entryId: 'tool-subagent-primary', moduleName: '@deepseek-ai/dsh-tool-subagent', enabled: true, fiberPhase: null },
  134. { entryId: longId, moduleName: '@deepseek-ai/dsh-tool-subagent', enabled: false, fiberPhase: null },
  135. ],
  136. }],
  137. })
  138. expect(screen.getByRole('button', { name: 'tool-subagent, tool-subagent-primary, Enabled' })
  139. .getAttribute('aria-expanded')).toBe('false')
  140. const secondary = screen.getByRole('button', { name: `tool-subagent, ${longId}, Disabled` })
  141. expect(secondary.getAttribute('aria-expanded')).toBe('false')
  142. expect(secondary.children).toHaveLength(2)
  143. expect(secondary.children[0]?.textContent).toContain('tool-subagent')
  144. expect(secondary.children[0]?.textContent).toContain('Disabled')
  145. expect(secondary.children[1]?.textContent).toBe(subtitle)
  146. expect(screen.getByTitle(longId).textContent).toBe(subtitle)
  147. fireEvent.change(screen.getByRole('searchbox', { name: en.search }), { target: { value: 'include:agent-presets:tool-subagent-secondary' } })
  148. expect(screen.queryByRole('button', { name: 'tool-subagent, tool-subagent-primary, Enabled' })).toBeNull()
  149. const filteredSecondary = screen.getByRole('button', { name: `tool-subagent, ${longId}, Disabled` })
  150. fireEvent.click(filteredSecondary)
  151. expect(filteredSecondary.getAttribute('aria-expanded')).toBe('true')
  152. })
  153. it('expands the global plane with failures first and preset-provided rows inline', async () => {
  154. const view = await renderReady()
  155. expect(screen.queryByText(en.presetEnabledTag)).toBeNull()
  156. fireEvent.click(globalToggle())
  157. expect(globalToggle().getAttribute('aria-expanded')).toBe('true')
  158. const failed = view.container.querySelector('[data-plugin-scope="global"] [data-failed="true"]')
  159. expect(failed?.getAttribute('data-plugin-entry')).toBe('telemetry')
  160. // Failures float above the Loader-ordered remainder.
  161. expect(view.container.querySelector('[data-plugin-scope="global"] li')).toBe(failed)
  162. // Rows the presets took over sit inline, marked instead of plainly disabled.
  163. expect(screen.getAllByText(en.presetEnabledTag)).toHaveLength(2)
  164. fireEvent.click(screen.getByRole('button', { name: 'tool-bash, bash-host, Enabled via presets' }))
  165. expect(screen.getByText(en.presetProvidedDetail)).toBeTruthy()
  166. expect(screen.getByText(en.enabledIn)).toBeTruthy()
  167. expect(screen.getByText('标准模式 · ptc')).toBeTruthy()
  168. // The failed global card reports its runtime state.
  169. fireEvent.click(screen.getByRole('button', { name: 'telemetry, telemetry, Failed' }))
  170. expect(screen.getByText('Failed to start')).toBeTruthy()
  171. // An enabled entry with no live fiber says so in its details, dot-free.
  172. fireEvent.click(screen.getByRole('button', { name: 'unobserved-name, unobserved, Enabled' }))
  173. expect(screen.getByText('Not running')).toBeTruthy()
  174. // A disabled row outside every preset stays plainly disabled.
  175. fireEvent.click(screen.getByRole('button', { name: 'dormant, dormant, Disabled' }))
  176. expect(screen.queryByText(en.presetProvidedDetail)).toBeNull()
  177. fireEvent.click(globalToggle())
  178. expect(globalToggle().getAttribute('aria-expanded')).toBe('false')
  179. expect(screen.queryByText(en.presetEnabledTag)).toBeNull()
  180. })
  181. it('switches the inspected preset in place, including broken ones', async () => {
  182. const view = await renderReady()
  183. const pickPreset = (label: string): void => {
  184. fireEvent.click(screen.getByRole('button', { name: en.switcherLabel }))
  185. fireEvent.click(screen.getByRole('menuitem', { name: label }))
  186. }
  187. pickPreset('ptc')
  188. expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('3')
  189. fireEvent.click(screen.getByRole('button', { name: 'tool-bash, bash, Enabled' }))
  190. // An unnamed preset labels provenance by its id.
  191. expect(screen.getByText(en.fromPreset).nextElementSibling?.textContent).toBe('ptc')
  192. pickPreset('坏预设 (failed to load)')
  193. expect(screen.getByRole('alert').textContent).toBe('the composition file is missing')
  194. expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('0')
  195. })
  196. it('collapses the preset group until a search forces it open', async () => {
  197. const view = await renderReady()
  198. const toggle = screen.getByRole('button', { name: en.presetTitle })
  199. expect(toggle.getAttribute('aria-expanded')).toBe('true')
  200. fireEvent.click(toggle)
  201. expect(toggle.getAttribute('aria-expanded')).toBe('false')
  202. // The header keeps its count while the rows are folded away.
  203. expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('6')
  204. expect(view.container.querySelectorAll('[data-plugin-scope="preset"] li')).toHaveLength(0)
  205. fireEvent.change(screen.getByRole('searchbox', { name: en.search }), { target: { value: 'pwsh' } })
  206. expect(toggle.getAttribute('aria-expanded')).toBe('true')
  207. expect(screen.getByText(en.conditionalTag)).toBeTruthy()
  208. fireEvent.change(screen.getByRole('searchbox', { name: en.search }), { target: { value: '' } })
  209. expect(toggle.getAttribute('aria-expanded')).toBe('false')
  210. fireEvent.click(toggle)
  211. expect(toggle.getAttribute('aria-expanded')).toBe('true')
  212. })
  213. it('routes every preset name through the display resolver', async () => {
  214. // The resolver stands in for presetDisplayText: shipped presets localize,
  215. // user-authored ones keep their own metadata.
  216. const localized: PluginInventorySettingsTabInjected['presetName'] = preset =>
  217. preset.trust === 'system' ? `Localized ${preset.id}` : preset.name ?? preset.id
  218. render(<PluginInventorySettingsTab {...props(async () => SNAPSHOT, localized)} />)
  219. await screen.findByRole('searchbox', { name: en.search })
  220. const switcher = screen.getByRole('button', { name: en.switcherLabel })
  221. expect(switcher.textContent).toBe('Localized standard (default)')
  222. fireEvent.click(switcher)
  223. expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([
  224. 'Localized standard (default)',
  225. 'Localized ptc',
  226. '坏预设 (failed to load)',
  227. ])
  228. fireEvent.keyDown(document, { key: 'Escape' })
  229. fireEvent.click(screen.getByRole('button', { name: 'pwsh, pwsh, Conditional' }))
  230. expect(screen.getByText(en.fromPreset).nextElementSibling?.textContent).toBe('Localized standard')
  231. fireEvent.click(globalToggle())
  232. fireEvent.click(screen.getByRole('button', { name: 'tool-bash, bash-host, Enabled via presets' }))
  233. expect(screen.getByText('Localized standard · Localized ptc')).toBeTruthy()
  234. })
  235. it('jumps from a preset-provided row to the preset that enables it', async () => {
  236. await renderReady()
  237. fireEvent.click(screen.getByRole('button', { name: en.switcherLabel }))
  238. fireEvent.click(screen.getByRole('menuitem', { name: 'ptc' }))
  239. fireEvent.click(globalToggle())
  240. fireEvent.click(screen.getByRole('button', { name: 'tool-bash, bash-host, Enabled via presets' }))
  241. fireEvent.click(screen.getByRole('button', { name: en.viewInPreset }))
  242. expect(screen.getByRole('button', { name: en.switcherLabel }).textContent)
  243. .toBe('标准模式 (default)')
  244. })
  245. it('searches across scopes and points at matches in other presets', async () => {
  246. const view = await renderReady()
  247. const search = screen.getByRole('searchbox', { name: en.search })
  248. fireEvent.change(search, { target: { value: 'tool-bash' } })
  249. // Searching forces the collapsed global plane and drawer open.
  250. expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('1')
  251. expect(view.container.querySelector('[data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('1')
  252. expect(screen.getByText(en.presetEnabledTag)).toBeTruthy()
  253. expect(screen.queryByText(`1 ${en.failedCountLabel}`)).toBeNull()
  254. const hint = screen.getByText((text: string) => text.startsWith('2 more matches'))
  255. expect(hint).toBeTruthy()
  256. fireEvent.click(screen.getByRole('button', { name: 'ptc' }))
  257. expect(screen.getByRole('button', { name: en.switcherLabel }).textContent).toBe('ptc')
  258. // A match visible only in another preset keeps the pointer without rows.
  259. fireEvent.change(search, { target: { value: 'crashy' } })
  260. expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('0')
  261. expect(screen.getByText((text: string) => text.startsWith('1 more matches'))).toBeTruthy()
  262. expect(screen.queryByText(en.emptySearch)).toBeNull()
  263. // A match on a Loader entry id only reaches the global plane.
  264. fireEvent.change(search, { target: { value: '8a1b2c3d' } })
  265. expect(view.container.querySelector('[data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('1')
  266. expect(screen.queryByText((text: string) => text.includes('more matches'))).toBeNull()
  267. fireEvent.change(search, { target: { value: 'not-a-plugin' } })
  268. expect(screen.getByText(en.emptySearch)).toBeTruthy()
  269. expect(screen.queryAllByRole('listitem')).toHaveLength(0)
  270. })
  271. it('renders a rosterless deployment as one expanded global list', async () => {
  272. const view = await renderReady({
  273. entries: [
  274. { entryId: 'hmr', moduleName: '@deepseek-ai/cordis-plugin-hmr', enabled: true, fiberPhase: 'active' },
  275. { entryId: 'off', moduleName: '@fixture/off', enabled: false, fiberPhase: null },
  276. ],
  277. } as unknown as Snapshot)
  278. expect(screen.queryByRole('button', { name: en.switcherLabel })).toBeNull()
  279. expect(globalToggle().getAttribute('aria-expanded')).toBe('true')
  280. expect(screen.getAllByRole('listitem')).toHaveLength(2)
  281. fireEvent.click(screen.getByRole('button', { name: 'hmr, hmr, Enabled' }))
  282. expect(screen.getByText(en.runtime)).toBeTruthy()
  283. expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('hmr')
  284. fireEvent.click(screen.getByRole('button', { name: 'off, off, Disabled' }))
  285. expect(screen.getAllByText(en.moduleLabel).length).toBeGreaterThan(0)
  286. expect(screen.queryByText(en.runtime)).toBeNull()
  287. })
  288. it('renders a preset-only snapshot without the global section', async () => {
  289. await renderReady({
  290. entries: [],
  291. agentPresets: [{
  292. id: 'solo',
  293. trust: 'user',
  294. isDefault: false,
  295. rows: [{ entryId: 'one', moduleName: '@fixture/one', enabled: true, fiberPhase: null }],
  296. }],
  297. })
  298. expect(screen.queryByRole('button', { name: (name: string) => name.startsWith(en.globalTitle) })).toBeNull()
  299. expect(screen.queryByText(en.empty)).toBeNull()
  300. expect(screen.getAllByRole('listitem')).toHaveLength(1)
  301. })
  302. it('shows a generic failure and retries into the empty state', async () => {
  303. const list = vi.fn<PluginInventorySettingsTabInjected['list']>()
  304. .mockRejectedValueOnce(new Error('private transport detail'))
  305. .mockResolvedValueOnce({ entries: [] })
  306. render(<PluginInventorySettingsTab {...props(list)} />)
  307. expect((await screen.findByRole('alert')).textContent).toBe(en.error)
  308. expect(screen.queryByText('private transport detail')).toBeNull()
  309. fireEvent.click(screen.getByRole('button', { name: en.retry }))
  310. await waitFor(() => { expect(list).toHaveBeenCalledTimes(2) })
  311. expect(await screen.findByText(en.empty)).toBeTruthy()
  312. })
  313. it('contains a synchronous Remote failure and ignores a result after unmount', async () => {
  314. const syncFailure = vi.fn(() => { throw new Error('namespace unavailable') }) as PluginInventorySettingsTabInjected['list']
  315. const failed = render(<PluginInventorySettingsTab {...props(syncFailure)} />)
  316. expect((await screen.findByRole('alert')).textContent).toBe(en.error)
  317. failed.unmount()
  318. const deferred = Promise.withResolvers<Snapshot>()
  319. const pending = render(<PluginInventorySettingsTab {...props(() => deferred.promise)} />)
  320. expect(screen.getByText(en.loading)).toBeTruthy()
  321. pending.unmount()
  322. await act(async () => { deferred.resolve(SNAPSHOT) })
  323. const deferredFailure = Promise.withResolvers<Snapshot>()
  324. const pendingFailure = render(<PluginInventorySettingsTab {...props(() => deferredFailure.promise)} />)
  325. pendingFailure.unmount()
  326. await act(async () => { deferredFailure.reject(new Error('late failure')) })
  327. })
  328. })