components.client.spec.tsx 23 KB

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