components.client.spec.tsx 5.9 KB

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