components.client.spec.tsx 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
  1. // @vitest-environment jsdom
  2. import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
  3. import type { ClientEntryState } from '@deepseek-ai/dsh-client-modules/client'
  4. import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
  5. import { act, cleanup, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'
  6. import { afterEach, describe, expect, it, vi } from 'vitest'
  7. import { PluginInventorySettingsTab } from '../src/client/PluginInventorySettingsTab.tsx'
  8. import type {
  9. PluginInventorySettingsTabInjected,
  10. PluginInventorySettingsTabProps,
  11. } from '../src/client/PluginInventorySettingsTab.tsx'
  12. import { en, zh, type PluginInventoryLocaleKey } from '../src/client/locales.ts'
  13. import { BundleManager, usePluginManagement, type PluginManagement } from '../src/client/management.tsx'
  14. afterEach(cleanup)
  15. type Snapshot = Awaited<ReturnType<PluginInventorySettingsTabInjected['list']>>
  16. const t = ((key: PluginInventoryLocaleKey, params?: Record<string, string>): string =>
  17. Object.entries(params ?? {}).reduce(
  18. (text, [name, value]) => text.replaceAll(`{${name}}`, value),
  19. en[key],
  20. )) as PluginInventorySettingsTabProps['t']
  21. function props(
  22. list: PluginInventorySettingsTabInjected['list'],
  23. presetName: PluginInventorySettingsTabInjected['presetName'] = preset => preset.name ?? preset.id,
  24. ): PluginInventorySettingsTabProps {
  25. return {
  26. t,
  27. list,
  28. presetName,
  29. useClientSync: bindSnapshotSelector(createSnapshotStore<ClientEntryState>({ syncing: false, failures: [] })),
  30. retryClient: vi.fn(),
  31. } as PluginInventorySettingsTabProps
  32. }
  33. /** A deployment with a roster: one failed global row, two preset-provided rows. */
  34. const SNAPSHOT = {
  35. entries: [
  36. { entryId: 'telemetry', moduleName: '@fixture/telemetry', enabled: true, fiberPhase: 'failed' },
  37. { entryId: 'timer', moduleName: 'cordis:timer', enabled: true, fiberPhase: 'active' },
  38. { entryId: '8a1b2c3d', moduleName: '@deepseek-ai/dsh-hmr', enabled: true, fiberPhase: 'active' },
  39. { entryId: 'unobserved', moduleName: '@fixture/unobserved-name', enabled: true, fiberPhase: null },
  40. { entryId: 'bash-host', moduleName: '@deepseek-ai/dsh-tool-bash', enabled: false, fiberPhase: null },
  41. { entryId: 'fs-host', moduleName: '@deepseek-ai/dsh-tool-fs', enabled: false, fiberPhase: null },
  42. { entryId: 'dormant', moduleName: '@fixture/dormant', enabled: false, fiberPhase: null },
  43. ],
  44. agentPresets: [
  45. {
  46. id: 'standard',
  47. trust: 'system',
  48. name: '标准模式',
  49. isDefault: true,
  50. rows: [
  51. { entryId: 'bash', moduleName: '@deepseek-ai/dsh-tool-bash', enabled: true, fiberPhase: 'active' },
  52. { entryId: 'fs', moduleName: '@deepseek-ai/dsh-tool-fs', enabled: true, fiberPhase: null },
  53. {
  54. entryId: 'pwsh',
  55. moduleName: '@fixture/pwsh',
  56. enabled: 'conditional',
  57. condition: 'process.platform === \'win32\'',
  58. fiberPhase: null,
  59. },
  60. { entryId: 'codex', moduleName: '@fixture/codex', enabled: false, fiberPhase: null },
  61. { entryId: 'crashy', moduleName: '@fixture/crashy', enabled: true, fiberPhase: 'failed' },
  62. { entryId: null, moduleName: '@fixture/anonymous', enabled: true, fiberPhase: null },
  63. ],
  64. },
  65. {
  66. id: 'ptc',
  67. trust: 'system',
  68. isDefault: false,
  69. rows: [
  70. { entryId: 'bash', moduleName: '@deepseek-ai/dsh-tool-bash', enabled: true, fiberPhase: null },
  71. { entryId: 'bash-fork', moduleName: '@deepseek-ai/dsh-tool-bash', enabled: true, fiberPhase: null },
  72. { entryId: 'fs', moduleName: '@deepseek-ai/dsh-tool-fs', enabled: 'conditional', fiberPhase: null },
  73. ],
  74. },
  75. { id: 'shattered', trust: 'user', name: '坏预设', isDefault: false, broken: 'the composition file is missing', rows: [] },
  76. ],
  77. } as unknown as Snapshot
  78. async function renderReady(snapshot: Snapshot = SNAPSHOT): Promise<ReturnType<typeof render>> {
  79. const view = render(<PluginInventorySettingsTab {...props(async () => snapshot)} />)
  80. await screen.findByRole('searchbox', { name: en.search })
  81. return view
  82. }
  83. const globalToggle = (): HTMLElement =>
  84. screen.getByRole('button', { name: (name: string) => name.startsWith(en.globalTitle) })
  85. describe('PluginInventorySettingsTab', () => {
  86. it('shows the default preset first and keeps the global plane collapsed', async () => {
  87. const view = await renderReady()
  88. const switcher = screen.getByRole('button', { name: en.switcherLabel })
  89. expect(switcher.textContent).toBe('标准模式 (default)')
  90. fireEvent.click(switcher)
  91. expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([
  92. '标准模式 (default)',
  93. 'ptc',
  94. '坏预设 (failed to load)',
  95. ])
  96. fireEvent.keyDown(document, { key: 'Escape' })
  97. expect(screen.queryAllByRole('menuitem')).toHaveLength(0)
  98. expect(screen.getByText(en.presetSubtitle)).toBeTruthy()
  99. expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('6')
  100. // Only the preset group lists rows while the global plane stays collapsed.
  101. expect(screen.getAllByRole('listitem')).toHaveLength(6)
  102. expect(screen.getAllByText(en.enabledTag)).toHaveLength(3)
  103. expect(screen.getByText(en.conditionalTag)).toBeTruthy()
  104. expect(screen.getByText(en.disabledTag)).toBeTruthy()
  105. expect(screen.getByText(en.failedTag)).toBeTruthy()
  106. expect(screen.getByRole('img', { name: 'Running' })).toBeTruthy()
  107. // No live fiber, no dot: file-state rows carry only their enablement tag.
  108. expect(screen.queryByRole('img', { name: 'Not running' })).toBeNull()
  109. expect(globalToggle().getAttribute('aria-expanded')).toBe('false')
  110. expect(view.container.querySelector('[data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('7')
  111. expect(screen.getByText(`1 ${en.failedCountLabel}`)).toBeTruthy()
  112. // A preset row expands into its source facts.
  113. fireEvent.click(screen.getByRole('button', { name: 'pwsh, pwsh, Conditional' }))
  114. expect(screen.getByText(en.fromPreset)).toBeTruthy()
  115. expect(screen.getByText('标准模式')).toBeTruthy()
  116. expect(screen.getByText(en.condition)).toBeTruthy()
  117. expect(screen.getByText('process.platform === \'win32\'')).toBeTruthy()
  118. fireEvent.click(screen.getByRole('button', { name: 'pwsh, pwsh, Conditional' }))
  119. expect(screen.queryByText(en.condition)).toBeNull()
  120. // A failed preset row names its runtime state instead of a condition.
  121. fireEvent.click(screen.getByRole('button', { name: 'crashy, crashy, Failed' }))
  122. expect(screen.getByText(en.runtime)).toBeTruthy()
  123. expect(screen.getByText('Failed to start')).toBeTruthy()
  124. // A row declaring no id has no Loader identity line, only its module.
  125. fireEvent.click(screen.getByRole('button', { name: 'anonymous, Enabled' }))
  126. expect(view.container.querySelector('[data-loader-entry]')).toBeNull()
  127. expect(screen.getByText(en.moduleLabel).nextElementSibling?.textContent).toBe('@fixture/anonymous')
  128. })
  129. it('distinguishes collapsed same-module rows by stable entry id', async () => {
  130. const longId = 'include:agent-presets:tool-subagent-secondary-with-a-complete-stable-identity'
  131. const subtitle = 'agent-presets:tool-subagent-secondary-with-a-complete-stable-identity'
  132. await renderReady({
  133. entries: [],
  134. agentPresets: [{
  135. id: 'same-module',
  136. trust: 'user',
  137. isDefault: true,
  138. rows: [
  139. { entryId: 'tool-subagent-primary', moduleName: '@deepseek-ai/dsh-tool-subagent', enabled: true, fiberPhase: null },
  140. { entryId: longId, moduleName: '@deepseek-ai/dsh-tool-subagent', enabled: false, fiberPhase: null },
  141. ],
  142. }],
  143. })
  144. expect(screen.getByRole('button', { name: 'tool-subagent, tool-subagent-primary, Enabled' })
  145. .getAttribute('aria-expanded')).toBe('false')
  146. const secondary = screen.getByRole('button', { name: `tool-subagent, ${longId}, Disabled` })
  147. expect(secondary.getAttribute('aria-expanded')).toBe('false')
  148. expect(secondary.children).toHaveLength(2)
  149. expect(secondary.children[0]?.textContent).toContain('tool-subagent')
  150. expect(secondary.children[0]?.textContent).toContain('Disabled')
  151. expect(secondary.children[1]?.textContent).toBe(subtitle)
  152. expect(screen.getByTitle(longId).textContent).toBe(subtitle)
  153. fireEvent.change(screen.getByRole('searchbox', { name: en.search }), { target: { value: 'include:agent-presets:tool-subagent-secondary' } })
  154. expect(screen.queryByRole('button', { name: 'tool-subagent, tool-subagent-primary, Enabled' })).toBeNull()
  155. const filteredSecondary = screen.getByRole('button', { name: `tool-subagent, ${longId}, Disabled` })
  156. fireEvent.click(filteredSecondary)
  157. expect(filteredSecondary.getAttribute('aria-expanded')).toBe('true')
  158. })
  159. it('expands the global plane with failures first and preset-provided rows inline', async () => {
  160. const view = await renderReady()
  161. expect(screen.queryByText(en.presetEnabledTag)).toBeNull()
  162. fireEvent.click(globalToggle())
  163. expect(globalToggle().getAttribute('aria-expanded')).toBe('true')
  164. const failed = view.container.querySelector('[data-plugin-scope="global"] [data-failed="true"]')
  165. expect(failed?.getAttribute('data-plugin-entry')).toBe('telemetry')
  166. // Failures float above the Loader-ordered remainder.
  167. expect(view.container.querySelector('[data-plugin-scope="global"] li')).toBe(failed)
  168. // Rows the presets took over sit inline, marked instead of plainly disabled.
  169. expect(screen.getAllByText(en.presetEnabledTag)).toHaveLength(2)
  170. fireEvent.click(screen.getByRole('button', { name: 'tool-bash, bash-host, Enabled via presets' }))
  171. expect(screen.getByText(en.presetProvidedDetail)).toBeTruthy()
  172. expect(screen.getByText(en.enabledIn)).toBeTruthy()
  173. expect(screen.getByText('标准模式 · ptc')).toBeTruthy()
  174. // The failed global card reports its runtime state.
  175. fireEvent.click(screen.getByRole('button', { name: 'telemetry, telemetry, Failed' }))
  176. expect(screen.getByText('Failed to start')).toBeTruthy()
  177. // An enabled entry with no live fiber says so in its details, dot-free.
  178. fireEvent.click(screen.getByRole('button', { name: 'unobserved-name, unobserved, Enabled' }))
  179. expect(screen.getByText('Not running')).toBeTruthy()
  180. // A disabled row outside every preset stays plainly disabled.
  181. fireEvent.click(screen.getByRole('button', { name: 'dormant, dormant, Disabled' }))
  182. expect(screen.queryByText(en.presetProvidedDetail)).toBeNull()
  183. fireEvent.click(globalToggle())
  184. expect(globalToggle().getAttribute('aria-expanded')).toBe('false')
  185. expect(screen.queryByText(en.presetEnabledTag)).toBeNull()
  186. })
  187. it('switches the inspected preset in place, including broken ones', async () => {
  188. const view = await renderReady()
  189. const pickPreset = (label: string): void => {
  190. fireEvent.click(screen.getByRole('button', { name: en.switcherLabel }))
  191. fireEvent.click(screen.getByRole('menuitem', { name: label }))
  192. }
  193. pickPreset('ptc')
  194. expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('3')
  195. fireEvent.click(screen.getByRole('button', { name: 'tool-bash, bash, Enabled' }))
  196. // An unnamed preset labels its source by id.
  197. expect(screen.getByText(en.fromPreset).nextElementSibling?.textContent).toBe('ptc')
  198. pickPreset('坏预设 (failed to load)')
  199. expect(screen.getByRole('alert').textContent).toBe('the composition file is missing')
  200. expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('0')
  201. })
  202. it('collapses the preset group until a search forces it open', async () => {
  203. const view = await renderReady()
  204. const toggle = screen.getByRole('button', { name: en.presetTitle })
  205. expect(toggle.getAttribute('aria-expanded')).toBe('true')
  206. fireEvent.click(toggle)
  207. expect(toggle.getAttribute('aria-expanded')).toBe('false')
  208. // The header keeps its count while the rows are folded away.
  209. expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('6')
  210. expect(view.container.querySelectorAll('[data-plugin-scope="preset"] li')).toHaveLength(0)
  211. fireEvent.change(screen.getByRole('searchbox', { name: en.search }), { target: { value: 'pwsh' } })
  212. expect(toggle.getAttribute('aria-expanded')).toBe('true')
  213. expect(screen.getByText(en.conditionalTag)).toBeTruthy()
  214. fireEvent.change(screen.getByRole('searchbox', { name: en.search }), { target: { value: '' } })
  215. expect(toggle.getAttribute('aria-expanded')).toBe('false')
  216. fireEvent.click(toggle)
  217. expect(toggle.getAttribute('aria-expanded')).toBe('true')
  218. })
  219. it('routes every preset name through the display resolver', async () => {
  220. // The resolver stands in for presetDisplayText: shipped presets localize,
  221. // user-authored ones keep their own metadata.
  222. const localized: PluginInventorySettingsTabInjected['presetName'] = preset =>
  223. preset.trust === 'system' ? `Localized ${preset.id}` : preset.name ?? preset.id
  224. render(<PluginInventorySettingsTab {...props(async () => SNAPSHOT, localized)} />)
  225. await screen.findByRole('searchbox', { name: en.search })
  226. const switcher = screen.getByRole('button', { name: en.switcherLabel })
  227. expect(switcher.textContent).toBe('Localized standard (default)')
  228. fireEvent.click(switcher)
  229. expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([
  230. 'Localized standard (default)',
  231. 'Localized ptc',
  232. '坏预设 (failed to load)',
  233. ])
  234. fireEvent.keyDown(document, { key: 'Escape' })
  235. fireEvent.click(screen.getByRole('button', { name: 'pwsh, pwsh, Conditional' }))
  236. expect(screen.getByText(en.fromPreset).nextElementSibling?.textContent).toBe('Localized standard')
  237. fireEvent.click(globalToggle())
  238. fireEvent.click(screen.getByRole('button', { name: 'tool-bash, bash-host, Enabled via presets' }))
  239. expect(screen.getByText('Localized standard · Localized ptc')).toBeTruthy()
  240. })
  241. it('jumps from a preset-provided row to the preset that enables it', async () => {
  242. await renderReady()
  243. fireEvent.click(screen.getByRole('button', { name: en.switcherLabel }))
  244. fireEvent.click(screen.getByRole('menuitem', { name: 'ptc' }))
  245. fireEvent.click(globalToggle())
  246. fireEvent.click(screen.getByRole('button', { name: 'tool-bash, bash-host, Enabled via presets' }))
  247. fireEvent.click(screen.getByRole('button', { name: en.viewInPreset }))
  248. expect(screen.getByRole('button', { name: en.switcherLabel }).textContent)
  249. .toBe('标准模式 (default)')
  250. })
  251. it('searches across scopes and points at matches in other presets', async () => {
  252. const view = await renderReady()
  253. const search = screen.getByRole('searchbox', { name: en.search })
  254. fireEvent.change(search, { target: { value: 'tool-bash' } })
  255. // Searching forces the collapsed global plane and drawer open.
  256. expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('1')
  257. expect(view.container.querySelector('[data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('1')
  258. expect(screen.getByText(en.presetEnabledTag)).toBeTruthy()
  259. expect(screen.queryByText(`1 ${en.failedCountLabel}`)).toBeNull()
  260. const hint = screen.getByText((text: string) => text.startsWith('2 more matches'))
  261. expect(hint).toBeTruthy()
  262. fireEvent.click(screen.getByRole('button', { name: 'ptc' }))
  263. expect(screen.getByRole('button', { name: en.switcherLabel }).textContent).toBe('ptc')
  264. // A match visible only in another preset keeps the pointer without rows.
  265. fireEvent.change(search, { target: { value: 'crashy' } })
  266. expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('0')
  267. expect(screen.getByText((text: string) => text.startsWith('1 more matches'))).toBeTruthy()
  268. expect(screen.queryByText(en.emptySearch)).toBeNull()
  269. // A match on a Loader entry id only reaches the global plane.
  270. fireEvent.change(search, { target: { value: '8a1b2c3d' } })
  271. expect(view.container.querySelector('[data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('1')
  272. expect(screen.queryByText((text: string) => text.includes('more matches'))).toBeNull()
  273. fireEvent.change(search, { target: { value: 'not-a-plugin' } })
  274. expect(screen.getByText(en.emptySearch)).toBeTruthy()
  275. expect(screen.queryAllByRole('listitem')).toHaveLength(0)
  276. })
  277. it('renders a rosterless deployment as one expanded global list', async () => {
  278. const view = await renderReady({
  279. entries: [
  280. { entryId: 'hmr', moduleName: '@deepseek-ai/dsh-hmr', enabled: true, fiberPhase: 'active' },
  281. { entryId: 'off', moduleName: '@fixture/off', enabled: false, fiberPhase: null },
  282. ],
  283. } as unknown as Snapshot)
  284. expect(screen.queryByRole('button', { name: en.switcherLabel })).toBeNull()
  285. expect(globalToggle().getAttribute('aria-expanded')).toBe('true')
  286. expect(screen.getAllByRole('listitem')).toHaveLength(2)
  287. fireEvent.click(screen.getByRole('button', { name: 'hmr, hmr, Enabled' }))
  288. expect(screen.getByText(en.runtime)).toBeTruthy()
  289. expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('hmr')
  290. fireEvent.click(screen.getByRole('button', { name: 'off, off, Disabled' }))
  291. expect(screen.getAllByText(en.moduleLabel).length).toBeGreaterThan(0)
  292. expect(screen.queryByText(en.runtime)).toBeNull()
  293. })
  294. it('renders a preset-only snapshot without the global section', async () => {
  295. await renderReady({
  296. entries: [],
  297. agentPresets: [{
  298. id: 'solo',
  299. trust: 'user',
  300. isDefault: false,
  301. rows: [{ entryId: 'one', moduleName: '@fixture/one', enabled: true, fiberPhase: null }],
  302. }],
  303. })
  304. expect(screen.queryByRole('button', { name: (name: string) => name.startsWith(en.globalTitle) })).toBeNull()
  305. expect(screen.queryByText(en.empty)).toBeNull()
  306. expect(screen.getAllByRole('listitem')).toHaveLength(1)
  307. })
  308. it('shows a generic failure and retries into the empty state', async () => {
  309. const list = vi.fn<PluginInventorySettingsTabInjected['list']>()
  310. .mockRejectedValueOnce(new Error('private transport detail'))
  311. .mockResolvedValueOnce({ entries: [] })
  312. render(<PluginInventorySettingsTab {...props(list)} />)
  313. expect((await screen.findByRole('alert')).textContent).toBe(en.error)
  314. expect(screen.queryByText('private transport detail')).toBeNull()
  315. fireEvent.click(screen.getByRole('button', { name: en.retry }))
  316. await waitFor(() => { expect(list).toHaveBeenCalledTimes(2) })
  317. expect(await screen.findByText(en.empty)).toBeTruthy()
  318. })
  319. it('contains a synchronous Remote failure and ignores a result after unmount', async () => {
  320. const syncFailure = vi.fn(() => { throw new Error('namespace unavailable') }) as PluginInventorySettingsTabInjected['list']
  321. const failed = render(<PluginInventorySettingsTab {...props(syncFailure)} />)
  322. expect((await screen.findByRole('alert')).textContent).toBe(en.error)
  323. failed.unmount()
  324. const deferred = Promise.withResolvers<Snapshot>()
  325. const pending = render(<PluginInventorySettingsTab {...props(() => deferred.promise)} />)
  326. expect(screen.getByText(en.loading)).toBeTruthy()
  327. pending.unmount()
  328. await act(async () => { deferred.resolve(SNAPSHOT) })
  329. const deferredFailure = Promise.withResolvers<Snapshot>()
  330. const pendingFailure = render(<PluginInventorySettingsTab {...props(() => deferredFailure.promise)} />)
  331. pendingFailure.unmount()
  332. await act(async () => { deferredFailure.reject(new Error('late failure')) })
  333. })
  334. })
  335. describe('persistent profile management', () => {
  336. it('runs bundle and plugin controls through the same manager and shows failed results', async () => {
  337. let enabled = true
  338. let selected = true
  339. let installed = true
  340. const list: PluginInventorySettingsTabInjected['list'] = async () => ({
  341. managementAvailable: true,
  342. entries: [{ entryId: 'managed' as Snapshot['entries'][number]['entryId'], moduleName: '@fixture/managed', enabled, fiberPhase: enabled ? 'active' : null }],
  343. })
  344. const management = {
  345. listPlugins: async () => [{ ...(await list()).entries[0]!, patchId: 'managed' }],
  346. listBundles: async () => installed ? [{ name: 'extra', version: '1', enabled: selected, removable: true }] : [],
  347. setPluginEnabled: vi.fn(async (_id: Snapshot['entries'][number]['entryId'], next: boolean) => {
  348. enabled = next
  349. return { changed: true, application: 'applied' as const, stage: 'enable' as const, target: 'managed', enabled: next }
  350. }),
  351. setBundleEnabled: vi.fn(async (_name: string, next: boolean) => {
  352. selected = next
  353. return { changed: true, application: 'applied' as const, stage: 'enable' as const, target: 'extra', enabled: next }
  354. }),
  355. installBundle: vi.fn(async () => ({ changed: false, application: 'failed' as const, stage: 'install' as const, target: 'new-bundle', error: { code: 'operation-error' as const, diagnostic: 'Registry unavailable.' } })),
  356. removeBundle: vi.fn(async () => {
  357. installed = false
  358. return { changed: true, application: 'applied' as const, stage: 'remove' as const, target: 'extra' }
  359. }),
  360. } satisfies NonNullable<PluginInventorySettingsTabInjected['management']>
  361. render(<PluginInventorySettingsTab {...props(list)} management={management} />)
  362. fireEvent.click(await screen.findByRole('switch', { name: 'Toggle bundle extra' }))
  363. await waitFor(() => { expect(management.setBundleEnabled).toHaveBeenCalledWith('extra', false) })
  364. await waitFor(() => { expect(screen.getByRole<HTMLInputElement>('switch', { name: 'Toggle bundle extra' }).checked).toBe(false) })
  365. fireEvent.click(screen.getByRole('button', { name: /managed, managed/ }))
  366. fireEvent.click(await screen.findByRole('switch', { name: 'Toggle plugin managed' }))
  367. await waitFor(() => { expect(management.setPluginEnabled).toHaveBeenCalledWith('managed', false) })
  368. await waitFor(() => { expect(screen.getByRole<HTMLInputElement>('switch', { name: 'Toggle plugin managed' }).checked).toBe(false) })
  369. fireEvent.change(screen.getByRole('textbox', { name: 'npm package name or local path' }), { target: { value: 'new-bundle' } })
  370. fireEvent.click(screen.getByRole('checkbox', { name: 'Enable after installation' }))
  371. fireEvent.click(screen.getByRole('button', { name: 'Install' }))
  372. await waitFor(() => { expect(management.installBundle).toHaveBeenCalledWith('new-bundle', { enabled: false }) })
  373. expect((await screen.findByRole('alert')).textContent).toContain('Registry unavailable.')
  374. fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
  375. await waitFor(() => { expect(management.removeBundle).toHaveBeenCalledWith('extra') })
  376. await waitFor(() => { expect(screen.queryByRole('switch', { name: 'Toggle bundle extra' })).toBeNull() })
  377. })
  378. })
  379. function managementFixture() {
  380. const applied = { changed: true, application: 'applied' as const, stage: 'enable' as const, target: 'example' }
  381. return {
  382. listPlugins: vi.fn<PluginManagement['listPlugins']>(async () => []),
  383. listBundles: vi.fn<PluginManagement['listBundles']>(async () => []),
  384. setPluginEnabled: vi.fn(async () => applied), setBundleEnabled: vi.fn(async () => applied),
  385. installBundle: vi.fn(async () => applied), removeBundle: vi.fn(async () => applied),
  386. }
  387. }
  388. it('ignores inventory completion after unmount and reports current read failures', async () => {
  389. for (const failure of [undefined, new Error('late failure')]) {
  390. const read = Promise.withResolvers<Awaited<ReturnType<PluginManagement['listBundles']>>>()
  391. const manager = managementFixture()
  392. vi.mocked(manager.listBundles).mockReturnValue(read.promise)
  393. const hook = renderHook(() => usePluginManagement(manager, true, 0))
  394. hook.unmount()
  395. await act(async () => {
  396. if (failure === undefined) read.resolve([])
  397. else read.reject(failure)
  398. await read.promise.catch(() => {})
  399. })
  400. }
  401. for (const failure of [new Error('inventory unavailable'), 'inventory rejected']) {
  402. const manager = managementFixture()
  403. vi.mocked(manager.listBundles).mockRejectedValue(failure)
  404. const hook = renderHook(() => usePluginManagement(manager, true, 0))
  405. await waitFor(() => { expect(hook.result.current.error).toBe(failure instanceof Error ? failure.message : failure) })
  406. hook.unmount()
  407. }
  408. })
  409. it('blocks duplicate submissions and refreshes after a rejected management request', async () => {
  410. const manager = managementFixture()
  411. const hook = renderHook(() => usePluginManagement(manager, true, 0))
  412. const pending = Promise.withResolvers<Awaited<ReturnType<PluginManagement['removeBundle']>>>()
  413. const operation = vi.fn(() => pending.promise)
  414. await act(async () => {
  415. const first = hook.result.current.run(operation)
  416. await hook.result.current.run(operation)
  417. pending.resolve({ changed: true, application: 'applied', stage: 'remove', target: 'removed' })
  418. await first
  419. })
  420. expect(operation).toHaveBeenCalledOnce()
  421. expect(hook.result.current.result?.target).toBe('removed')
  422. for (const failure of [new Error('request unavailable'), 'request rejected']) {
  423. await act(async () => { await hook.result.current.run(async () => { throw failure }) })
  424. expect(hook.result.current.error).toBe(failure instanceof Error ? failure.message : failure)
  425. expect(hook.result.current.busy).toBe(false)
  426. }
  427. })
  428. it('shows package diagnostics, read errors and protected or missing bundles', () => {
  429. const manager = managementFixture()
  430. const state: ReturnType<typeof usePluginManagement> = {
  431. plugins: [], bundles: [
  432. { name: 'missing', enabled: false, removable: true, error: { code: 'operation-error', diagnostic: 'package files missing' } },
  433. { name: 'core', enabled: true, removable: false, readOnlyReason: 'management-required' },
  434. ], busy: false, refresh: 0, error: 'inventory unavailable', run: async () => {},
  435. result: { changed: true, application: 'failed', stage: 'install', target: 'example', error: { code: 'not-bundle' },
  436. packageResult: { exitCode: 1, output: 'failed', truncated: false, logPath: '/profile/operation/pnpm.log' } },
  437. }
  438. render(<BundleManager manager={manager} state={state} t={t} />)
  439. expect(screen.getByText('inventory unavailable')).toBeDefined()
  440. expect(screen.getByText('/profile/operation/pnpm.log')).toBeDefined()
  441. expect(screen.getByText(/package files missing/)).toBeDefined()
  442. expect(screen.getByText(en['management-required'])).toBeDefined()
  443. expect(screen.getByRole<HTMLInputElement>('switch', { name: 'Toggle bundle missing' }).disabled).toBe(true)
  444. expect(screen.getByRole<HTMLInputElement>('switch', { name: 'Toggle bundle core' }).disabled).toBe(true)
  445. })
  446. it.each([['en', en], ['zh', zh]] as const)('renders management codes and cleanup results using the %s dictionary', (_locale, dictionary) => {
  447. const translate = ((key: PluginInventoryLocaleKey) => dictionary[key]) as PluginInventorySettingsTabProps['t']
  448. render(<BundleManager manager={managementFixture()} t={translate} state={{
  449. plugins: [], bundles: [{ name: 'plain', enabled: false, removable: true, error: { code: 'not-bundle' } }],
  450. busy: false, refresh: 0, run: async () => {}, error: undefined,
  451. result: { changed: true, application: 'failed', stage: 'install', target: 'plain',
  452. error: { code: 'not-bundle' }, warnings: ['pending external service'], remainingDependencies: ['plain'],
  453. cleanup: { name: 'plain', error: { code: 'operation-error', diagnostic: 'EACCES' },
  454. packageResult: { exitCode: 1, output: 'EACCES', truncated: false, logPath: '/cleanup.log' } } },
  455. }} />)
  456. expect(screen.getAllByText(dictionary['not-bundle'], { exact: false }).length).toBeGreaterThan(0)
  457. expect(screen.getByText(dictionary.cleanup, { exact: false })).toBeDefined()
  458. expect(screen.getByText('/cleanup.log')).toBeDefined()
  459. expect(screen.getByRole<HTMLInputElement>('switch').disabled).toBe(true)
  460. expect(screen.getByRole<HTMLButtonElement>('button', { name: dictionary.remove }).disabled).toBe(false)
  461. })
  462. it('localizes protected plugin explanations in expanded details', async () => {
  463. const list: PluginInventorySettingsTabInjected['list'] = async () => ({ managementAvailable: true,
  464. entries: [{ entryId: 'managed' as Snapshot['entries'][number]['entryId'], moduleName: '@fixture/managed', enabled: true, fiberPhase: 'active' }],
  465. })
  466. const manager = managementFixture()
  467. manager.listPlugins.mockResolvedValue([{ ...(await list()).entries[0]!, readOnlyReason: 'management-required' }])
  468. render(<PluginInventorySettingsTab {...props(list)} management={manager} />)
  469. fireEvent.click(await screen.findByRole('button', { name: /managed, managed/ }))
  470. expect(await screen.findByText(en['management-required'])).toBeDefined()
  471. })
  472. it('shows a completed dependency cleanup without a subprocess log', () => {
  473. render(<BundleManager manager={managementFixture()} t={t} state={{
  474. plugins: [], bundles: [], busy: false, refresh: 0, error: undefined, run: async () => {},
  475. result: { stage: 'install', target: 'plain', changed: false, application: 'failed', cleanup: { name: 'plain' } },
  476. }} />)
  477. expect(screen.getByRole('alert').textContent).toContain('New dependency cleanup: plain — Applied')
  478. })
  479. it('shows current-page sync errors and retries without re-reading Host inventory', async () => {
  480. const list = vi.fn(async () => ({ entries: [] }))
  481. const sync = createSnapshotStore<ClientEntryState>({ syncing: true, failures: [] })
  482. const retryClient = vi.fn()
  483. render(<PluginInventorySettingsTab {...props(list)} useClientSync={bindSnapshotSelector(sync)} retryClient={retryClient} />)
  484. expect(screen.getByRole('status').textContent).toContain('Syncing plugins on this page')
  485. await waitFor(() => { expect(list).toHaveBeenCalledOnce() })
  486. act(() => { sync.set({ syncing: false, failures: [{ id: 'client-addon', message: 'download failed' }] }) })
  487. expect(screen.getByText('client-addon: download failed')).toBeTruthy()
  488. fireEvent.click(screen.getByRole('button', { name: 'Retry this page' }))
  489. expect(retryClient).toHaveBeenCalledOnce()
  490. expect(list).toHaveBeenCalledOnce()
  491. act(() => { sync.set({ syncing: false, failures: [] }) })
  492. expect(screen.queryByRole('alert')).toBeNull()
  493. })