sidebar-root.spec.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. // @vitest-environment jsdom
  2. /**
  3. * SidebarRoot interaction spec, props-direct (slot-parity test doctrine:
  4. * components are fed composed props, no assembly machinery). The standard
  5. * useSessions hook is stubbed with a real web-react SnapshotStore selector;
  6. * expansion/search live inside the component, so all viewing behavior is
  7. * driven through the DOM. Covers expand/collapse, subtree unfold, search
  8. * filtering, row activation, and the creation entries.
  9. */
  10. import { afterEach, describe, expect, it, vi } from 'vitest'
  11. import { cleanup, fireEvent, render, screen } from '@testing-library/react'
  12. import { act, useSyncExternalStore } from 'react'
  13. // Engine home: runtime/client since the store migration; the engine carries
  14. // no hook (runtime is React-free), so the spec binds the selector locally.
  15. import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
  16. import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
  17. import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
  18. /** Minimal selector hook over an engine store (production binding lives in the renderer). */
  19. function hookOf<T>(src: { getSnapshot(): T; subscribe(fn: () => void): () => void }) {
  20. return <S,>(sel: (s: T) => S, _eq?: (a: S, b: S) => boolean): S =>
  21. sel(useSyncExternalStore(src.subscribe.bind(src), src.getSnapshot.bind(src)))
  22. }
  23. const sid = (s: string) => s as SessionId
  24. /** Bare-string init; brands ids and omits absent optional keys (exactOptionalPropertyTypes). */
  25. interface SummaryInit {
  26. id: string
  27. title?: string
  28. cwd?: string
  29. parentId?: string
  30. running?: boolean
  31. updatedAt?: number
  32. }
  33. function summary(init: SummaryInit): SessionSummary {
  34. const s: SessionSummary = {
  35. id: sid(init.id),
  36. title: init.title ?? init.id,
  37. displayTitle: init.title ?? init.id,
  38. running: init.running ?? false,
  39. updatedAt: init.updatedAt ?? 0,
  40. }
  41. if (init.cwd !== undefined) s.cwd = init.cwd
  42. if (init.parentId !== undefined) s.parentId = sid(init.parentId)
  43. return s
  44. }
  45. function listStateOf(...summaries: SessionSummary[]): SessionListState {
  46. const byId: Record<SessionId, SessionSummary> = {}
  47. for (const s of summaries) byId[s.id] = s
  48. return { ids: summaries.map((s) => s.id), byId, current: undefined }
  49. }
  50. afterEach(cleanup)
  51. function mount(...summaries: SessionSummary[]) {
  52. // Real engine store as the useSessions stub: same uSES selector shape the
  53. // framework delivers, so list updates re-render exactly like production.
  54. const sessions = createSnapshotStore<SessionListState>(listStateOf(...summaries))
  55. const onOpen = vi.fn((id: SessionId) => { sessions.update((d) => { d.current = id }) })
  56. const onCreate = vi.fn()
  57. // The owner decides collapsed in production (AppFrame maps the preference);
  58. // the harness mirrors that loop so the toggle drives a re-render.
  59. let collapsed = false
  60. const view = (width: number) => (
  61. <SidebarRoot
  62. collapsed={collapsed}
  63. width={width}
  64. useSessions={hookOf(sessions)}
  65. onOpen={onOpen}
  66. onCreate={onCreate}
  67. onToggleSidebar={onToggleSidebar}
  68. />
  69. )
  70. const onToggleSidebar = vi.fn(() => {
  71. collapsed = !collapsed
  72. utils.rerender(view(collapsed ? 56 : 300))
  73. })
  74. const utils = render(view(300))
  75. return { sessions, onOpen, onCreate, onToggleSidebar, ...utils }
  76. }
  77. const projectData = () => [
  78. summary({ id: 'root', title: 'root work', cwd: '/proj', updatedAt: 5 }),
  79. summary({ id: 'kid', title: 'forked child', cwd: '/proj', parentId: sid('root'), updatedAt: 4 }),
  80. summary({ id: 'lone', title: 'elsewhere', cwd: '/other', updatedAt: 3 }),
  81. ]
  82. /** Flush the store's microtask-batched notification into React. */
  83. const flush = async () => { await act(async () => { await Promise.resolve() }) }
  84. /** The brand wordmark is decorative svg (aria-hidden, no text); locate it by its native viewBox. */
  85. const wordmark = () => document.querySelector('svg[viewBox="0 0 182 24"]')
  86. describe('SidebarRoot', () => {
  87. it('renders chrome and collapsed project rows', () => {
  88. mount(...projectData())
  89. expect(wordmark()).not.toBeNull()
  90. expect(screen.getByText('New Session')).toBeTruthy()
  91. expect(screen.getByText('proj')).toBeTruthy()
  92. expect(screen.getByText('2 sessions')).toBeTruthy()
  93. expect(screen.getByText('1 session')).toBeTruthy()
  94. expect(screen.queryByText('root work')).toBeNull()
  95. })
  96. it('expands a project on click and unfolds a subtree via the twist', () => {
  97. mount(...projectData())
  98. act(() => { fireEvent.click(screen.getByText('proj')) })
  99. expect(screen.getByText('root work')).toBeTruthy()
  100. expect(screen.queryByText('forked child')).toBeNull()
  101. act(() => { fireEvent.click(screen.getByLabelText('Expand')) })
  102. expect(screen.getByText('forked child')).toBeTruthy()
  103. act(() => { fireEvent.click(screen.getByLabelText('Collapse')) })
  104. expect(screen.queryByText('forked child')).toBeNull()
  105. })
  106. it('opens a session on row click and marks it selected', async () => {
  107. const { onOpen } = mount(...projectData())
  108. act(() => { fireEvent.click(screen.getByText('proj')) })
  109. act(() => { fireEvent.click(screen.getByText('root work')) })
  110. expect(onOpen).toHaveBeenCalledWith('root')
  111. // The mock routed the open into sessions.current — highlight follows.
  112. await flush()
  113. expect(screen.getByText('root work').closest('[role="treeitem"]')!.getAttribute('aria-selected')).toBe('true')
  114. })
  115. it('search filters across groups and forces ancestor chains visible', () => {
  116. mount(...projectData())
  117. const input = screen.getByPlaceholderText('Search name, keywords...')
  118. act(() => { fireEvent.change(input, { target: { value: 'forked' } }) })
  119. expect(screen.getByText('forked child')).toBeTruthy()
  120. expect(screen.getByText('root work')).toBeTruthy()
  121. expect(screen.queryByText('elsewhere')).toBeNull()
  122. expect(screen.queryByText(/^other$/)).toBeNull()
  123. act(() => { fireEvent.click(screen.getByLabelText('Clear search')) })
  124. expect(screen.queryByText('root work')).toBeNull()
  125. expect(screen.getByText('proj')).toBeTruthy()
  126. })
  127. it('shows the blank-list empty state without a query', () => {
  128. mount()
  129. expect(screen.getByText('No sessions yet')).toBeTruthy()
  130. })
  131. it('shows the no-match empty state', () => {
  132. mount(...projectData())
  133. const input = screen.getByPlaceholderText('Search name, keywords...')
  134. act(() => { fireEvent.change(input, { target: { value: 'zzz-none' } }) })
  135. expect(screen.getByText('No matches')).toBeTruthy()
  136. })
  137. it('routes the three creation entries with the right cwd', () => {
  138. const { onCreate } = mount(...projectData())
  139. act(() => { fireEvent.click(screen.getByText('New Session')) })
  140. expect(onCreate).toHaveBeenLastCalledWith()
  141. act(() => { fireEvent.click(screen.getByLabelText('New workspace')) })
  142. expect(onCreate).toHaveBeenLastCalledWith()
  143. // Per-project "+" is hover-revealed by CSS; still clickable in jsdom.
  144. act(() => { fireEvent.click(screen.getAllByLabelText('New session here')[0]!) })
  145. expect(onCreate).toHaveBeenLastCalledWith('/proj')
  146. })
  147. it('collapse fades the wide content out, then the rail keeps the four controls', () => {
  148. vi.useFakeTimers()
  149. try {
  150. const { onToggleSidebar, onCreate } = mount(...projectData())
  151. act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
  152. expect(onToggleSidebar).toHaveBeenCalledOnce()
  153. // Fade window: the wide chrome is still mounted while it fades.
  154. expect(wordmark()).not.toBeNull()
  155. expect(screen.getByRole('tree')).toBeTruthy()
  156. // Settle: wide content unmounts, the rail controls remain.
  157. act(() => { vi.advanceTimersByTime(300) })
  158. expect(wordmark()).toBeNull()
  159. expect(screen.queryByText('New Session')).toBeNull()
  160. expect(screen.queryByRole('tree')).toBeNull()
  161. // Rail order mirrors the expanded rows: open, new session, new workspace, search.
  162. const rail = ['Open sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']
  163. .map((label) => screen.getByLabelText(label))
  164. for (let i = 1; i < rail.length; i++) {
  165. expect(rail[i - 1]!.compareDocumentPosition(rail[i]!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
  166. }
  167. // Rail creation entries route like their expanded counterparts.
  168. act(() => { fireEvent.click(screen.getByLabelText('New session')) })
  169. expect(onCreate).toHaveBeenLastCalledWith()
  170. act(() => { fireEvent.click(screen.getByLabelText('Open sidebar')) })
  171. expect(onToggleSidebar).toHaveBeenCalledTimes(2)
  172. expect(screen.getByLabelText('Collapse sidebar')).toBeTruthy()
  173. expect(screen.getByText('New Session')).toBeTruthy()
  174. } finally {
  175. vi.useRealTimers()
  176. }
  177. })
  178. it('rail search expands the sidebar and focuses the search box', () => {
  179. vi.useFakeTimers()
  180. try {
  181. const { onToggleSidebar } = mount(...projectData())
  182. // While expanded the search control is inert (the row click focuses instead).
  183. act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) })
  184. expect(onToggleSidebar).not.toHaveBeenCalled()
  185. act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
  186. act(() => { vi.advanceTimersByTime(300) })
  187. act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) })
  188. expect(onToggleSidebar).toHaveBeenCalledTimes(2)
  189. // Focus waits out the 300ms column slide (EXPAND_SLIDE_MS).
  190. act(() => { vi.advanceTimersByTime(300) })
  191. const input = screen.getByPlaceholderText('Search name, keywords...')
  192. expect(document.activeElement).toBe(input)
  193. } finally {
  194. vi.useRealTimers()
  195. }
  196. })
  197. it('expanded search focuses without toggling the sidebar', () => {
  198. const { onToggleSidebar } = mount(...projectData())
  199. const input = screen.getByPlaceholderText('Search name, keywords...')
  200. act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) })
  201. expect(document.activeElement).toBe(input)
  202. expect(onToggleSidebar).not.toHaveBeenCalled()
  203. })
  204. it('the search query survives a collapse/expand round trip', () => {
  205. vi.useFakeTimers()
  206. try {
  207. mount(...projectData())
  208. const input = screen.getByPlaceholderText('Search name, keywords...')
  209. act(() => { fireEvent.change(input, { target: { value: 'forked' } }) })
  210. act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
  211. act(() => { vi.advanceTimersByTime(300) })
  212. act(() => { fireEvent.click(screen.getByLabelText('Open sidebar')) })
  213. const restored = screen.getByPlaceholderText('Search name, keywords...') as HTMLInputElement
  214. expect(restored.value).toBe('forked')
  215. expect(screen.getByText('forked child')).toBeTruthy()
  216. expect(screen.queryByText('elsewhere')).toBeNull()
  217. } finally {
  218. vi.useRealTimers()
  219. }
  220. })
  221. it('group-by menu behaves', () => {
  222. mount(...projectData())
  223. expect(screen.queryByText('Update')).toBeNull()
  224. act(() => { fireEvent.click(screen.getByLabelText('Group by')) })
  225. expect(screen.getByText('Update')).toBeTruthy()
  226. expect(screen.getByText('Status')).toBeTruthy()
  227. // Selecting the active strategy closes the list (only workspace is enabled).
  228. act(() => { fireEvent.click(screen.getByText('WorkSpace', { selector: 'button *' })) })
  229. expect(screen.queryByText('Update')).toBeNull()
  230. // Reopen and dismiss via Escape (Menu onClose channel).
  231. act(() => { fireEvent.click(screen.getByLabelText('Group by')) })
  232. act(() => { fireEvent.keyDown(document, { key: 'Escape' }) })
  233. expect(screen.queryByText('Update')).toBeNull()
  234. })
  235. it('re-renders when the sessions list gains a session', async () => {
  236. const { sessions } = mount(...projectData())
  237. act(() => {
  238. sessions.update((draft) => {
  239. draft.ids.push(sid('fresh'))
  240. draft.byId[sid('fresh')] = summary({ id: 'fresh', title: 'brand new', cwd: '/fresh', updatedAt: 99 })
  241. })
  242. })
  243. // Store notifications are microtask-batched.
  244. await flush()
  245. expect(screen.getByText('fresh')).toBeTruthy()
  246. })
  247. it('row "More" anchors swallow the click without opening or toggling', () => {
  248. const { onOpen } = mount(...projectData())
  249. act(() => { fireEvent.click(screen.getByText('proj')) })
  250. // Project-row anchor: must not collapse the project (rows stay visible).
  251. act(() => { fireEvent.click(screen.getAllByLabelText('More')[0]!) })
  252. expect(screen.getByText('root work')).toBeTruthy()
  253. // Session-row anchor: must not open the session.
  254. act(() => { fireEvent.click(screen.getAllByLabelText('More')[1]!) })
  255. expect(onOpen).not.toHaveBeenCalled()
  256. })
  257. it('shows the running state dot only for running sessions', () => {
  258. mount(
  259. summary({ id: 'busy', title: 'busy one', cwd: '/p', running: true, updatedAt: 2 }),
  260. summary({ id: 'idle', title: 'idle one', cwd: '/p', updatedAt: 1 }),
  261. )
  262. act(() => { fireEvent.click(screen.getByText('p')) })
  263. const busyRow = screen.getByText('busy one').closest('[role="treeitem"]')!
  264. const idleRow = screen.getByText('idle one').closest('[role="treeitem"]')!
  265. expect(busyRow.querySelector('[data-state="ongoing"]')).toBeTruthy()
  266. expect(idleRow.querySelector('[data-state="ongoing"]')).toBeNull()
  267. })
  268. })