session-provider.spec.tsx 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  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. specOf: key => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
  48. isLive: () => true,
  49. storeOf: () => undefined,
  50. sessions: {
  51. list: observable<unknown>({ ids: [] }),
  52. provideInfo: provide,
  53. },
  54. workspaces: { list: observable<unknown>({ items: [] }) },
  55. }
  56. return {
  57. host,
  58. // Same driver surface as the old current cell: set(id) publishes the
  59. // resolved bundle (or the absent projection) through the provide source.
  60. current: {
  61. set: (id: string | undefined) => {
  62. currentId = id
  63. provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo)
  64. },
  65. },
  66. addSession: (id: string) => {
  67. // Bare source per bundle (identity-stable): the machinery binds useSession from it.
  68. const info: SessionProvideInfo = {
  69. sessionId: id,
  70. hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } },
  71. props: {},
  72. }
  73. infos.set(id, info)
  74. if (currentId === id) provide.set(info)
  75. return info
  76. },
  77. /** Swap one session's bundle in place (roster-change stand-in); republish when current. */
  78. replaceSession: (info: SessionProvideInfo) => {
  79. infos.set(info.sessionId, info)
  80. if (currentId === info.sessionId) provide.set(info)
  81. },
  82. registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) },
  83. }
  84. }
  85. describe('SessionProvider', () => {
  86. it('renders empty without a current session, switches to the body on select, falls back on an unresolvable id', () => {
  87. const h = makeHost({
  88. root: () => (
  89. <SessionProvider empty={() => <span>empty</span>}>
  90. {id => <div data-testid="body">{id}</div>}
  91. </SessionProvider>
  92. ),
  93. })
  94. h.addSession('s1')
  95. const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
  96. expect(view.container.textContent).toBe('empty')
  97. act(() => { h.current.set('s1') })
  98. expect(view.container.textContent).toBe('s1')
  99. act(() => { h.current.set('ghost') }) // listed nowhere: cell() misses
  100. expect(view.container.textContent).toBe('empty')
  101. })
  102. it('renders null empty state when the empty prop is omitted', () => {
  103. const h = makeHost({
  104. root: () => <SessionProvider>{id => <b>{id}</b>}</SessionProvider>,
  105. })
  106. const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
  107. expect(view.container.textContent).toBe('')
  108. })
  109. it('remounts the body on session switch (key semantics) but not on unrelated re-renders', () => {
  110. let mounts = 0
  111. function Body({ id }: { id: string }) {
  112. const mounted = useRef(false)
  113. useEffect(() => {
  114. /* v8 ignore next -- strict-mode double-invoke guard, not a branch under test */
  115. if (!mounted.current) { mounted.current = true; mounts += 1 }
  116. }, [])
  117. return <div>{id}</div>
  118. }
  119. const h = makeHost({
  120. root: () => <SessionProvider>{id => <Body id={id} />}</SessionProvider>,
  121. })
  122. h.addSession('s1')
  123. h.addSession('s2')
  124. const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
  125. act(() => { h.current.set('s1') })
  126. const afterS1 = mounts
  127. act(() => { h.current.set('s2') })
  128. expect(mounts).toBe(afterS1 + 1)
  129. const afterS2 = mounts
  130. view.rerender(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
  131. expect(mounts).toBe(afterS2)
  132. })
  133. it('delivers the resolved cell to session slots under it (observable behavior, not context internals)', () => {
  134. const seen: Record<string, unknown>[] = []
  135. const h = makeHost({
  136. root: renderSlot => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
  137. })
  138. h.addSession('s1')
  139. h.addSession('s2')
  140. h.registerSession({
  141. component: (props: { useSession?: <S>(sel: (s: { sid: string }) => S) => S; sessionId?: string }) => {
  142. // The bound hook reads the cell's bare source — asserting through it
  143. // proves the machinery wired THIS session's source, not another's.
  144. seen.push({ sessionId: props.sessionId, read: props.useSession!(s => s.sid) })
  145. return null
  146. },
  147. options: {},
  148. })
  149. render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
  150. act(() => { h.current.set('s1') })
  151. expect(seen.at(-1)!['read']).toBe('s1')
  152. expect(seen.at(-1)!['sessionId']).toBe('s1')
  153. act(() => { h.current.set('s2') })
  154. expect(seen.at(-1)!['read']).toBe('s2')
  155. expect(seen.at(-1)!['sessionId']).toBe('s2')
  156. })
  157. it('republishes a mounted session entry when its provide bundle changes under the same id', () => {
  158. const seen: unknown[] = []
  159. const h = makeHost({
  160. root: renderSlot => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
  161. })
  162. const original = h.addSession('s1')
  163. h.registerSession({
  164. component: (props: { feature?: string }) => {
  165. seen.push(props.feature)
  166. return null
  167. },
  168. options: {},
  169. })
  170. render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
  171. act(() => { h.current.set('s1') })
  172. expect(seen.at(-1)).toBeUndefined()
  173. // A provider-roster change rematerializes the bundle; the provide source
  174. // must carry it to already-mounted entries without a selection change.
  175. act(() => { h.replaceSession({ ...original, props: { feature: 'now-live' } }) })
  176. expect(seen.at(-1)).toBe('now-live')
  177. })
  178. it('fails loud when mounted outside the renderer tree (no host channel)', () => {
  179. const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
  180. expect(() => render(
  181. <SessionProvider>{id => <b>{id}</b>}</SessionProvider>,
  182. )).toThrow(/outside the installed renderer tree/)
  183. spy.mockRestore()
  184. })
  185. })