session-provider.client.spec.tsx 7.9 KB

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