sidebar-root.client.spec.tsx 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. // @vitest-environment jsdom
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import { cleanup, fireEvent, render, screen } from '@testing-library/react'
  4. import type { ReactNode } from 'react'
  5. import type {
  6. SidebarFooterActionOwnerProps, SidebarRootComponentProps, SidebarSectionOwnerProps,
  7. SidebarSettingsOwnerProps,
  8. } from '../src/client/contract/slots.ts'
  9. import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
  10. import { en } from '../src/client/locales.ts'
  11. import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
  12. // English-dictionary translate stub: the shell renders the same copy the
  13. // assertions below query by accessible name.
  14. const t: SidebarRootComponentProps['t'] = key =>
  15. (en as Record<string, string>)[key] ?? (commonEn as Record<string, string>)[key] ?? key
  16. afterEach(() => {
  17. cleanup()
  18. vi.unstubAllEnvs()
  19. vi.useRealTimers()
  20. })
  21. // The shell never reads the global hooks itself, but they ride the standard
  22. // props share; stub them as never-called functions.
  23. const neverHook = (() => { throw new Error('shell must not read global hooks') }) as never
  24. type AttentionSnapshot = Parameters<Parameters<SidebarRootComponentProps['useSessionPendingInteraction']>[0]>[0]
  25. const noAttention: AttentionSnapshot = new Map()
  26. const useSessionPendingInteraction: SidebarRootComponentProps['useSessionPendingInteraction'] = selector => selector(noAttention)
  27. function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; width?: number } = {}) {
  28. const startSession = vi.fn()
  29. const toggleSidebar = vi.fn()
  30. let regionOwner: SidebarSectionOwnerProps | undefined
  31. let settingsOwner: SidebarSettingsOwnerProps | undefined
  32. let footerActionOwner: SidebarFooterActionOwnerProps | undefined
  33. const brandMark = <span data-testid="custom-brand-mark">M</span>
  34. const brandName = <span data-testid="custom-brand-name">Custom Brand</span>
  35. let current = { collapsed, width }
  36. const root = () => (
  37. <SidebarRoot
  38. collapsed={current.collapsed} width={current.width}
  39. useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={neverHook}
  40. startSession={startSession} toggleSidebar={toggleSidebar} t={t}
  41. renderSlot={((
  42. key: string,
  43. owner: SidebarFooterActionOwnerProps | SidebarSectionOwnerProps | SidebarSettingsOwnerProps,
  44. ) => {
  45. if (key === 'sidebar.brand.mark') return brandMark
  46. if (key === 'sidebar.brand.name') return brandName
  47. if (key === 'sidebar.settings') {
  48. settingsOwner = owner
  49. return <div data-testid="settings-seat" data-wide={owner.wide} />
  50. }
  51. if (key === 'sidebar.footer.action') {
  52. footerActionOwner = owner
  53. return <div data-testid="footer-action-seat" data-wide={owner.wide} />
  54. }
  55. regionOwner = owner as SidebarSectionOwnerProps
  56. return <div data-testid="region" data-wide={owner.wide} />
  57. }) as SidebarRootComponentProps['renderSlot']}
  58. />
  59. )
  60. const view = render(root())
  61. return {
  62. startSession,
  63. toggleSidebar,
  64. regionOwner: () => {
  65. if (regionOwner === undefined) throw new Error('region owner not rendered')
  66. return regionOwner
  67. },
  68. settingsOwner: () => {
  69. if (settingsOwner === undefined) throw new Error('settings owner not rendered')
  70. return settingsOwner
  71. },
  72. footerActionOwner: () => {
  73. if (footerActionOwner === undefined) throw new Error('footer action owner not rendered')
  74. return footerActionOwner
  75. },
  76. rerender(next: Partial<typeof current>) {
  77. current = { ...current, ...next }
  78. view.rerender(root())
  79. },
  80. }
  81. }
  82. describe('SidebarRoot shell', () => {
  83. it('routes New Session (capsule + wordmark) and the column toggle', () => {
  84. const b = mountShell()
  85. expect(screen.getByTestId('custom-brand-mark')).toBeTruthy()
  86. expect(screen.getByTestId('custom-brand-name')).toBeTruthy()
  87. // Expanded, both the wordmark and the capsule start a session.
  88. const starters = screen.getAllByRole('button', { name: 'New session' })
  89. expect(starters).toHaveLength(2)
  90. for (const button of starters) fireEvent.click(button)
  91. expect(b.startSession).toHaveBeenCalledTimes(2)
  92. fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
  93. expect(b.toggleSidebar).toHaveBeenCalledOnce()
  94. })
  95. it('renders generic brand fallbacks when no package fills the slots', () => {
  96. vi.stubEnv('DSH_CLIENT_COMMIT_HASH', '0123456')
  97. vi.stubEnv('DSH_CLIENT_GIT_DIRTY', 'true')
  98. vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3-rc.4')
  99. const { container } = render(<SidebarRoot
  100. collapsed={false} width={300}
  101. useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={neverHook}
  102. startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
  103. renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) =>
  104. options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']}
  105. />)
  106. expect(screen.getByText('DSH Local Build')).toBeTruthy()
  107. expect(screen.getByText('1.2.3-rc.4-0123456-dirty')).toBeTruthy()
  108. expect(container.querySelector('svg')).not.toBeNull()
  109. })
  110. it.each([
  111. [{ DSH_CLIENT_VERSION: '1.2.3' }, '1.2.3'],
  112. [{ DSH_CLIENT_COMMIT_HASH: 'abcdef0', DSH_CLIENT_VERSION: '1.2.3' }, '1.2.3-abcdef0'],
  113. ])('omits unavailable build-version suffixes from %j', (environment, expected) => {
  114. for (const [name, value] of Object.entries(environment)) vi.stubEnv(name, value)
  115. render(<SidebarRoot
  116. collapsed={false} width={300}
  117. useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={neverHook}
  118. startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
  119. renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) =>
  120. options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']}
  121. />)
  122. expect(screen.getByText('DSH Local Build')).toBeTruthy()
  123. expect(screen.getByText(expected)).toBeTruthy()
  124. })
  125. it('retains the local-build fallback without complete build metadata', () => {
  126. render(<SidebarRoot
  127. collapsed={false} width={300}
  128. useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={neverHook}
  129. startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
  130. renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) =>
  131. options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']}
  132. />)
  133. expect(screen.getByText('DSH Local Build')).toBeTruthy()
  134. })
  135. it('hands the region its wide flag and clamps expandSidebar to the collapsed state', () => {
  136. const b = mountShell()
  137. expect(b.regionOwner().wide).toBe(true)
  138. // The settings seat rides the same wide flag (ui-settings renders the row).
  139. expect(b.settingsOwner().wide).toBe(true)
  140. expect(b.footerActionOwner().wide).toBe(true)
  141. // Expanded: the request is a no-op (no accidental collapse).
  142. b.regionOwner().expandSidebar()
  143. expect(b.toggleSidebar).not.toHaveBeenCalled()
  144. })
  145. it('keeps the region mounted through collapse and expands on its request', () => {
  146. vi.useFakeTimers()
  147. const b = mountShell()
  148. b.rerender({ collapsed: true })
  149. // Wide content survives the crossfade window, then settles into the rail.
  150. expect(b.regionOwner().wide).toBe(true)
  151. vi.advanceTimersByTime(200)
  152. b.rerender({})
  153. expect(b.regionOwner().wide).toBe(false)
  154. expect(b.footerActionOwner().wide).toBe(false)
  155. expect(screen.getByTestId('region')).toBeTruthy()
  156. b.regionOwner().expandSidebar()
  157. expect(b.toggleSidebar).toHaveBeenCalledOnce()
  158. })
  159. it('renders statically collapsed on a cold start (no crossfade classes)', () => {
  160. const b = mountShell({ collapsed: true })
  161. expect(b.regionOwner().wide).toBe(false)
  162. expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy()
  163. })
  164. })