apply.client.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. /**
  2. * The plugin's wiring, and its removal when the plugin goes.
  3. *
  4. * The registry and the navigation controller are real, because "provided"
  5. * means what those faces do; the slot, locale, frame, and resource faces are
  6. * recorders, because what matters here is what was handed to them — two seats
  7. * over one store, the guide's body under its own id, the frame reports, the
  8. * service binding — and that every registration is gone after dispose, which
  9. * is what makes a reload safe. The seats' components have their own specs.
  10. */
  11. import { describe, expect, it, vi } from 'vitest'
  12. import { Context } from '@deepseek-ai/cordis'
  13. import type { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
  14. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  15. import { apply, inject } from '../src/client/index.ts'
  16. import type { GuideInjected, SidebarRightInjected } from '../src/client/index.ts'
  17. import { apply as hostApply } from '../src/index.ts'
  18. import { SidebarRightController } from '../src/client/service.ts'
  19. import { SidebarRightTabRegistry } from '../src/client/tab-registry.ts'
  20. import type { createSidebarRightStore } from '../src/client/stores.ts'
  21. import { RightbarSeat } from '../src/client/shell/SidebarRight.tsx'
  22. import { RightbarRoot } from '../src/client/shell/RightbarRoot.tsx'
  23. import { ExpandButton } from '../src/client/shell/ExpandButton.tsx'
  24. import { GuideBody } from '../src/client/tabs/guide/GuideBody.tsx'
  25. import { GuideTitle } from '../src/client/tabs/guide/GuideTitle.tsx'
  26. import { GUIDE_ID } from '../src/client/tabs/guide/definition.ts'
  27. import { en, zh } from '../src/client/locales.ts'
  28. const SESSION = 's-test' as SessionId
  29. interface Recorded {
  30. name: string
  31. key?: string
  32. locale?: string
  33. store?: unknown
  34. children?: unknown
  35. inject?: (sessionId: SessionId) => unknown
  36. component: unknown
  37. }
  38. async function boot() {
  39. const ctx = new Context()
  40. const registered: Recorded[] = []
  41. const slots = {
  42. inject: vi.fn((_name: string, register: Parameters<SlotRegistry['inject']>[1]) => ctx.effect(register)),
  43. register: vi.fn((options: Omit<Recorded, 'component'>, component: unknown) => {
  44. const entry: Recorded = { ...options, component }
  45. registered.push(entry)
  46. return () => { registered.splice(registered.indexOf(entry), 1) }
  47. }),
  48. }
  49. const dictionaries = new Map<string, unknown>()
  50. const locale = {
  51. // Copy is the dictionary's contract; the key stands in for the translation.
  52. bind: vi.fn(() => (key: string) => key),
  53. register: vi.fn((ns: string, dicts: unknown) => {
  54. dictionaries.set(ns, dicts)
  55. return () => { dictionaries.delete(ns) }
  56. }),
  57. }
  58. const layout = { openRightbar: vi.fn(), closeRightbar: vi.fn() }
  59. const resources = { pin: vi.fn<(address: string, signal: AbortSignal) => void>() }
  60. ctx.provide('slots', slots as never)
  61. ctx.provide('locale', locale as never)
  62. ctx.provide('layout', layout as never)
  63. ctx.provide('resources', resources as never)
  64. const fiber = ctx.plugin({ inject: [...inject], apply })
  65. await fiber.await()
  66. const seat = (name: string): Recorded => {
  67. const entry = registered.find(candidate => candidate.name === name)
  68. if (entry === undefined) throw new Error(`expected a registration into ${name}`)
  69. return entry
  70. }
  71. const injectedOf = (entry: Recorded): unknown => {
  72. if (entry.inject === undefined) throw new Error(`expected ${entry.name} to inject`)
  73. return entry.inject(SESSION)
  74. }
  75. return { ctx, registered, dictionaries, layout, resources, fiber, seat, injectedOf }
  76. }
  77. describe('ui-sidebar-right apply', () => {
  78. it('keeps the host Loader entry inert', () => {
  79. expect(hostApply).not.toThrow()
  80. })
  81. it('provides both faces, and registers the guide through the same two-stage path as any other type', async () => {
  82. const { ctx, registered, dictionaries, seat } = await boot()
  83. expect(ctx.sidebarRightTabs).toBeInstanceOf(SidebarRightTabRegistry)
  84. expect(ctx.sidebarRight).toBeInstanceOf(SidebarRightController)
  85. expect('adopt' in ctx.sidebarRight).toBe(false)
  86. expect(dictionaries.get('sidebarRight')).toEqual({ zh, en })
  87. const guide = ctx.sidebarRightTabs.get('guide')
  88. expect(guide?.id).toBe(GUIDE_ID)
  89. expect(guide?.priority).toBe('builtin')
  90. expect(guide?.title('sidebar://guide')).toBe('tab.guide.title')
  91. // Five registrations: the root and panel seats, the header's corner seat,
  92. // and the guide body and chip title under the guide implementation's id.
  93. // The guide draws no product copy of its own, so neither guide seat binds the dictionary.
  94. expect(registered.map(entry => [entry.name, entry.key, entry.locale, entry.component])).toEqual([
  95. ['rightbar', undefined, undefined, RightbarRoot],
  96. ['rightbar.session', undefined, 'sidebarRight', RightbarSeat],
  97. ['conversation.session.header.corner', undefined, 'sidebarRight', ExpandButton],
  98. ['sidebar.right.pane.tab', GUIDE_ID, undefined, GuideBody],
  99. ['sidebar.right.pane.tab.title', GUIDE_ID, undefined, GuideTitle],
  100. ])
  101. // The panel declares the extension seats; the guide declares its chain child.
  102. expect(Object.keys(seat('rightbar.session').children as object)).toEqual([
  103. 'sidebar.right.pane.tab', 'sidebar.right.pane.tab.title', 'sidebar.right.tab.menu.item',
  104. ])
  105. expect(seat('sidebar.right.pane.tab').children).toMatchObject({ 'sidebar.right.tab.guide': { kind: 'chain', scope: 'session' } })
  106. // Both seats read one store: the button only needs to know whether the panel is expanded.
  107. expect(seat('rightbar.session').store).toBeDefined()
  108. expect(seat('conversation.session.header.corner').store).toBe(seat('rightbar.session').store)
  109. })
  110. it('hands the panel seat the frame report, the service binding, the opens, the observable registry, and the Tab domain', async () => {
  111. const { ctx, layout, resources, seat, injectedOf } = await boot()
  112. const injected = injectedOf(seat('rightbar.session')) as SidebarRightInjected
  113. // The frame learns the composition of expanded and presentation, nothing else.
  114. injected.syncPresentation({ shown: true, track: true, fullscreen: false })
  115. expect(layout.openRightbar).toHaveBeenLastCalledWith(true, false)
  116. injected.syncPresentation({ shown: true, track: true, fullscreen: true })
  117. expect(layout.openRightbar).toHaveBeenLastCalledWith(true, true)
  118. injected.syncPresentation({ shown: true, track: false, fullscreen: true })
  119. expect(layout.openRightbar).toHaveBeenLastCalledWith(false, true)
  120. injected.syncPresentation({ shown: false, track: false, fullscreen: false })
  121. expect(layout.closeRightbar).toHaveBeenCalledOnce()
  122. // The registry, observable: what the seat dispatches a kind to.
  123. expect(injected.hooks.tabTypes.getSnapshot().find(type => type.kind === 'guide')?.id).toBe(GUIDE_ID)
  124. const seen = vi.fn()
  125. const unsubscribe = injected.hooks.tabTypes.subscribe(seen)
  126. ctx.sidebarRightTabs.register({ id: 'spec/text', kind: 'text', patterns: ['dsh-resource://file/**'], title: () => 'text' })
  127. expect(seen).toHaveBeenCalledOnce()
  128. unsubscribe()
  129. // The binding makes the service act on this seat's session; the seat's
  130. // store instance is minted here from the handle the registration declared.
  131. const handle = seat('rightbar.session').store as ReturnType<typeof createSidebarRightStore>
  132. const instance = handle.create()
  133. instance.clearPersisted()
  134. const release = injected.bindService({ sessionId: SESSION, actions: instance.actions, surfaces: {}, canSplitPane: () => true })
  135. injected.openTab('guide', { revealIfOpened: false })
  136. const surface = instance.getSnapshot().bySession[SESSION]
  137. expect(surface?.layout.expanded).toBe(true)
  138. expect(Object.values(surface?.layout.tabs ?? {}).map(tab => tab.kind)).toEqual(['guide'])
  139. // Holding a record pins its address through the resource model.
  140. if (surface === undefined) throw new Error('expected a surface')
  141. ctx.sidebarRight.tabDomain.sync(SESSION, surface.layout)
  142. expect(resources.pin).toHaveBeenCalledWith('sidebar://guide', expect.any(AbortSignal))
  143. release()
  144. expect(() => { ctx.sidebarRight.toggleExpanded() }).toThrow('no session surface is mounted')
  145. })
  146. it('adopts each session\'s store instance as the runtime mints it, so a tab\'s own actions land with no seat bound', async () => {
  147. const { ctx, resources, seat } = await boot()
  148. const handle = seat('rightbar.session').store as ReturnType<typeof createSidebarRightStore>
  149. // Both seats declare the same wrapped handle, so either minting adopts.
  150. expect(seat('conversation.session.header.corner').store).toBe(handle)
  151. const instance = handle.create(SESSION)
  152. instance.actions.open(SESSION)
  153. // The first expansion seeds the guide; a second tab beside it makes it closable.
  154. instance.actions.setExpanded(SESSION, true)
  155. instance.actions.openContent(SESSION, { kind: 'text', contentId: 'dsh-resource://file/session/s/a.txt', title: 'a' }, () => {})
  156. const guide = Object.values(instance.getSnapshot().bySession[SESSION]?.layout.tabs ?? {}).find(tab => tab.kind === 'guide')
  157. if (guide === undefined) throw new Error('expected the seeded guide')
  158. // Held and pinned from the store's own commit: no seat synced anything.
  159. const occurrence = ctx.sidebarRight.tabDomain.occurrence(SESSION, guide)
  160. expect(resources.pin).toHaveBeenCalledWith('sidebar://guide', occurrence.signal)
  161. occurrence.tabActions.close()
  162. expect(instance.getSnapshot().bySession[SESSION]?.layout.tabs[guide.id]).toBeUndefined()
  163. expect(occurrence.signal.aborted).toBe(true)
  164. expect(ctx.sidebarRight.openTabs.getSnapshot().length).toBeGreaterThan(0)
  165. instance.clearPersisted()
  166. expect(ctx.sidebarRight.openTabs.getSnapshot()).toEqual([])
  167. })
  168. it('hands the guide body the registry\'s entry boxes, observable', async () => {
  169. const { ctx, seat, injectedOf } = await boot()
  170. const { hooks: { guideEntries } } = injectedOf(seat('sidebar.right.pane.tab')) as GuideInjected
  171. expect(guideEntries.getSnapshot()).toEqual([])
  172. const seen = vi.fn()
  173. guideEntries.subscribe(seen)
  174. ctx.sidebarRightTabs.register({
  175. id: 'spec/files',
  176. kind: 'files',
  177. title: () => 'Files',
  178. guide: [{ id: 'default', order: 10, title: () => 'Files' }],
  179. })
  180. expect(seen).toHaveBeenCalledOnce()
  181. expect(guideEntries.getSnapshot().map(entry => entry.kind)).toEqual(['files'])
  182. })
  183. it('takes every registration and both faces back when disposed, aborting the open records, so a reload registers again', async () => {
  184. const { ctx, registered, dictionaries, fiber, seat, injectedOf } = await boot()
  185. const injected = injectedOf(seat('rightbar.session')) as SidebarRightInjected
  186. const handle = seat('rightbar.session').store as ReturnType<typeof createSidebarRightStore>
  187. // Minted under the session key, so the instance is adopted and the teardown releases it.
  188. const instance = handle.create(SESSION)
  189. injected.bindService({ sessionId: SESSION, actions: instance.actions, surfaces: {}, canSplitPane: () => true })
  190. injected.openTab('guide')
  191. const surface = instance.getSnapshot().bySession[SESSION]
  192. const guide = Object.values(surface?.layout.tabs ?? {})[0]
  193. if (guide === undefined) throw new Error('expected the guide tab')
  194. const { signal, tabActions } = ctx.sidebarRight.tabDomain.occurrence(SESSION, guide)
  195. await fiber.dispose()
  196. expect(signal.aborted).toBe(true)
  197. // The adoption went with the plugin: a late action from the dead occurrence changes nothing.
  198. tabActions.close()
  199. expect(instance.getSnapshot().bySession[SESSION]?.layout.tabs[guide.id]).toBeDefined()
  200. expect(ctx.get('sidebarRight')).toBeUndefined()
  201. expect(ctx.get('sidebarRightTabs')).toBeUndefined()
  202. expect(registered).toEqual([])
  203. expect(dictionaries.size).toBe(0)
  204. await ctx.plugin({ inject: [...inject], apply }).await()
  205. expect(ctx.sidebarRightTabs.get('guide')?.id).toBe(GUIDE_ID)
  206. expect(registered).toHaveLength(5)
  207. })
  208. })