sidebar-root.client.spec.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. // @vitest-environment jsdom
  2. import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
  5. import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
  6. import type { ReactNode } from 'react'
  7. import type {
  8. SidebarFooterActionOwnerProps, SidebarRootComponentProps, SidebarSectionOwnerProps,
  9. SidebarSettingsOwnerProps,
  10. } from '../src/client/contract/slots.ts'
  11. import { HeaderLeadingControls, type HeaderLeadingControlsProps } from '../src/client/HeaderLeadingControls.tsx'
  12. import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
  13. import { en } from '../src/client/locales.ts'
  14. import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
  15. // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
  16. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
  17. const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
  18. // English-dictionary translate stub: the shell renders the same copy the
  19. // assertions below query by accessible name.
  20. const t: SidebarRootComponentProps['t'] = key =>
  21. (en as Record<string, string>)[key] ?? (commonEn as Record<string, string>)[key] ?? key
  22. afterEach(() => {
  23. cleanup()
  24. delete document.documentElement.dataset.platform
  25. vi.unstubAllEnvs()
  26. vi.useRealTimers()
  27. })
  28. // The shell never reads the global hooks itself, but they ride the standard
  29. // props share; stub them as never-called functions.
  30. const neverHook = (() => { throw new Error('shell must not read global hooks') }) as never
  31. type AttentionSnapshot = Parameters<Parameters<SidebarRootComponentProps['useSessionStatus']>[0]>[0]
  32. const noAttention: AttentionSnapshot = new Map()
  33. const useSessionStatus: SidebarRootComponentProps['useSessionStatus'] = selector => selector(noAttention)
  34. function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; width?: number } = {}) {
  35. const startSession = vi.fn()
  36. const toggleSidebar = vi.fn()
  37. let regionOwner: SidebarSectionOwnerProps | undefined
  38. let settingsOwner: SidebarSettingsOwnerProps | undefined
  39. let footerActionOwner: SidebarFooterActionOwnerProps | undefined
  40. const brandMark = <span data-testid="custom-brand-mark">M</span>
  41. const brandName = <span data-testid="custom-brand-name">Custom Brand</span>
  42. let current = { collapsed, width }
  43. const root = () => (
  44. <SidebarRoot
  45. collapsed={current.collapsed} width={current.width}
  46. useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook}
  47. usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])}
  48. useResource={useResource} useWorkspaces={neverHook}
  49. startSession={startSession} toggleSidebar={toggleSidebar} t={t}
  50. renderSlot={((
  51. key: string,
  52. owner: SidebarFooterActionOwnerProps | SidebarSectionOwnerProps | SidebarSettingsOwnerProps,
  53. ) => {
  54. if (key === 'sidebar.brand.mark') return brandMark
  55. if (key === 'sidebar.brand.name') return brandName
  56. if (key === 'sidebar.toggle.badge') return null
  57. if (key === 'sidebar.settings') {
  58. settingsOwner = owner
  59. return <div data-testid="settings-seat" data-wide={owner.wide} />
  60. }
  61. if (key === 'sidebar.footer.action') {
  62. footerActionOwner = owner
  63. return <div data-testid="footer-action-seat" data-wide={owner.wide} />
  64. }
  65. regionOwner = owner as SidebarSectionOwnerProps
  66. return <div data-testid="region" data-wide={owner.wide} />
  67. }) as SidebarRootComponentProps['renderSlot']}
  68. />
  69. )
  70. const view = render(root())
  71. return {
  72. startSession,
  73. toggleSidebar,
  74. regionOwner: () => {
  75. if (regionOwner === undefined) throw new Error('region owner not rendered')
  76. return regionOwner
  77. },
  78. settingsOwner: () => {
  79. if (settingsOwner === undefined) throw new Error('settings owner not rendered')
  80. return settingsOwner
  81. },
  82. footerActionOwner: () => {
  83. if (footerActionOwner === undefined) throw new Error('footer action owner not rendered')
  84. return footerActionOwner
  85. },
  86. rerender(next: Partial<typeof current>) {
  87. current = { ...current, ...next }
  88. view.rerender(root())
  89. },
  90. }
  91. }
  92. describe('SidebarRoot shell', () => {
  93. it('routes New Session (capsule + wordmark) and the column toggle', () => {
  94. const b = mountShell()
  95. expect(screen.getByTestId('custom-brand-mark')).toBeTruthy()
  96. expect(screen.getByTestId('custom-brand-name')).toBeTruthy()
  97. // Expanded, both the wordmark and the capsule start a session.
  98. const starters = screen.getAllByRole('button', { name: 'New session' })
  99. expect(starters).toHaveLength(2)
  100. for (const button of starters) fireEvent.click(button)
  101. expect(b.startSession).toHaveBeenCalledTimes(2)
  102. fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
  103. expect(b.toggleSidebar).toHaveBeenCalledOnce()
  104. })
  105. it('renders generic brand fallbacks when no package fills the slots', () => {
  106. vi.stubEnv('DSH_CLIENT_COMMIT_HASH', '0123456')
  107. vi.stubEnv('DSH_CLIENT_GIT_DIRTY', 'true')
  108. vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3-rc.4')
  109. const { container } = render(<SidebarRoot
  110. collapsed={false} width={300}
  111. useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook}
  112. usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])}
  113. useResource={useResource} useWorkspaces={neverHook}
  114. startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
  115. renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) =>
  116. options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']}
  117. />)
  118. expect(screen.getByText('DSH Local Build')).toBeTruthy()
  119. expect(screen.getByText('1.2.3-rc.4-0123456-dirty')).toBeTruthy()
  120. expect(container.querySelector('svg')).not.toBeNull()
  121. })
  122. it.each([
  123. [{ DSH_CLIENT_VERSION: '1.2.3' }, '1.2.3'],
  124. [{ DSH_CLIENT_COMMIT_HASH: 'abcdef0', DSH_CLIENT_VERSION: '1.2.3' }, '1.2.3-abcdef0'],
  125. ])('omits unavailable build-version suffixes from %j', (environment, expected) => {
  126. for (const [name, value] of Object.entries(environment)) vi.stubEnv(name, value)
  127. render(<SidebarRoot
  128. collapsed={false} width={300}
  129. useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook}
  130. usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])}
  131. useResource={useResource} useWorkspaces={neverHook}
  132. startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
  133. renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) =>
  134. options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']}
  135. />)
  136. expect(screen.getByText('DSH Local Build')).toBeTruthy()
  137. expect(screen.getByText(expected)).toBeTruthy()
  138. })
  139. it('retains the local-build fallback without complete build metadata', () => {
  140. render(<SidebarRoot
  141. collapsed={false} width={300}
  142. useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook}
  143. usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])}
  144. useResource={useResource} useWorkspaces={neverHook}
  145. startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
  146. renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) =>
  147. options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']}
  148. />)
  149. expect(screen.getByText('DSH Local Build')).toBeTruthy()
  150. })
  151. it('hands the region its wide flag and clamps expandSidebar to the collapsed state', () => {
  152. const b = mountShell()
  153. expect(b.regionOwner().wide).toBe(true)
  154. // The settings seat rides the same wide flag (ui-settings renders the row).
  155. expect(b.settingsOwner().wide).toBe(true)
  156. expect(b.footerActionOwner().wide).toBe(true)
  157. // Expanded: the request is a no-op (no accidental collapse).
  158. b.regionOwner().expandSidebar()
  159. expect(b.toggleSidebar).not.toHaveBeenCalled()
  160. })
  161. it('keeps the region mounted through collapse and expands on its request', () => {
  162. vi.useFakeTimers()
  163. const b = mountShell()
  164. b.rerender({ collapsed: true })
  165. // Wide content survives the crossfade window, then settles into the rail.
  166. expect(b.regionOwner().wide).toBe(true)
  167. vi.advanceTimersByTime(200)
  168. b.rerender({})
  169. expect(b.regionOwner().wide).toBe(false)
  170. expect(b.footerActionOwner().wide).toBe(false)
  171. expect(screen.getByTestId('region')).toBeTruthy()
  172. b.regionOwner().expandSidebar()
  173. expect(b.toggleSidebar).toHaveBeenCalledOnce()
  174. })
  175. it('renders statically collapsed on a cold start (no crossfade classes)', () => {
  176. const b = mountShell({ collapsed: true })
  177. expect(b.regionOwner().wide).toBe(false)
  178. expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy()
  179. })
  180. it('shows only the badge bubble while the rail badge is hovered inside the toggle', () => {
  181. vi.useFakeTimers()
  182. render(<SidebarRoot
  183. collapsed width={56}
  184. useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook}
  185. usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])}
  186. useResource={useResource} useWorkspaces={neverHook}
  187. startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
  188. renderSlot={((key: string) => key === 'sidebar.toggle.badge'
  189. ? <Tooltip label="Update — V1.2.3"><span data-testid="badge" /></Tooltip>
  190. : null) as SidebarRootComponentProps['renderSlot']}
  191. />)
  192. const toggle = screen.getByRole('button', { name: 'Open sidebar' })
  193. fireEvent.mouseEnter(toggle)
  194. act(() => { vi.advanceTimersByTime(500) })
  195. expect(screen.getByRole('tooltip').textContent).toBe('Open sidebar')
  196. // The badge's own bubble replaces the toggle's rather than stacking on it,
  197. // even after the toggle's longer hover delay has elapsed.
  198. fireEvent.mouseEnter(screen.getByTestId('badge'))
  199. act(() => { vi.advanceTimersByTime(500) })
  200. expect(screen.getAllByRole('tooltip').map(bubble => bubble.textContent)).toEqual(['Update — V1.2.3'])
  201. fireEvent.mouseLeave(screen.getByTestId('badge'), { relatedTarget: toggle })
  202. expect(screen.getByRole('tooltip').textContent).toBe('Open sidebar')
  203. fireEvent.mouseLeave(toggle)
  204. expect(screen.queryByRole('tooltip')).toBeNull()
  205. })
  206. })
  207. it('keeps the macOS sidebar toggle in its top strip', () => {
  208. document.documentElement.dataset.platform = 'darwin'
  209. const shell = mountShell()
  210. fireEvent.click(screen.getByRole('button', { name: en['toggle.collapse'] }))
  211. expect(shell.toggleSidebar).toHaveBeenCalledOnce()
  212. })
  213. it.each([undefined, 'win32', 'linux', 'darwin'])('shows header sidebar controls only on macOS desktop (%s)', (platform) => {
  214. if (platform !== undefined) document.documentElement.dataset.platform = platform
  215. const toggleSidebar = vi.fn()
  216. const startSession = vi.fn()
  217. // This occupant only consumes its two actions and locale, not Session hooks.
  218. const props = { toggleSidebar, startSession, t } as HeaderLeadingControlsProps
  219. const view = render(<HeaderLeadingControls {...props} />)
  220. if (platform !== 'darwin') {
  221. expect(view.container.innerHTML).toBe('')
  222. return
  223. }
  224. fireEvent.click(screen.getByRole('button', { name: en['toggle.open'] }))
  225. fireEvent.click(screen.getByRole('button', { name: en['session.new.label'] }))
  226. expect(toggleSidebar).toHaveBeenCalledOnce()
  227. expect(startSession).toHaveBeenCalledOnce()
  228. })