| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246 |
- // @vitest-environment jsdom
- import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
- import { afterEach, describe, expect, it, vi } from 'vitest'
- import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
- import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
- import type { ReactNode } from 'react'
- import type {
- SidebarFooterActionOwnerProps, SidebarRootComponentProps, SidebarSectionOwnerProps,
- SidebarSettingsOwnerProps,
- } from '../src/client/contract/slots.ts'
- import { HeaderLeadingControls, type HeaderLeadingControlsProps } from '../src/client/HeaderLeadingControls.tsx'
- import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
- import { en } from '../src/client/locales.ts'
- import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
- // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
- const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
- const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
- // English-dictionary translate stub: the shell renders the same copy the
- // assertions below query by accessible name.
- const t: SidebarRootComponentProps['t'] = key =>
- (en as Record<string, string>)[key] ?? (commonEn as Record<string, string>)[key] ?? key
- afterEach(() => {
- cleanup()
- delete document.documentElement.dataset.platform
- vi.unstubAllEnvs()
- vi.useRealTimers()
- })
- // The shell never reads the global hooks itself, but they ride the standard
- // props share; stub them as never-called functions.
- const neverHook = (() => { throw new Error('shell must not read global hooks') }) as never
- type AttentionSnapshot = Parameters<Parameters<SidebarRootComponentProps['useSessionStatus']>[0]>[0]
- const noAttention: AttentionSnapshot = new Map()
- const useSessionStatus: SidebarRootComponentProps['useSessionStatus'] = selector => selector(noAttention)
- function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; width?: number } = {}) {
- const startSession = vi.fn()
- const toggleSidebar = vi.fn()
- let regionOwner: SidebarSectionOwnerProps | undefined
- let settingsOwner: SidebarSettingsOwnerProps | undefined
- let footerActionOwner: SidebarFooterActionOwnerProps | undefined
- const brandMark = <span data-testid="custom-brand-mark">M</span>
- const brandName = <span data-testid="custom-brand-name">Custom Brand</span>
- let current = { collapsed, width }
- const root = () => (
- <SidebarRoot
- collapsed={current.collapsed} width={current.width}
- useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook}
- usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])}
- useResource={useResource} useWorkspaces={neverHook}
- startSession={startSession} toggleSidebar={toggleSidebar} t={t}
- renderSlot={((
- key: string,
- owner: SidebarFooterActionOwnerProps | SidebarSectionOwnerProps | SidebarSettingsOwnerProps,
- ) => {
- if (key === 'sidebar.brand.mark') return brandMark
- if (key === 'sidebar.brand.name') return brandName
- if (key === 'sidebar.toggle.badge') return null
- if (key === 'sidebar.settings') {
- settingsOwner = owner
- return <div data-testid="settings-seat" data-wide={owner.wide} />
- }
- if (key === 'sidebar.footer.action') {
- footerActionOwner = owner
- return <div data-testid="footer-action-seat" data-wide={owner.wide} />
- }
- regionOwner = owner as SidebarSectionOwnerProps
- return <div data-testid="region" data-wide={owner.wide} />
- }) as SidebarRootComponentProps['renderSlot']}
- />
- )
- const view = render(root())
- return {
- startSession,
- toggleSidebar,
- regionOwner: () => {
- if (regionOwner === undefined) throw new Error('region owner not rendered')
- return regionOwner
- },
- settingsOwner: () => {
- if (settingsOwner === undefined) throw new Error('settings owner not rendered')
- return settingsOwner
- },
- footerActionOwner: () => {
- if (footerActionOwner === undefined) throw new Error('footer action owner not rendered')
- return footerActionOwner
- },
- rerender(next: Partial<typeof current>) {
- current = { ...current, ...next }
- view.rerender(root())
- },
- }
- }
- describe('SidebarRoot shell', () => {
- it('routes New Session (capsule + wordmark) and the column toggle', () => {
- const b = mountShell()
- expect(screen.getByTestId('custom-brand-mark')).toBeTruthy()
- expect(screen.getByTestId('custom-brand-name')).toBeTruthy()
- // Expanded, both the wordmark and the capsule start a session.
- const starters = screen.getAllByRole('button', { name: 'New session' })
- expect(starters).toHaveLength(2)
- for (const button of starters) fireEvent.click(button)
- expect(b.startSession).toHaveBeenCalledTimes(2)
- fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
- expect(b.toggleSidebar).toHaveBeenCalledOnce()
- })
- it('renders generic brand fallbacks when no package fills the slots', () => {
- vi.stubEnv('DSH_CLIENT_COMMIT_HASH', '0123456')
- vi.stubEnv('DSH_CLIENT_GIT_DIRTY', 'true')
- vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3-rc.4')
- const { container } = render(<SidebarRoot
- collapsed={false} width={300}
- useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook}
- usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])}
- useResource={useResource} useWorkspaces={neverHook}
- startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
- renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) =>
- options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']}
- />)
- expect(screen.getByText('DSH Local Build')).toBeTruthy()
- expect(screen.getByText('1.2.3-rc.4-0123456-dirty')).toBeTruthy()
- expect(container.querySelector('svg')).not.toBeNull()
- })
- it.each([
- [{ DSH_CLIENT_VERSION: '1.2.3' }, '1.2.3'],
- [{ DSH_CLIENT_COMMIT_HASH: 'abcdef0', DSH_CLIENT_VERSION: '1.2.3' }, '1.2.3-abcdef0'],
- ])('omits unavailable build-version suffixes from %j', (environment, expected) => {
- for (const [name, value] of Object.entries(environment)) vi.stubEnv(name, value)
- render(<SidebarRoot
- collapsed={false} width={300}
- useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook}
- usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])}
- useResource={useResource} useWorkspaces={neverHook}
- startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
- renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) =>
- options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']}
- />)
- expect(screen.getByText('DSH Local Build')).toBeTruthy()
- expect(screen.getByText(expected)).toBeTruthy()
- })
- it('retains the local-build fallback without complete build metadata', () => {
- render(<SidebarRoot
- collapsed={false} width={300}
- useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook}
- usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])}
- useResource={useResource} useWorkspaces={neverHook}
- startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
- renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) =>
- options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']}
- />)
- expect(screen.getByText('DSH Local Build')).toBeTruthy()
- })
- it('hands the region its wide flag and clamps expandSidebar to the collapsed state', () => {
- const b = mountShell()
- expect(b.regionOwner().wide).toBe(true)
- // The settings seat rides the same wide flag (ui-settings renders the row).
- expect(b.settingsOwner().wide).toBe(true)
- expect(b.footerActionOwner().wide).toBe(true)
- // Expanded: the request is a no-op (no accidental collapse).
- b.regionOwner().expandSidebar()
- expect(b.toggleSidebar).not.toHaveBeenCalled()
- })
- it('keeps the region mounted through collapse and expands on its request', () => {
- vi.useFakeTimers()
- const b = mountShell()
- b.rerender({ collapsed: true })
- // Wide content survives the crossfade window, then settles into the rail.
- expect(b.regionOwner().wide).toBe(true)
- vi.advanceTimersByTime(200)
- b.rerender({})
- expect(b.regionOwner().wide).toBe(false)
- expect(b.footerActionOwner().wide).toBe(false)
- expect(screen.getByTestId('region')).toBeTruthy()
- b.regionOwner().expandSidebar()
- expect(b.toggleSidebar).toHaveBeenCalledOnce()
- })
- it('renders statically collapsed on a cold start (no crossfade classes)', () => {
- const b = mountShell({ collapsed: true })
- expect(b.regionOwner().wide).toBe(false)
- expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy()
- })
- it('shows only the badge bubble while the rail badge is hovered inside the toggle', () => {
- vi.useFakeTimers()
- render(<SidebarRoot
- collapsed width={56}
- useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook}
- usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])}
- useResource={useResource} useWorkspaces={neverHook}
- startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
- renderSlot={((key: string) => key === 'sidebar.toggle.badge'
- ? <Tooltip label="Update — V1.2.3"><span data-testid="badge" /></Tooltip>
- : null) as SidebarRootComponentProps['renderSlot']}
- />)
- const toggle = screen.getByRole('button', { name: 'Open sidebar' })
- fireEvent.mouseEnter(toggle)
- act(() => { vi.advanceTimersByTime(500) })
- expect(screen.getByRole('tooltip').textContent).toBe('Open sidebar')
- // The badge's own bubble replaces the toggle's rather than stacking on it,
- // even after the toggle's longer hover delay has elapsed.
- fireEvent.mouseEnter(screen.getByTestId('badge'))
- act(() => { vi.advanceTimersByTime(500) })
- expect(screen.getAllByRole('tooltip').map(bubble => bubble.textContent)).toEqual(['Update — V1.2.3'])
- fireEvent.mouseLeave(screen.getByTestId('badge'), { relatedTarget: toggle })
- expect(screen.getByRole('tooltip').textContent).toBe('Open sidebar')
- fireEvent.mouseLeave(toggle)
- expect(screen.queryByRole('tooltip')).toBeNull()
- })
- })
- it('keeps the macOS sidebar toggle in its top strip', () => {
- document.documentElement.dataset.platform = 'darwin'
- const shell = mountShell()
- fireEvent.click(screen.getByRole('button', { name: en['toggle.collapse'] }))
- expect(shell.toggleSidebar).toHaveBeenCalledOnce()
- })
- it.each([undefined, 'win32', 'linux', 'darwin'])('shows header sidebar controls only on macOS desktop (%s)', (platform) => {
- if (platform !== undefined) document.documentElement.dataset.platform = platform
- const toggleSidebar = vi.fn()
- const startSession = vi.fn()
- // This occupant only consumes its two actions and locale, not Session hooks.
- const props = { toggleSidebar, startSession, t } as HeaderLeadingControlsProps
- const view = render(<HeaderLeadingControls {...props} />)
- if (platform !== 'darwin') {
- expect(view.container.innerHTML).toBe('')
- return
- }
- fireEvent.click(screen.getByRole('button', { name: en['toggle.open'] }))
- fireEvent.click(screen.getByRole('button', { name: en['session.new.label'] }))
- expect(toggleSidebar).toHaveBeenCalledOnce()
- expect(startSession).toHaveBeenCalledOnce()
- })
|