settings-root.client.spec.tsx 18 KB

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