settings-root.client.spec.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  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 { useEffect, useState } from 'react'
  5. import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
  6. import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
  7. import type { SettingsRootComponentProps } from '../src/client/shell-contract.ts'
  8. import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
  9. import { en, zh } from '../src/client/locales.ts'
  10. // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
  11. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
  12. const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
  13. afterEach(() => {
  14. cleanup()
  15. vi.useRealTimers()
  16. })
  17. type Row = { id: string; order: number; label: string }
  18. type Step = { id: string; order: number }
  19. /** Slot-content stand-ins: the shell renders whatever the seats contribute. */
  20. const SEAT_CONTENT: Record<string, string> = {
  21. 'settings.trigger': 'Settings',
  22. 'settings.header': 'Settings Title',
  23. 'settings.action': 'Open configuration file',
  24. 'settings.close': 'Close',
  25. }
  26. type AttentionSnapshot = Parameters<Parameters<SettingsRootComponentProps['useSessionPendingInteraction']>[0]>[0]
  27. type ConnectionSnapshot = Parameters<Parameters<SettingsRootComponentProps['useConnectionState']>[0]>[0]
  28. const noAttention: AttentionSnapshot = new Map()
  29. const useSessionPendingInteraction: SettingsRootComponentProps['useSessionPendingInteraction'] = selector => selector(noAttention)
  30. function mount({
  31. wide = true,
  32. dictionary = en,
  33. connectionState = 'connected',
  34. onboardingActive = true,
  35. rows = [
  36. { id: 'general', order: 0, label: 'General' },
  37. { id: 'models', order: 10, label: 'Models' },
  38. { id: 'agent-presets', order: 20, label: 'Agent presets' },
  39. ],
  40. steps = [
  41. { id: 'welcome', order: -100 },
  42. { id: 'credential', order: 0 },
  43. ],
  44. }: {
  45. wide?: boolean
  46. dictionary?: typeof en | typeof zh
  47. connectionState?: ConnectionSnapshot
  48. onboardingActive?: boolean
  49. rows?: Row[]
  50. steps?: Step[]
  51. } = {}) {
  52. // Mutable row source standing in for the bound useSections hook; bump()
  53. // plays a ledger change through the same observable contract.
  54. let current = rows
  55. let currentConnectionState = connectionState
  56. const listeners = new Set<() => void>()
  57. const connectionListeners = new Set<() => void>()
  58. const reconnect = vi.fn()
  59. const renderSlot = vi.fn(
  60. ((key: string, _owner: unknown, opts?: { only?: string }) => {
  61. if (key === 'settings.section') return <div data-testid={`section-${opts?.only ?? 'all'}`} />
  62. return SEAT_CONTENT[key]
  63. }) as SettingsRootComponentProps['renderSlot'],
  64. )
  65. const useSessions = ((select: (state: unknown) => unknown) => select(onboardingActive
  66. ? { phase: 'ready', current: undefined, byId: {} }
  67. : {
  68. phase: 'ready',
  69. current: 'active-session',
  70. byId: { 'active-session': { blank: false } },
  71. })) as never
  72. const unusedHook = (() => { throw new Error('unused by SettingsRoot') }) as never
  73. const props: SettingsRootComponentProps = {
  74. useSessions,
  75. useSessionPendingInteraction,
  76. usePanelInfo, useResource,
  77. useWorkspaces: unusedHook,
  78. wide,
  79. reconnect,
  80. t: makeTranslate(dictionary),
  81. useConnectionState: (select) => {
  82. const [, force] = useState(0)
  83. useEffect(() => {
  84. const listener = () => { force(n => n + 1) }
  85. connectionListeners.add(listener)
  86. return () => { connectionListeners.delete(listener) }
  87. }, [])
  88. return select(currentConnectionState)
  89. },
  90. useOnboardingSteps: select => select(steps),
  91. useSections: (select) => {
  92. const [, force] = useState(0)
  93. useEffect(() => {
  94. const listener = () => { force(n => n + 1) }
  95. listeners.add(listener)
  96. return () => { listeners.delete(listener) }
  97. }, [])
  98. return select(current)
  99. },
  100. renderSlot,
  101. }
  102. const view = render(<SettingsRoot {...props} />)
  103. const bump = (next: Row[]) => {
  104. act(() => {
  105. current = next
  106. for (const fn of [...listeners]) fn()
  107. })
  108. }
  109. const setConnectionState = (next: typeof currentConnectionState) => {
  110. act(() => {
  111. currentConnectionState = next
  112. for (const fn of [...connectionListeners]) fn()
  113. })
  114. }
  115. return { view, renderSlot, bump, listeners, reconnect, setConnectionState }
  116. }
  117. function openPanel() {
  118. const trigger = screen.getByRole('button', { name: 'Settings' })
  119. trigger.focus()
  120. fireEvent.click(trigger)
  121. return trigger
  122. }
  123. describe('SettingsRoot trigger', () => {
  124. it.each([
  125. { column: 'expanded English', wide: true, dictionary: en, name: 'Settings' },
  126. { column: 'collapsed English', wide: false, dictionary: en, name: 'Settings' },
  127. { column: 'expanded Chinese', wide: true, dictionary: zh, name: '设置' },
  128. { column: 'collapsed Chinese', wide: false, dictionary: zh, name: '设置' },
  129. ])('uses the locale name and accepts keyboard-style activation for the $column trigger', ({
  130. wide, dictionary, name,
  131. }) => {
  132. const { renderSlot } = mount({ wide, dictionary })
  133. const trigger = screen.getByRole('button', { name })
  134. expect(trigger.getAttribute('aria-label')).toBe(name)
  135. expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide })
  136. expect(trigger.getAttribute('aria-expanded')).toBe('false')
  137. trigger.focus()
  138. fireEvent.click(trigger, { detail: 0 })
  139. expect(screen.getByRole('dialog')).toBeTruthy()
  140. expect(screen.getByRole('button', { name, expanded: true })).toBeTruthy()
  141. })
  142. it('shows outage, retry progress, and a two-second recovery confirmation', () => {
  143. vi.useFakeTimers()
  144. const mounted = mount()
  145. expect(screen.queryByRole('button', { name: 'Disconnected, reconnect now' })).toBeNull()
  146. mounted.setConnectionState('disconnected')
  147. const indicator = screen.getByRole('button', { name: 'Disconnected, reconnect now' })
  148. expect(indicator.textContent).toContain('Disconnected')
  149. expect(indicator.hasAttribute('title')).toBe(false)
  150. expect(indicator.querySelector('svg')).toBeTruthy()
  151. fireEvent.click(indicator)
  152. expect(mounted.reconnect).toHaveBeenCalledOnce()
  153. mounted.setConnectionState('connecting')
  154. expect(screen.getByRole('button', { name: 'Reconnecting, reconnect now' }).textContent)
  155. .toContain('Reconnecting...')
  156. // An attempt that resolves instantly still shows the connecting pill for
  157. // its 800ms minimum before the confirmation replaces it.
  158. mounted.setConnectionState('connected')
  159. expect(screen.queryByRole('status')).toBeNull()
  160. act(() => { vi.advanceTimersByTime(800) })
  161. expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy()
  162. // The confirmation window is measured from visibility, not the transition.
  163. act(() => { vi.advanceTimersByTime(1_999) })
  164. expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy()
  165. // The confirmation window closes at 2s, then the pill fades for 150ms.
  166. act(() => { vi.advanceTimersByTime(1) })
  167. act(() => { vi.advanceTimersByTime(150) })
  168. expect(screen.queryByRole('status')).toBeNull()
  169. })
  170. it('keeps the attempt label steady through the hold and confirms for the full window', () => {
  171. vi.useFakeTimers()
  172. const mounted = mount({ dictionary: zh })
  173. mounted.setConnectionState('connecting')
  174. const attempt = screen.getByRole('button', { name: '连接中断,正在重试,点击立即重连' })
  175. expect(attempt.textContent).toContain('重新连接中')
  176. fireEvent.click(attempt)
  177. expect(mounted.reconnect).toHaveBeenCalledOnce()
  178. expect(attempt.textContent).toContain('重新连接中')
  179. // An attempt that resolves mid-hold keeps its label until the hold ends.
  180. act(() => { vi.advanceTimersByTime(100) })
  181. mounted.setConnectionState('connected')
  182. expect(screen.getByRole('button', { name: '连接中断,正在重试,点击立即重连' }).textContent)
  183. .toContain('重新连接中')
  184. act(() => { vi.advanceTimersByTime(700) })
  185. expect(screen.getByRole('status', { name: '连接成功' })).toBeTruthy()
  186. // The full two-second confirmation follows the delayed appearance.
  187. act(() => { vi.advanceTimersByTime(1_999) })
  188. expect(screen.getByRole('status', { name: '连接成功' })).toBeTruthy()
  189. act(() => { vi.advanceTimersByTime(1) })
  190. act(() => { vi.advanceTimersByTime(150) })
  191. expect(screen.queryByRole('status')).toBeNull()
  192. })
  193. it('skips the hold when the attempt already stayed visible long enough', () => {
  194. vi.useFakeTimers()
  195. const mounted = mount()
  196. mounted.setConnectionState('connecting')
  197. act(() => { vi.advanceTimersByTime(800) })
  198. mounted.setConnectionState('connected')
  199. expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy()
  200. act(() => { vi.advanceTimersByTime(2_000) })
  201. act(() => { vi.advanceTimersByTime(150) })
  202. expect(screen.queryByRole('status')).toBeNull()
  203. })
  204. it('keeps the reconnect indicator out of the collapsed rail', () => {
  205. mount({ wide: false, connectionState: 'disconnected' })
  206. expect(screen.queryByRole('button', { name: 'Disconnected, reconnect now' })).toBeNull()
  207. })
  208. })
  209. describe('SettingsPanel chrome seats', () => {
  210. it('names the dialog via aria-labelledby pointing at the header seat node', () => {
  211. mount()
  212. openPanel()
  213. const dialog = screen.getByRole('dialog')
  214. const titleId = dialog.getAttribute('aria-labelledby')!
  215. expect(titleId).toBeTruthy()
  216. const title = document.getElementById(titleId)!
  217. expect(title.textContent).toBe('Settings Title')
  218. expect(screen.getByRole('dialog', { name: 'Settings Title' })).toBeTruthy()
  219. })
  220. it('names the close button through the visually-hidden close seat text', () => {
  221. mount()
  222. openPanel()
  223. const close = screen.getByRole('button', { name: 'Close' })
  224. expect(close.hasAttribute('aria-label')).toBe(false)
  225. expect(close.textContent).toContain('Close')
  226. })
  227. it('renders header actions before the shell-owned close control', () => {
  228. const { renderSlot } = mount()
  229. openPanel()
  230. expect(screen.getByText('Open configuration file')).toBeTruthy()
  231. expect(renderSlot).toHaveBeenCalledWith('settings.action', {})
  232. })
  233. })
  234. describe('SettingsPanel close paths', () => {
  235. it('closes via the header button and restores trigger focus', async () => {
  236. mount()
  237. const trigger = openPanel()
  238. fireEvent.click(screen.getByRole('button', { name: 'Close' }))
  239. expect(screen.queryByRole('dialog')).toBeNull()
  240. await vi.waitFor(() => { expect(document.activeElement).toBe(trigger) })
  241. })
  242. it('closes via a mask click and restores trigger focus', async () => {
  243. mount()
  244. const trigger = openPanel()
  245. const dialog = screen.getByRole('dialog')
  246. fireEvent.click(dialog.parentElement!.firstElementChild!)
  247. expect(screen.queryByRole('dialog')).toBeNull()
  248. await vi.waitFor(() => { expect(document.activeElement).toBe(trigger) })
  249. })
  250. it('closes via document-level Escape, restores trigger focus, and unhooks the listener', async () => {
  251. mount()
  252. const trigger = openPanel()
  253. fireEvent.keyDown(document, { key: 'Escape' })
  254. expect(screen.queryByRole('dialog')).toBeNull()
  255. await vi.waitFor(() => { expect(document.activeElement).toBe(trigger) })
  256. // Ignored while closed (listener removed with the panel) and non-Escape
  257. // keys are ignored while open.
  258. fireEvent.keyDown(document, { key: 'Escape' })
  259. openPanel()
  260. fireEvent.keyDown(document, { key: 'Enter' })
  261. expect(screen.getByRole('dialog')).toBeTruthy()
  262. })
  263. it('lands focus on the close button when the dialog opens', () => {
  264. mount()
  265. openPanel()
  266. expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Close' }))
  267. })
  268. })
  269. describe('SettingsPanel navigation', () => {
  270. it('projects rows, marks the first active, and renders only that section', () => {
  271. mount()
  272. openPanel()
  273. expect(screen.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true')
  274. expect(screen.getByRole('button', { name: 'Models' }).getAttribute('aria-current')).toBeNull()
  275. expect(screen.getByTestId('section-general')).toBeTruthy()
  276. })
  277. it('gives every section a nav glyph, distinct for the ids the shell knows', () => {
  278. mount({
  279. rows: [
  280. { id: 'general', order: 0, label: 'General' },
  281. { id: 'models', order: 10, label: 'Models' },
  282. { id: 'agent-presets', order: 20, label: 'Agent presets' },
  283. { id: 'plugins', order: 30, label: 'Plugins' },
  284. { id: 'contributed', order: 40, label: 'Contributed' },
  285. ],
  286. })
  287. openPanel()
  288. // Glyphs carry no id of their own, so the drawn paths are what tells them apart.
  289. const glyphs = ['General', 'Models', 'Agent presets', 'Plugins', 'Contributed']
  290. .map(name => screen.getByRole('button', { name }).querySelector('svg')?.innerHTML)
  291. expect(glyphs.every(glyph => glyph !== undefined && glyph !== '')).toBe(true)
  292. // The three ids the shell names get their own glyph; every other section —
  293. // including one this package never heard of — shares the gear.
  294. expect(new Set(glyphs.slice(0, 4)).size).toBe(4)
  295. expect(glyphs[4]).toBe(glyphs[0])
  296. })
  297. it('switches the rendered section on nav click', () => {
  298. mount()
  299. openPanel()
  300. fireEvent.click(screen.getByRole('button', { name: 'Models' }))
  301. expect(screen.getByRole('button', { name: 'Models' }).getAttribute('aria-current')).toBe('true')
  302. expect(screen.getByTestId('section-models')).toBeTruthy()
  303. expect(screen.queryByTestId('section-general')).toBeNull()
  304. })
  305. it('mounts onboarding steps in order and transfers ownership only on completion', () => {
  306. const { renderSlot } = mount()
  307. const first = renderSlot.mock.calls.find(call => call[0] === 'settings.onboarding')
  308. expect(first?.[1]).toMatchObject({ stepId: 'welcome' })
  309. expect(first?.[2]).toEqual({ only: 'welcome' })
  310. act(() => {
  311. (first?.[1] as { complete: () => void }).complete()
  312. ;(first?.[1] as { complete: () => void }).complete()
  313. })
  314. const onboardingCalls = renderSlot.mock.calls.filter(call => call[0] === 'settings.onboarding')
  315. const second = onboardingCalls.at(-1)
  316. expect(second?.[1]).toMatchObject({ stepId: 'credential' })
  317. expect(second?.[2]).toEqual({ only: 'credential' })
  318. act(() => {
  319. (second?.[1] as { openSection: (id: string) => void }).openSection('models')
  320. })
  321. expect(screen.getByRole('dialog')).toBeTruthy()
  322. expect(screen.getByTestId('section-models')).toBeTruthy()
  323. cleanup()
  324. const inactive = mount({ onboardingActive: false }).renderSlot.mock.calls
  325. .filter(call => call[0] === 'settings.onboarding')
  326. expect(inactive).toHaveLength(0)
  327. })
  328. it('paints no takeover chrome of its own around the mounted step', () => {
  329. // The chrome (mask, opaque stage, #root inert) belongs to the step via
  330. // the step-owned dialog surface — a mounted-but-deciding step that
  331. // renders null must show and block nothing (the reload white-flash fix;
  332. // onboarding-surface.spec.tsx pins the primitive's half).
  333. const appRoot = document.createElement('div')
  334. appRoot.id = 'root'
  335. document.body.append(appRoot)
  336. const { view } = mount()
  337. expect(view.container.querySelector('[class*="onboarding"]')).toBeNull()
  338. expect(document.body.querySelector('[class*="onboarding"]')).toBeNull()
  339. expect(appRoot.inert).not.toBe(true)
  340. view.unmount()
  341. appRoot.remove()
  342. })
  343. it('falls back to the first row when the active entry unregisters', () => {
  344. const { bump } = mount()
  345. openPanel()
  346. fireEvent.click(screen.getByRole('button', { name: 'Models' }))
  347. bump([{ id: 'general', order: 0, label: 'General' }])
  348. expect(screen.queryByRole('button', { name: 'Models' })).toBeNull()
  349. expect(screen.getByTestId('section-general')).toBeTruthy()
  350. })
  351. it('renders an empty content column when the ledger is empty', () => {
  352. const { renderSlot } = mount({ rows: [] })
  353. openPanel()
  354. expect(screen.getByRole('dialog')).toBeTruthy()
  355. const sectionCalls = renderSlot.mock.calls.filter(c => c[0] === 'settings.section')
  356. expect(sectionCalls).toHaveLength(0)
  357. })
  358. it('drops the ledger subscription on unmount', () => {
  359. const { view, listeners } = mount()
  360. expect(listeners.size).toBe(1)
  361. view.unmount()
  362. expect(listeners.size).toBe(0)
  363. })
  364. })