session-provider.client.spec.tsx 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. // @vitest-environment jsdom
  2. import { Fragment, useEffect, useRef } from 'react'
  3. import { Context } from '@deepseek-ai/cordis'
  4. import { describe, expect, it, vi } from 'vitest'
  5. import { act, render } from '@testing-library/react'
  6. import type {
  7. SessionProviderComponent, StoredEntry,
  8. } from '@deepseek-ai/dsh-client-ui-slots'
  9. import type {
  10. ScopedStandardSourceBinding, SlotRendererHost, SlotScopeAdapter, StandardSourceBinding,
  11. } from '@deepseek-ai/dsh-client-ui-renderer/client'
  12. import { createSlotRenderer } from '../src/client/scoped-slots.tsx'
  13. type SessionBinding = ScopedStandardSourceBinding
  14. function observable<T>(initial: T) {
  15. let value = initial
  16. const subs = new Set<() => void>()
  17. return {
  18. getSnapshot: () => value,
  19. subscribe: (fn: () => void) => { subs.add(fn); return () => { subs.delete(fn) } },
  20. set: (next: T) => { value = next; for (const fn of [...subs]) fn() },
  21. }
  22. }
  23. /**
  24. * Minimal host: SessionProvider only reads the Session scope adapter, but it must
  25. * render inside the renderer tree (HostContext), so the harness mounts a real
  26. * root entry whose body is the test's render-prop provider.
  27. */
  28. function makeHost(
  29. bodies: {
  30. root: (
  31. rp: (key: string, owner: object) => React.ReactNode,
  32. SessionProvider: SessionProviderComponent,
  33. ) => React.ReactNode
  34. },
  35. options: { installRenderArea?: boolean } = {},
  36. ) {
  37. const scopeCtx = new Context()
  38. const absentBinding: StandardSourceBinding = {
  39. key: undefined,
  40. hooks: { session: undefined },
  41. keyedHooks: {},
  42. props: { sessionId: undefined },
  43. }
  44. const currentBinding = observable<StandardSourceBinding>(absentBinding)
  45. let currentId: string | undefined
  46. const bindings = new Map<string, SessionBinding>()
  47. const sessionEntries: StoredEntry[] = []
  48. const root = observable<StandardSourceBinding>({
  49. key: undefined,
  50. hooks: {},
  51. keyedHooks: {},
  52. props: {},
  53. })
  54. const sessionAdapter: SlotScopeAdapter = {
  55. current: currentBinding,
  56. resolve: key => bindings.get(key),
  57. ...(options.installRenderArea === false
  58. ? {}
  59. : {
  60. renderArea: (binding, { empty, children }) => binding.key === undefined
  61. ? <>{empty?.() ?? null}</>
  62. : <Fragment key={binding.key}>{children}</Fragment>,
  63. }),
  64. }
  65. const rootEntry: StoredEntry = {
  66. component: (props: {
  67. renderSlot: (key: string, owner: object) => React.ReactNode
  68. SessionProvider: SessionProviderComponent
  69. }) => <>{bodies.root(props.renderSlot, props.SessionProvider)}</>,
  70. options: {},
  71. children: { 'k.session': { kind: 'single', scope: 'session' } },
  72. }
  73. const host: SlotRendererHost = {
  74. subscribe: () => () => {},
  75. getVersion: () => 0,
  76. entriesOf: key => key === 'root' ? [rootEntry] : sessionEntries,
  77. // Single-kind everywhere and no crashes in this suite: the projection is
  78. // the raw view and crash reports never fire.
  79. entriesOfSlot: key => key === 'root' ? [rootEntry] : sessionEntries,
  80. reportEntryError: () => {},
  81. specOf: key => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
  82. isLive: () => true,
  83. storeOf: () => undefined,
  84. root,
  85. scopeRevision: observable(0),
  86. scope: () => sessionAdapter,
  87. }
  88. return {
  89. host,
  90. // Driver surface: set(id) publishes the resolved binding (or the absent
  91. // projection) through the scope adapter.
  92. current: {
  93. set: (id: string | undefined) => {
  94. currentId = id
  95. currentBinding.set((id === undefined ? undefined : bindings.get(id)) ?? absentBinding)
  96. },
  97. },
  98. addSession: (id: string) => {
  99. // Bare source per binding (identity-stable): the machinery binds useSession from it.
  100. const binding: SessionBinding = {
  101. key: id,
  102. ctx: scopeCtx,
  103. hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } },
  104. keyedHooks: {},
  105. props: { sessionId: id },
  106. }
  107. bindings.set(id, binding)
  108. if (currentId === id) currentBinding.set(binding)
  109. return binding
  110. },
  111. /** Swap one session's binding in place (roster-change stand-in); republish when current. */
  112. replaceSession: (binding: SessionBinding) => {
  113. bindings.set(binding.key, binding)
  114. if (currentId === binding.key) currentBinding.set(binding)
  115. },
  116. registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) },
  117. }
  118. }
  119. describe('SessionProvider', () => {
  120. it('renders empty without a current session, switches to the body on select, falls back on an unresolvable id', () => {
  121. const h = makeHost({
  122. root: (_renderSlot, SessionProvider) => (
  123. <SessionProvider empty={() => <span>empty</span>}>
  124. <div data-testid="body">session</div>
  125. </SessionProvider>
  126. ),
  127. })
  128. h.addSession('s1')
  129. const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
  130. expect(view.container.textContent).toBe('empty')
  131. act(() => { h.current.set('s1') })
  132. expect(view.container.textContent).toBe('session')
  133. act(() => { h.current.set('ghost') }) // listed nowhere: cell() misses
  134. expect(view.container.textContent).toBe('empty')
  135. })
  136. it('renders null empty state when the empty prop is omitted', () => {
  137. const h = makeHost({
  138. root: (_renderSlot, SessionProvider) => <SessionProvider><b>session</b></SessionProvider>,
  139. })
  140. const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
  141. expect(view.container.textContent).toBe('')
  142. })
  143. it('remounts the body on session switch (key semantics) but not on unrelated re-renders', () => {
  144. let mounts = 0
  145. function Body({ id }: { id: string }) {
  146. const mounted = useRef(false)
  147. useEffect(() => {
  148. /* v8 ignore next -- strict-mode double-invoke guard, not a branch under test */
  149. if (!mounted.current) { mounted.current = true; mounts += 1 }
  150. }, [])
  151. return <div>{id}</div>
  152. }
  153. const h = makeHost({
  154. root: (renderSlot, SessionProvider) => (
  155. <SessionProvider>{renderSlot('k.session', {})}</SessionProvider>
  156. ),
  157. })
  158. h.registerSession({
  159. component: (props: { sessionId?: string }) => <Body id={props.sessionId ?? 'missing'} />,
  160. options: {},
  161. })
  162. h.addSession('s1')
  163. h.addSession('s2')
  164. const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
  165. act(() => { h.current.set('s1') })
  166. const afterS1 = mounts
  167. act(() => { h.current.set('s2') })
  168. expect(mounts).toBe(afterS1 + 1)
  169. const afterS2 = mounts
  170. view.rerender(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
  171. expect(mounts).toBe(afterS2)
  172. })
  173. it('delivers the resolved cell to session slots under it (observable behavior, not context internals)', () => {
  174. const seen: Record<string, unknown>[] = []
  175. const h = makeHost({
  176. root: (renderSlot, SessionProvider) => (
  177. <SessionProvider>{renderSlot('k.session', {})}</SessionProvider>
  178. ),
  179. })
  180. h.addSession('s1')
  181. h.addSession('s2')
  182. h.registerSession({
  183. component: (props: { useSession?: <S>(sel: (s: { sid: string }) => S) => S; sessionId?: string }) => {
  184. // The bound hook reads the cell's bare source — asserting through it
  185. // proves the machinery wired THIS session's source, not another's.
  186. seen.push({ sessionId: props.sessionId, read: props.useSession!(s => s.sid) })
  187. return null
  188. },
  189. options: {},
  190. })
  191. render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
  192. act(() => { h.current.set('s1') })
  193. expect(seen.at(-1)!['read']).toBe('s1')
  194. expect(seen.at(-1)!['sessionId']).toBe('s1')
  195. act(() => { h.current.set('s2') })
  196. expect(seen.at(-1)!['read']).toBe('s2')
  197. expect(seen.at(-1)!['sessionId']).toBe('s2')
  198. })
  199. it('republishes a mounted session entry when its provide bundle changes under the same id', () => {
  200. const seen: unknown[] = []
  201. const h = makeHost({
  202. root: (renderSlot, SessionProvider) => (
  203. <SessionProvider>{renderSlot('k.session', {})}</SessionProvider>
  204. ),
  205. })
  206. const original = h.addSession('s1')
  207. h.registerSession({
  208. component: (props: { feature?: string }) => {
  209. seen.push(props.feature)
  210. return null
  211. },
  212. options: {},
  213. })
  214. render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
  215. act(() => { h.current.set('s1') })
  216. expect(seen.at(-1)).toBeUndefined()
  217. // A provider-roster change rematerializes the binding; the scope source
  218. // must carry it to already-mounted entries without a selection change.
  219. act(() => { h.replaceSession({ ...original, props: { feature: 'now-live' } }) })
  220. expect(seen.at(-1)).toBe('now-live')
  221. })
  222. it('fails loud when the Session scope owner omits its area renderer', () => {
  223. const h = makeHost({ root: () => null }, { installRenderArea: false })
  224. const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
  225. expect(() => render(<>{createSlotRenderer().renderRoot(h.host, {})}</>))
  226. .toThrow(/does not provide its area renderer/)
  227. spy.mockRestore()
  228. })
  229. })