components.client.spec.tsx 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  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): string => en[key]) as PluginInventorySettingsTabProps['t']
  13. function props(list: PluginInventorySettingsTabInjected['list']): PluginInventorySettingsTabProps {
  14. return {
  15. t,
  16. list,
  17. } as PluginInventorySettingsTabProps
  18. }
  19. const SNAPSHOT = {
  20. entries: [
  21. { entryId: '8a1b2c3d', moduleName: '@deepseek-ai/cordis-plugin-hmr', enabled: true, fiberPhase: 'active' },
  22. { entryId: 'pending', moduleName: 'cordis:pending-name', enabled: true, fiberPhase: 'pending' },
  23. { entryId: 'loading', moduleName: '@fixture/loading-name', enabled: true, fiberPhase: 'loading' },
  24. { entryId: 'failed', moduleName: '@fixture/failed-name', enabled: true, fiberPhase: 'failed' },
  25. { entryId: 'unloading', moduleName: '@fixture/unloading-name', enabled: true, fiberPhase: 'unloading' },
  26. { entryId: 'unobserved', moduleName: '@fixture/unobserved-name', enabled: true, fiberPhase: null },
  27. { entryId: 'disabled-entry', moduleName: '@deepseek-ai/dsh-host-directory-picker-native', enabled: false, fiberPhase: null },
  28. ],
  29. } as unknown as Snapshot
  30. describe('PluginInventorySettingsTab', () => {
  31. it('renders runtime status only for enabled plugins', async () => {
  32. const deferred = Promise.withResolvers<Snapshot>()
  33. const list = vi.fn(() => deferred.promise)
  34. const view = render(<PluginInventorySettingsTab {...props(list)} />)
  35. expect(screen.getByText(en.loading)).toBeTruthy()
  36. await act(async () => { deferred.resolve(SNAPSHOT) })
  37. expect(list).toHaveBeenCalledOnce()
  38. expect(screen.getByRole('searchbox', { name: en.search })).toBeTruthy()
  39. expect(screen.getByRole('heading', { name: en.catalog })).toBeTruthy()
  40. expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('7')
  41. expect(screen.getAllByRole('listitem')).toHaveLength(7)
  42. expect(screen.getAllByText(en.enabledTag)).toHaveLength(6)
  43. expect(screen.getByText(en.disabledTag)).toBeTruthy()
  44. for (const value of [
  45. 'Mounted',
  46. 'Waiting for dependencies',
  47. 'Loading',
  48. 'Mount failed',
  49. 'Unloading',
  50. 'Not mounted',
  51. ]) {
  52. expect(screen.getByRole('img', { name: value })).toBeTruthy()
  53. }
  54. const active = screen.getByRole('button', { name: 'hmr, Mounted, Enabled' })
  55. expect(active.getAttribute('aria-expanded')).toBe('false')
  56. fireEvent.click(active)
  57. expect(active.getAttribute('aria-expanded')).toBe('true')
  58. expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('8a1b2c3d')
  59. expect(screen.getByText(en.configuration)).toBeTruthy()
  60. expect(screen.getByText(en.cordis)).toBeTruthy()
  61. fireEvent.click(active)
  62. expect(view.container.querySelector('[data-loader-entry]')).toBeNull()
  63. fireEvent.click(active)
  64. fireEvent.change(screen.getByRole('searchbox', { name: en.search }), {
  65. target: { value: 'disabled-entry' },
  66. })
  67. expect(view.container.querySelector('[data-loader-entry]')).toBeNull()
  68. fireEvent.click(screen.getByRole('button', { name: 'directory-picker-native, Disabled' }))
  69. expect(screen.getAllByText(en.disabledTag)).toHaveLength(2)
  70. expect(screen.queryByText(en.cordis)).toBeNull()
  71. expect(screen.queryByText(en.unobserved)).toBeNull()
  72. })
  73. it('filters by module name or Loader entry id', async () => {
  74. render(<PluginInventorySettingsTab {...props(async () => SNAPSHOT)} />)
  75. const search = await screen.findByRole('searchbox', { name: en.search })
  76. fireEvent.change(search, { target: { value: 'disabled-entry' } })
  77. expect(screen.getAllByRole('listitem')).toHaveLength(1)
  78. expect(screen.getByText('directory-picker-native')).toBeTruthy()
  79. fireEvent.change(search, { target: { value: 'cordis-plugin-hmr' } })
  80. expect(screen.getAllByRole('listitem')).toHaveLength(1)
  81. expect(screen.getByText('hmr')).toBeTruthy()
  82. fireEvent.change(search, { target: { value: 'not-a-plugin' } })
  83. expect(screen.queryAllByRole('listitem')).toHaveLength(0)
  84. expect(screen.getByText(en.emptySearch)).toBeTruthy()
  85. })
  86. it('shows a generic failure and retries into the empty state', async () => {
  87. const list = vi.fn<PluginInventorySettingsTabInjected['list']>()
  88. .mockRejectedValueOnce(new Error('private transport detail'))
  89. .mockResolvedValueOnce({ entries: [] })
  90. render(<PluginInventorySettingsTab {...props(list)} />)
  91. expect((await screen.findByRole('alert')).textContent).toBe(en.error)
  92. expect(screen.queryByText('private transport detail')).toBeNull()
  93. fireEvent.click(screen.getByRole('button', { name: en.retry }))
  94. await waitFor(() => { expect(list).toHaveBeenCalledTimes(2) })
  95. expect(await screen.findByText(en.empty)).toBeTruthy()
  96. })
  97. it('contains a synchronous Remote failure and ignores a result after unmount', async () => {
  98. const syncFailure = vi.fn(() => { throw new Error('namespace unavailable') }) as PluginInventorySettingsTabInjected['list']
  99. const failed = render(<PluginInventorySettingsTab {...props(syncFailure)} />)
  100. expect((await screen.findByRole('alert')).textContent).toBe(en.error)
  101. failed.unmount()
  102. const deferred = Promise.withResolvers<Snapshot>()
  103. const pending = render(<PluginInventorySettingsTab {...props(() => deferred.promise)} />)
  104. pending.unmount()
  105. await act(async () => { deferred.resolve(SNAPSHOT) })
  106. const deferredFailure = Promise.withResolvers<Snapshot>()
  107. const pendingFailure = render(<PluginInventorySettingsTab {...props(() => deferredFailure.promise)} />)
  108. pendingFailure.unmount()
  109. await act(async () => { deferredFailure.reject(new Error('late failure')) })
  110. })
  111. })