settings-root.client.spec.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. // @vitest-environment jsdom
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import { useEffect, useState } from 'react'
  4. import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
  5. import type { SettingsRootComponentProps } from '../src/client/shell-contract.ts'
  6. import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
  7. afterEach(cleanup)
  8. type Row = { id: string; order: number; label: string }
  9. type Step = { id: string; order: number }
  10. /** Slot-content stand-ins: the shell renders whatever the seats contribute. */
  11. const SEAT_CONTENT: Record<string, string> = {
  12. 'settings.trigger': 'Settings',
  13. 'settings.header': 'Settings Title',
  14. 'settings.action': 'Open configuration file',
  15. 'settings.close': 'Close',
  16. }
  17. type AttentionSnapshot = Parameters<Parameters<SettingsRootComponentProps['useSessionPendingInteraction']>[0]>[0]
  18. const noAttention: AttentionSnapshot = new Map()
  19. const useSessionPendingInteraction: SettingsRootComponentProps['useSessionPendingInteraction'] = selector => selector(noAttention)
  20. function mount({
  21. wide = true,
  22. onboardingActive = true,
  23. rows = [
  24. { id: 'general', order: 0, label: 'General' },
  25. { id: 'models', order: 10, label: 'Models' },
  26. { id: 'agent-presets', order: 20, label: 'Agent presets' },
  27. ],
  28. steps = [
  29. { id: 'welcome', order: -100 },
  30. { id: 'credential', order: 0 },
  31. ],
  32. }: { wide?: boolean; onboardingActive?: boolean; rows?: Row[]; steps?: Step[] } = {}) {
  33. // Mutable row source standing in for the bound useSections hook; bump()
  34. // plays a ledger change through the same observable contract.
  35. let current = rows
  36. const listeners = new Set<() => void>()
  37. const renderSlot = vi.fn(
  38. ((key: string, _owner: unknown, opts?: { only?: string }) => {
  39. if (key === 'settings.section') return <div data-testid={`section-${opts?.only ?? 'all'}`} />
  40. return SEAT_CONTENT[key]
  41. }) as SettingsRootComponentProps['renderSlot'],
  42. )
  43. const useSessions = ((select: (state: unknown) => unknown) => select(onboardingActive
  44. ? { phase: 'ready', current: undefined, byId: {} }
  45. : {
  46. phase: 'ready',
  47. current: 'active-session',
  48. byId: { 'active-session': { blank: false } },
  49. })) as never
  50. const unusedHook = (() => { throw new Error('unused by SettingsRoot') }) as never
  51. const props: SettingsRootComponentProps = {
  52. useSessions,
  53. useSessionPendingInteraction,
  54. useWorkspaces: unusedHook,
  55. wide,
  56. useOnboardingSteps: select => select(steps),
  57. useSections: (select) => {
  58. const [, force] = useState(0)
  59. useEffect(() => {
  60. const listener = () => { force(n => n + 1) }
  61. listeners.add(listener)
  62. return () => { listeners.delete(listener) }
  63. }, [])
  64. return select(current)
  65. },
  66. renderSlot,
  67. }
  68. const view = render(<SettingsRoot {...props} />)
  69. const bump = (next: Row[]) => {
  70. act(() => {
  71. current = next
  72. for (const fn of [...listeners]) fn()
  73. })
  74. }
  75. return { view, renderSlot, bump, listeners }
  76. }
  77. function openPanel() {
  78. fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
  79. }
  80. describe('SettingsRoot trigger', () => {
  81. it('renders the trigger seat content as the accessible name (no aria-label of its own)', () => {
  82. const { renderSlot } = mount()
  83. const trigger = screen.getByRole('button', { name: 'Settings' })
  84. expect(trigger.hasAttribute('aria-label')).toBe(false)
  85. expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide: true })
  86. expect(trigger.getAttribute('aria-expanded')).toBe('false')
  87. fireEvent.click(trigger)
  88. expect(screen.getByRole('dialog')).toBeTruthy()
  89. expect(screen.getByRole('button', { name: 'Settings', expanded: true })).toBeTruthy()
  90. })
  91. it('hands the rail state to the trigger seat', () => {
  92. const { renderSlot } = mount({ wide: false })
  93. expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide: false })
  94. })
  95. })
  96. describe('SettingsPanel chrome seats', () => {
  97. it('names the dialog via aria-labelledby pointing at the header seat node', () => {
  98. mount()
  99. openPanel()
  100. const dialog = screen.getByRole('dialog')
  101. const titleId = dialog.getAttribute('aria-labelledby')!
  102. expect(titleId).toBeTruthy()
  103. const title = document.getElementById(titleId)!
  104. expect(title.textContent).toBe('Settings Title')
  105. expect(screen.getByRole('dialog', { name: 'Settings Title' })).toBeTruthy()
  106. })
  107. it('names the close button through the visually-hidden close seat text', () => {
  108. mount()
  109. openPanel()
  110. const close = screen.getByRole('button', { name: 'Close' })
  111. expect(close.hasAttribute('aria-label')).toBe(false)
  112. expect(close.textContent).toContain('Close')
  113. })
  114. it('renders header actions before the shell-owned close control', () => {
  115. const { renderSlot } = mount()
  116. openPanel()
  117. expect(screen.getByText('Open configuration file')).toBeTruthy()
  118. expect(renderSlot).toHaveBeenCalledWith('settings.action', {})
  119. })
  120. })
  121. describe('SettingsPanel close paths', () => {
  122. it('closes via the header button', () => {
  123. mount()
  124. openPanel()
  125. fireEvent.click(screen.getByRole('button', { name: 'Close' }))
  126. expect(screen.queryByRole('dialog')).toBeNull()
  127. })
  128. it('closes via a mask click', () => {
  129. mount()
  130. openPanel()
  131. const dialog = screen.getByRole('dialog')
  132. fireEvent.click(dialog.parentElement!.firstElementChild!)
  133. expect(screen.queryByRole('dialog')).toBeNull()
  134. })
  135. it('closes via document-level Escape and unhooks the listener with the panel', () => {
  136. mount()
  137. openPanel()
  138. fireEvent.keyDown(document, { key: 'Escape' })
  139. expect(screen.queryByRole('dialog')).toBeNull()
  140. // Ignored while closed (listener removed with the panel) and non-Escape
  141. // keys are ignored while open.
  142. fireEvent.keyDown(document, { key: 'Escape' })
  143. openPanel()
  144. fireEvent.keyDown(document, { key: 'Enter' })
  145. expect(screen.getByRole('dialog')).toBeTruthy()
  146. })
  147. it('lands focus on the close button when the dialog opens', () => {
  148. mount()
  149. openPanel()
  150. expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Close' }))
  151. })
  152. })
  153. describe('SettingsPanel navigation', () => {
  154. it('projects rows, marks the first active, and renders only that section', () => {
  155. mount()
  156. openPanel()
  157. expect(screen.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true')
  158. expect(screen.getByRole('button', { name: 'Models' }).getAttribute('aria-current')).toBeNull()
  159. expect(screen.getByTestId('section-general')).toBeTruthy()
  160. })
  161. it('gives every section a nav glyph, distinct for the ids the shell knows', () => {
  162. mount({
  163. rows: [
  164. { id: 'general', order: 0, label: 'General' },
  165. { id: 'models', order: 10, label: 'Models' },
  166. { id: 'agent-presets', order: 20, label: 'Agent presets' },
  167. { id: 'plugins', order: 30, label: 'Plugins' },
  168. { id: 'contributed', order: 40, label: 'Contributed' },
  169. ],
  170. })
  171. openPanel()
  172. // Glyphs carry no id of their own, so the drawn paths are what tells them apart.
  173. const glyphs = ['General', 'Models', 'Agent presets', 'Plugins', 'Contributed']
  174. .map(name => screen.getByRole('button', { name }).querySelector('svg')?.innerHTML)
  175. expect(glyphs.every(glyph => glyph !== undefined && glyph !== '')).toBe(true)
  176. // The three ids the shell names get their own glyph; every other section —
  177. // including one this package never heard of — shares the gear.
  178. expect(new Set(glyphs.slice(0, 4)).size).toBe(4)
  179. expect(glyphs[4]).toBe(glyphs[0])
  180. })
  181. it('switches the rendered section on nav click', () => {
  182. mount()
  183. openPanel()
  184. fireEvent.click(screen.getByRole('button', { name: 'Models' }))
  185. expect(screen.getByRole('button', { name: 'Models' }).getAttribute('aria-current')).toBe('true')
  186. expect(screen.getByTestId('section-models')).toBeTruthy()
  187. expect(screen.queryByTestId('section-general')).toBeNull()
  188. })
  189. it('mounts onboarding steps in order and transfers ownership only on completion', () => {
  190. const { renderSlot } = mount()
  191. const first = renderSlot.mock.calls.find(call => call[0] === 'settings.onboarding')
  192. expect(first?.[1]).toMatchObject({ stepId: 'welcome' })
  193. expect(first?.[2]).toEqual({ only: 'welcome' })
  194. act(() => {
  195. (first?.[1] as { complete: () => void }).complete()
  196. ;(first?.[1] as { complete: () => void }).complete()
  197. })
  198. const onboardingCalls = renderSlot.mock.calls.filter(call => call[0] === 'settings.onboarding')
  199. const second = onboardingCalls.at(-1)
  200. expect(second?.[1]).toMatchObject({ stepId: 'credential' })
  201. expect(second?.[2]).toEqual({ only: 'credential' })
  202. act(() => {
  203. (second?.[1] as { openSection: (id: string) => void }).openSection('models')
  204. })
  205. expect(screen.getByRole('dialog')).toBeTruthy()
  206. expect(screen.getByTestId('section-models')).toBeTruthy()
  207. cleanup()
  208. const inactive = mount({ onboardingActive: false }).renderSlot.mock.calls
  209. .filter(call => call[0] === 'settings.onboarding')
  210. expect(inactive).toHaveLength(0)
  211. })
  212. it('paints no takeover chrome of its own around the mounted step', () => {
  213. // The chrome (mask, opaque stage, #root inert) belongs to the step via
  214. // the step-owned dialog surface — a mounted-but-deciding step that
  215. // renders null must show and block nothing (the reload white-flash fix;
  216. // onboarding-surface.spec.tsx pins the primitive's half).
  217. const appRoot = document.createElement('div')
  218. appRoot.id = 'root'
  219. document.body.append(appRoot)
  220. const { view } = mount()
  221. expect(view.container.querySelector('[class*="onboarding"]')).toBeNull()
  222. expect(document.body.querySelector('[class*="onboarding"]')).toBeNull()
  223. expect(appRoot.inert).not.toBe(true)
  224. view.unmount()
  225. appRoot.remove()
  226. })
  227. it('falls back to the first row when the active entry unregisters', () => {
  228. const { bump } = mount()
  229. openPanel()
  230. fireEvent.click(screen.getByRole('button', { name: 'Models' }))
  231. bump([{ id: 'general', order: 0, label: 'General' }])
  232. expect(screen.queryByRole('button', { name: 'Models' })).toBeNull()
  233. expect(screen.getByTestId('section-general')).toBeTruthy()
  234. })
  235. it('renders an empty content column when the ledger is empty', () => {
  236. const { renderSlot } = mount({ rows: [] })
  237. openPanel()
  238. expect(screen.getByRole('dialog')).toBeTruthy()
  239. const sectionCalls = renderSlot.mock.calls.filter(c => c[0] === 'settings.section')
  240. expect(sectionCalls).toHaveLength(0)
  241. })
  242. it('drops the ledger subscription on unmount', () => {
  243. const { view, listeners } = mount()
  244. expect(listeners.size).toBe(1)
  245. view.unmount()
  246. expect(listeners.size).toBe(0)
  247. })
  248. })