helpers.client.spec.tsx 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. // @vitest-environment jsdom
  2. import { act, cleanup, renderHook } from '@testing-library/react'
  3. import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client'
  4. import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
  5. import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-chat/client'
  6. import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client'
  7. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  8. import type { MainPanelId, PanelInfo } from '@deepseek-ai/dsh-client-ui-layout/client'
  9. import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
  10. import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'
  11. import {
  12. bindSnapshotSelector,
  13. chatSnapshot,
  14. conversationSnapshot,
  15. SlotTestRuntime,
  16. usePinnedBrowserLanguages,
  17. } from '../src/index.ts'
  18. const originalLanguages = [...navigator.languages]
  19. const originalLanguage = navigator.language
  20. declare module '@deepseek-ai/dsh-client-ui-slots' {
  21. interface SlotMap {
  22. 'trt.panel-info': { kind: 'keyed'; scope: 'root'; owner: { label: string } }
  23. }
  24. }
  25. usePinnedBrowserLanguages('zh-CN', 'en-US')
  26. afterEach(cleanup)
  27. afterAll(() => {
  28. expect(navigator.languages).toEqual(originalLanguages)
  29. expect(navigator.language).toBe(originalLanguage)
  30. })
  31. function entry(seq: number): SessionLiveEventEntry {
  32. return {
  33. type: 'event',
  34. event: {
  35. type: 'fixture/event',
  36. seq,
  37. time: seq,
  38. data: { seq },
  39. ignorable: true,
  40. } as unknown as SessionLiveEventEntry['event'],
  41. }
  42. }
  43. describe('fixture helpers', () => {
  44. it.each([false, true])('retracts default root sources without removing replacements (release first: %s)', async (releaseFirst) => {
  45. const runtime = await SlotTestRuntime.create()
  46. const hooks = { workspaces: runtime.workspaces.list, panelInfo: runtime.panelInfo }
  47. let releaseReplacement: (() => void) | undefined
  48. try {
  49. if (releaseFirst) {
  50. runtime.releaseWorkspaceSource()
  51. runtime.releasePanelInfoSource()
  52. } else {
  53. await runtime.dispose()
  54. }
  55. releaseReplacement = runtime.slots.provideRoot({ hooks })
  56. await runtime.dispose()
  57. await runtime.dispose()
  58. for (const key of ['workspaces', 'panelInfo'] as const) {
  59. expect(() => runtime.slots.provideRoot({ hooks: { [key]: hooks[key] } }))
  60. .toThrow(`duplicate root standard hook '${key}'`)
  61. }
  62. } finally {
  63. try {
  64. releaseReplacement?.()
  65. runtime.releaseWorkspaceSource()
  66. runtime.releasePanelInfoSource()
  67. } finally {
  68. await runtime.dispose()
  69. }
  70. }
  71. })
  72. it('drives panel hooks, retains keyed selection on owner updates, and releases the default source', async () => {
  73. const runtime = await SlotTestRuntime.create()
  74. try {
  75. await runtime.declare({ 'trt.panel-info': { kind: 'keyed', scope: 'root' } })
  76. runtime.slots.register({ name: 'trt.panel-info', key: 'probe' },
  77. ({ usePanelInfo, label }: PropsRuntime<'trt.panel-info'>) => (
  78. <span>{label}:{usePanelInfo(info => info.activePanelId) ?? 'conversation'}</span>
  79. ))
  80. const view = runtime.renderSlot('trt.panel-info', { label: 'first' }, { entryKey: 'probe' })
  81. expect(view.container.textContent).toBe('first:conversation')
  82. act(() => { runtime.panelInfo.set({ activePanelId: 'custom' as MainPanelId }) })
  83. expect(view.container.textContent).toBe('first:custom')
  84. view.update({ label: 'next' })
  85. expect(view.container.textContent).toBe('next:custom')
  86. const replacement = createSnapshotStore<PanelInfo>({ activePanelId: null })
  87. await act(async () => {
  88. runtime.releasePanelInfoSource()
  89. await runtime.mount({
  90. inject: ['slots'],
  91. apply(ctx) { ctx.slots.provideRoot({ hooks: { panelInfo: replacement } }) },
  92. })
  93. })
  94. expect(view.container.textContent).toBe('next:conversation')
  95. } finally {
  96. await runtime.dispose()
  97. }
  98. })
  99. it('rejects an upload until a suite replaces the default stub', async () => {
  100. const runtime = await SlotTestRuntime.create()
  101. expect(runtime.fileUpload.available).toBe(false)
  102. await expect(runtime.fileUpload.upload('fixture-session' as SessionId)).rejects.toThrow('file upload is not stubbed')
  103. await runtime.dispose()
  104. })
  105. it('builds independent Conversation and Chat snapshots with optional overrides', () => {
  106. const conversation = conversationSnapshot()
  107. expect(conversation).toEqual(EMPTY_CONVERSATION_SNAPSHOT)
  108. expect(conversation).not.toBe(EMPTY_CONVERSATION_SNAPSHOT)
  109. const activeTargets = new Set(['chat'])
  110. expect(conversationSnapshot({ activeTargets }).activeTargets).toBe(activeTargets)
  111. const chat = chatSnapshot()
  112. expect(chat).toEqual(EMPTY_CHAT_SNAPSHOT)
  113. expect(chat).not.toBe(EMPTY_CHAT_SNAPSHOT)
  114. const order = ['node-1']
  115. expect(chatSnapshot({ order }).order).toBe(order)
  116. })
  117. it('binds an observable snapshot through the production selector hook', () => {
  118. const source = createSnapshotStore({ value: 1 })
  119. const useValue = bindSnapshotSelector(source)
  120. const view = renderHook(() => useValue(snapshot => snapshot.value))
  121. expect(view.result.current).toBe(1)
  122. act(() => { source.update((draft) => { draft.value = 2 }) })
  123. expect(view.result.current).toBe(2)
  124. })
  125. it('pins both browser language fields for the calling suite', () => {
  126. expect(navigator.languages).toEqual(['zh-CN', 'en-US'])
  127. expect(navigator.language).toBe('zh-CN')
  128. })
  129. })
  130. describe('Session fixture lifecycle', () => {
  131. it('initializes and drives complete event windows through replace, prepend, and append', async () => {
  132. const runtime = await SlotTestRuntime.create()
  133. const first = entry(1)
  134. const older = entry(0)
  135. const live = entry(2)
  136. await runtime.sessions.add({ id: 'events', events: [first] }, { current: false })
  137. expect(runtime.sessions.behavior('events').eventSource.getSnapshot()).toMatchObject({
  138. entries: [first],
  139. hasMore: false,
  140. change: { kind: 'replace', entries: [first] },
  141. })
  142. await runtime.sessions.add({ id: 'has-more', hasMore: true }, { current: false })
  143. expect(runtime.sessions.behavior('has-more').eventSource.getSnapshot()).toMatchObject({
  144. entries: [],
  145. hasMore: true,
  146. })
  147. await runtime.sessions.replaceEvents('events', [first])
  148. await runtime.sessions.prependEvents('events', [older])
  149. await runtime.sessions.appendEvent('events', live)
  150. expect(runtime.sessions.behavior('events').eventSource.getSnapshot()).toMatchObject({
  151. entries: [older, first, live],
  152. hasMore: false,
  153. change: { kind: 'append', entries: [live] },
  154. })
  155. await runtime.dispose()
  156. })
  157. it('requires an explicit create stub and records successful create and refresh calls', async () => {
  158. const runtime = await SlotTestRuntime.create()
  159. await expect(runtime.sessions.create()).rejects.toThrow(/create is not stubbed/)
  160. await runtime.sessions.add({ id: 'created' }, { current: false })
  161. const create = vi.fn(() => Promise.resolve('created' as SessionId))
  162. runtime.sessions.stubCreate(create)
  163. await expect(runtime.sessions.create({ cwd: '/workspace' })).resolves.toBe('created')
  164. await expect(runtime.sessions.refresh()).resolves.toBeUndefined()
  165. expect(create).toHaveBeenCalledWith({ cwd: '/workspace' })
  166. expect(runtime.sessions.calls.slice(-2)).toEqual([
  167. { method: 'create', args: [{ cwd: '/workspace' }] },
  168. { method: 'refresh', args: [] },
  169. ])
  170. await runtime.dispose()
  171. })
  172. it('disposes a scope without materializing a binding', async () => {
  173. const runtime = await SlotTestRuntime.create()
  174. await runtime.sessions.add({ id: 'scope-only' }, { current: false })
  175. const scope = runtime.sessions.scope('scope-only')
  176. expect(scope).toBeDefined()
  177. const release = vi.fn()
  178. scope?.effect(() => release, 'fixture scope release')
  179. runtime.releaseWorkspaceSource()
  180. await runtime.dispose()
  181. expect(release).toHaveBeenCalledOnce()
  182. })
  183. })