apply.client.spec.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. /** Sidebar shell slot registration and its Session/layout callbacks. */
  2. import { Context, type Fiber } from '@deepseek-ai/cordis'
  3. import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
  4. import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
  5. import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
  6. import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
  7. import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
  8. import type { SidebarRootInjected } from '@deepseek-ai/dsh-client-ui-sidebar/client'
  9. import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
  10. import { HeaderLeadingControls } from '../src/client/HeaderLeadingControls.tsx'
  11. import { apply as hostApply } from '../src/index.ts'
  12. const owners = new Set<Fiber>()
  13. afterEach(async () => {
  14. try {
  15. for (const owner of owners) await owner.dispose()
  16. } finally {
  17. owners.clear()
  18. }
  19. })
  20. function SidebarFrame({ renderSlot }: PropsRenderSlots<'sidebar'>) {
  21. return renderSlot('sidebar', { collapsed: false, width: 300 })
  22. }
  23. async function bench(declare = true) {
  24. const root = new Context()
  25. let ctx: Context | undefined
  26. const owner = root.plugin((owned: Context) => { ctx = owned })
  27. owners.add(owner)
  28. await owner.await()
  29. if (ctx === undefined) throw new Error('the sidebar fixture owner did not activate')
  30. await ctx.plugin(SlotRegistry).await()
  31. const layout = { toggleSidebar: vi.fn(), selectPanel: vi.fn() }
  32. const uiWorkspace = { startSession: vi.fn() }
  33. ctx.provide('layout', layout)
  34. ctx.provide('uiWorkspace', uiWorkspace as never)
  35. ctx.provide('locale', new LocaleRuntime(ctx))
  36. const slots = ctx.get('slots') as SlotRegistry
  37. if (declare) {
  38. slots.register(
  39. { name: 'root', children: {
  40. 'sidebar': { kind: 'single', scope: 'root' },
  41. 'main': { kind: 'keyed', scope: 'root' },
  42. 'conversation.session.header.leading': { kind: 'single', scope: 'session' },
  43. } },
  44. SidebarFrame,
  45. )
  46. }
  47. return { ctx, slots, layout, uiWorkspace }
  48. }
  49. describe('ui-sidebar apply', () => {
  50. it('keeps the host Loader entry inert', () => {
  51. expect(hostApply).not.toThrow()
  52. })
  53. it('declares only the services it uses', () => {
  54. expect(inject).toEqual(['slots', 'layout', 'uiWorkspace', 'locale'])
  55. })
  56. it('registers the shell and declares its child seats', async () => {
  57. const b = await bench()
  58. await b.ctx.plugin({ inject: [...inject], apply }).await()
  59. expect(b.slots.entries('sidebar')).toHaveLength(1)
  60. expect(b.slots.spec('sidebar.brand.mark')).toEqual({ kind: 'single', scope: 'root' })
  61. expect(b.slots.spec('sidebar.brand.name')).toEqual({ kind: 'single', scope: 'root' })
  62. expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' })
  63. expect(b.slots.spec('sidebar.settings')).toEqual({ kind: 'single', scope: 'root' })
  64. expect(b.slots.spec('sidebar.footer.action')).toEqual({ kind: 'list', scope: 'root' })
  65. expect(b.slots.spec('sidebar.panellist')).toEqual({ kind: 'list', scope: 'root' })
  66. // Copy rides the standard locale seat, not the inject face.
  67. expect(b.slots.entries('sidebar')[0]!.locale).toBe('sidebar')
  68. // The header leading occupant reuses the shell's inject face and locale.
  69. const leading = b.slots.entries('conversation.session.header.leading')
  70. expect(leading).toHaveLength(1)
  71. expect(leading[0]!.component).toBe(HeaderLeadingControls)
  72. expect(leading[0]!.locale).toBe('sidebar')
  73. expect(leading[0]!.inject).toBe(b.slots.entries('sidebar')[0]!.inject)
  74. const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)()
  75. expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar', 'selectPanel', 'hooks'])
  76. expect(injected.hooks.panels.getSnapshot()).toEqual([])
  77. expect(b.slots.entries('main')).toEqual([])
  78. // Both arms delegate to the Workspace UI's shared New Session action.
  79. injected.startSession('workspace' as never)
  80. expect(b.uiWorkspace.startSession).toHaveBeenCalledWith('workspace')
  81. injected.startSession()
  82. expect(b.uiWorkspace.startSession).toHaveBeenLastCalledWith(undefined)
  83. injected.toggleSidebar()
  84. expect(b.layout.toggleSidebar).toHaveBeenCalledOnce()
  85. const panelId = 'custom-panel' as MainPanelId
  86. injected.selectPanel(panelId)
  87. expect(b.layout.selectPanel).toHaveBeenCalledExactlyOnceWith(panelId)
  88. expectTypeOf<Parameters<SidebarRootInjected['selectPanel']>[0]>().toEqualTypeOf<MainPanelId>()
  89. })
  90. it('waits for the sidebar declaration before registering', async () => {
  91. const b = await bench(false)
  92. const fiber = b.ctx.plugin({ inject: [...inject], apply })
  93. await fiber.await()
  94. expect(b.slots.entries('sidebar')).toHaveLength(0)
  95. const disposeRoot = b.slots.register({
  96. name: 'root',
  97. children: { sidebar: { kind: 'single', scope: 'root' } },
  98. }, SidebarFrame)
  99. expect(b.slots.entries('sidebar')).toHaveLength(1)
  100. disposeRoot()
  101. await fiber.dispose()
  102. })
  103. it('projects plugin-owned panel entries and removes them with their owner', async () => {
  104. const b = await bench()
  105. const sidebar = b.ctx.plugin({ inject: [...inject], apply })
  106. await sidebar.await()
  107. const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)()
  108. const panel = b.ctx.plugin({
  109. inject: ['slots'],
  110. apply(ctx: Context) {
  111. ctx.slots.register({ name: 'main', key: 'custom-panel' }, () => 'Custom panel')
  112. ctx.slots.register({
  113. name: 'sidebar.panellist', id: 'custom-panel', order: 20, label: 'Custom panel',
  114. }, () => null)
  115. },
  116. })
  117. try {
  118. await panel.await()
  119. await vi.waitFor(() => {
  120. expect(injected.hooks.panels.getSnapshot()).toEqual([
  121. { id: 'custom-panel', order: 20, label: 'Custom panel' },
  122. ])
  123. })
  124. expect(b.slots.entries('main').map(entry => entry.options.key)).toEqual(['custom-panel'])
  125. await panel.dispose()
  126. await vi.waitFor(() => { expect(injected.hooks.panels.getSnapshot()).toEqual([]) })
  127. expect(b.slots.entries('main')).toEqual([])
  128. } finally {
  129. await panel.dispose()
  130. await sidebar.dispose()
  131. }
  132. })
  133. it('removes the entry and child declaration on teardown', async () => {
  134. const b = await bench()
  135. const fiber = b.ctx.plugin({ inject: [...inject], apply })
  136. await fiber.await()
  137. await fiber.dispose()
  138. expect(b.slots.entries('sidebar')).toHaveLength(0)
  139. expect(b.slots.entries('conversation.session.header.leading')).toHaveLength(0)
  140. expect(b.slots.spec('sidebar.brand.mark')).toBeUndefined()
  141. expect(b.slots.spec('sidebar.brand.name')).toBeUndefined()
  142. expect(b.slots.spec('sidebar.workspaces')).toBeUndefined()
  143. expect(b.slots.spec('sidebar.footer.action')).toBeUndefined()
  144. expect(b.slots.spec('sidebar.panellist')).toBeUndefined()
  145. expect(b.slots.entries('main')).toHaveLength(0)
  146. })
  147. })