session-provider.spec.tsx 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  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 { 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.current/cell, 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 current = observable<string | undefined>(undefined)
  33. const infos = new Map<string, SessionProvideInfo>()
  34. const sessionEntries: StoredEntry[] = []
  35. const rootEntry: StoredEntry = {
  36. component: (props: { renderSlot: (key: string, owner: object) => React.ReactNode }) =>
  37. <>{bodies.root(props.renderSlot)}</>,
  38. options: {},
  39. children: { 'k.session': { kind: 'single', scope: 'session' } },
  40. }
  41. const host: SlotRendererHost = {
  42. subscribe: () => () => {},
  43. getVersion: () => 0,
  44. entriesOf: key => key === 'root' ? [rootEntry] : sessionEntries,
  45. specOf: key => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
  46. isLive: () => true,
  47. storeOf: () => undefined,
  48. sessions: {
  49. list: observable<unknown>({ ids: [] }),
  50. current,
  51. provideInfo: id => infos.get(id),
  52. maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id))
  53. ?? { sessionId: undefined, hooks: { session: undefined }, props: {} },
  54. },
  55. workspaces: { list: observable<unknown>({ items: [] }) },
  56. }
  57. return {
  58. host,
  59. current,
  60. addSession: (id: string) => {
  61. // Bare source per bundle (identity-stable): the machinery binds useSession from it.
  62. const info: SessionProvideInfo = {
  63. sessionId: id,
  64. hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } },
  65. props: {},
  66. }
  67. infos.set(id, info)
  68. return info
  69. },
  70. registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) },
  71. }
  72. }
  73. describe('SessionProvider', () => {
  74. it('renders empty without a current session, switches to the body on select, falls back on an unresolvable id', () => {
  75. const h = makeHost({
  76. root: () => (
  77. <SessionProvider empty={() => <span>empty</span>}>
  78. {id => <div data-testid="body">{id}</div>}
  79. </SessionProvider>
  80. ),
  81. })
  82. h.addSession('s1')
  83. const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
  84. expect(view.container.textContent).toBe('empty')
  85. act(() => { h.current.set('s1') })
  86. expect(view.container.textContent).toBe('s1')
  87. act(() => { h.current.set('ghost') }) // listed nowhere: cell() misses
  88. expect(view.container.textContent).toBe('empty')
  89. })
  90. it('renders null empty state when the empty prop is omitted', () => {
  91. const h = makeHost({
  92. root: () => <SessionProvider>{id => <b>{id}</b>}</SessionProvider>,
  93. })
  94. const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
  95. expect(view.container.textContent).toBe('')
  96. })
  97. it('remounts the body on session switch (key semantics) but not on unrelated re-renders', () => {
  98. let mounts = 0
  99. function Body({ id }: { id: string }) {
  100. const mounted = useRef(false)
  101. useEffect(() => {
  102. /* v8 ignore next -- strict-mode double-invoke guard, not a branch under test */
  103. if (!mounted.current) { mounted.current = true; mounts += 1 }
  104. }, [])
  105. return <div>{id}</div>
  106. }
  107. const h = makeHost({
  108. root: () => <SessionProvider>{id => <Body id={id} />}</SessionProvider>,
  109. })
  110. h.addSession('s1')
  111. h.addSession('s2')
  112. const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
  113. act(() => { h.current.set('s1') })
  114. const afterS1 = mounts
  115. act(() => { h.current.set('s2') })
  116. expect(mounts).toBe(afterS1 + 1)
  117. const afterS2 = mounts
  118. view.rerender(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
  119. expect(mounts).toBe(afterS2)
  120. })
  121. it('delivers the resolved cell to session slots under it (observable behavior, not context internals)', () => {
  122. const seen: Record<string, unknown>[] = []
  123. const h = makeHost({
  124. root: renderSlot => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
  125. })
  126. h.addSession('s1')
  127. h.addSession('s2')
  128. h.registerSession({
  129. component: (props: { useSession?: <S>(sel: (s: { sid: string }) => S) => S; sessionId?: string }) => {
  130. // The bound hook reads the cell's bare source — asserting through it
  131. // proves the machinery wired THIS session's source, not another's.
  132. seen.push({ sessionId: props.sessionId, read: props.useSession!(s => s.sid) })
  133. return null
  134. },
  135. options: {},
  136. })
  137. render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
  138. act(() => { h.current.set('s1') })
  139. expect(seen.at(-1)!['read']).toBe('s1')
  140. expect(seen.at(-1)!['sessionId']).toBe('s1')
  141. act(() => { h.current.set('s2') })
  142. expect(seen.at(-1)!['read']).toBe('s2')
  143. expect(seen.at(-1)!['sessionId']).toBe('s2')
  144. })
  145. it('fails loud when mounted outside the renderer tree (no host channel)', () => {
  146. const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
  147. expect(() => render(
  148. <SessionProvider>{id => <b>{id}</b>}</SessionProvider>,
  149. )).toThrow(/outside the installed renderer tree/)
  150. spy.mockRestore()
  151. })
  152. })