views.spec.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. // @vitest-environment jsdom
  2. /**
  3. * View registration acceptance on the real framework stack: the plugin fiber
  4. * registers trajectory/waterfall into a real SlotsService view ring, tabs
  5. * switch inside ConversationRoot (renderSlot share driven by the same tab
  6. * projection apply uses) without collapsing chat, trajectory renders the
  7. * turn-list chrome (no span stats bar), waterfall keeps in-body stats, and
  8. * fiber disposal removes both tabs. Span derivation edge cases ride along.
  9. */
  10. import { Context } from 'cordis'
  11. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  12. import { cleanup, fireEvent, render, screen } from '@testing-library/react'
  13. import { createElement, type FC, type ReactNode } from 'react'
  14. import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
  15. import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
  16. import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
  17. import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
  18. import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
  19. import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
  20. // Export discipline: packages/client/AGENTS.md.
  21. import { ConversationRoot, type ConversationRootProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationRoot.tsx'
  22. import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
  23. import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
  24. import { deriveSpans, deriveSpanStats } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/spans.ts'
  25. import { TrajectoryStatsHeader } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryStatsHeader.tsx'
  26. import { TrajectoryView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryView.tsx'
  27. import { WaterfallView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/WaterfallView.tsx'
  28. import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
  29. const SID = 's1' as SessionId
  30. /** Fallback-only chain stub (no composer takeover in these benches). */
  31. const fallbackRenderSlotChain: ConversationRootProps['renderSlotChain'] =
  32. (_key, _owner, opts) => opts?.fallback ?? null
  33. afterEach(cleanup)
  34. // The chat store persists under its declared key; clear so one case's active
  35. // view cannot rehydrate into the next.
  36. beforeEach(() => {
  37. // Node 22+ exposes an experimental localStorage global that is undefined
  38. // without --localstorage-file; only clear when a real Storage is present.
  39. if (typeof localStorage !== 'undefined') localStorage.clear()
  40. })
  41. /** Node fixture: user prologue, two turns, one tool result inside turn 1. */
  42. const NODES = [
  43. { kind: 'user', seq: 1, time: 1_000, content: [], source: null },
  44. { kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [] },
  45. {
  46. kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: null,
  47. content: [], isError: false, callView: null, resultView: null,
  48. },
  49. { kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [] },
  50. ] as unknown as ConversationSnapshot['nodes']
  51. function fakeSession(nodes: ConversationSnapshot['nodes']) {
  52. const store = createSnapshotStore({
  53. nodes, partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
  54. })
  55. return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
  56. }
  57. /** Empty sessions-list hook stub (breadcrumbs fall back to the raw id; engines carry no hook since the store migration — bind here). */
  58. function emptySessions() {
  59. const store = createSnapshotStore<SessionListState>(
  60. { ids: [], byId: {}, current: undefined } as SessionListState)
  61. return bindSnapshotSelector(store)
  62. }
  63. /** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
  64. const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
  65. /** Standalone view props: the session-scope standard kit the outlet would bake. */
  66. function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
  67. return {
  68. sessionId: SID,
  69. useSession: fakeSession(nodes).useSession,
  70. useSessions: emptySessions(),
  71. } as unknown as ConvViewProps
  72. }
  73. /** Real-stack bench: root Context + real SlotsService ring + the plugin fiber. */
  74. async function bench() {
  75. const ctx = new Context()
  76. const slots = new SlotsService(ctx)
  77. // The conversation entry's role: declare the ring, then seed the chat entry.
  78. slots.register({
  79. name: 'root',
  80. children: { 'conversation.view': { kind: 'list', scope: 'session' } },
  81. }, (_p: { renderSlot?: unknown }) => null)
  82. const chatBody = vi.fn(() => <div data-testid="chat-body" />)
  83. slots.register(
  84. { name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never)
  85. // 'conversation' inject is an ordering edge; the bench declares the ring
  86. // itself, so a stub satisfies the wait.
  87. ctx.provide('conversation', {})
  88. const fiber = ctx.plugin({ inject: [...inject], apply })
  89. await fiber.await()
  90. return { ctx, slots, fiber }
  91. }
  92. /** Tab projection twin of apply's viewTabs (the render-side consumption path). */
  93. function tabsOf(slots: SlotsService): ViewTab[] {
  94. return slots.entries('conversation.view')
  95. .map(e => ({ id: e.options.id!, label: e.options.label ?? e.options.id! }))
  96. }
  97. /** Mount ConversationRoot over the ring ledger with an outlet-faithful renderSlot. */
  98. function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
  99. const sessionSnapshot = createSnapshotStore({
  100. running: false, removed: false, promptError: null, nodes,
  101. partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
  102. })
  103. const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
  104. const chat = createChatStore().create()
  105. // Minimal outlet twin: resolve the ring entry by the `only` filter and
  106. // render it with the session standard kit (what SlotOutlet does for a
  107. // list-kind session slot, minus machinery).
  108. const renderSlot = ((key: string, _owner: object, opts?: { only?: string }): ReactNode => {
  109. const entry = slots.entries('conversation.view').find(e => e.options.id === opts?.only)
  110. if (entry === undefined) return null
  111. const View = entry.component as FC<ConvViewProps>
  112. return (
  113. <View
  114. {...({ sessionId: SID, useSession, useSessions: emptySessions() } as unknown as ConvViewProps)}
  115. key={key}
  116. />
  117. )
  118. }) as unknown as ConversationRootProps['renderSlot']
  119. return render(
  120. <ConversationRoot
  121. sessionId={SID}
  122. useSession={useSession}
  123. useSessions={emptySessions()}
  124. useStore={bindSnapshotSelector(chat)}
  125. actions={chat.actions}
  126. renderSlot={renderSlot}
  127. renderSlotChain={fallbackRenderSlotChain}
  128. SessionProvider={SessionProviderStub}
  129. views={{
  130. list: () => tabsOf(slots),
  131. subscribe: (fn) => slots.subscribe('conversation.view', fn),
  132. version: () => slots.getVersion('conversation.view'),
  133. }}
  134. send={vi.fn()}
  135. stop={vi.fn()}
  136. open={vi.fn()}
  137. />,
  138. )
  139. }
  140. describe('plugin registration', () => {
  141. it('registers trajectory and waterfall after chat on the ring', async () => {
  142. const b = await bench()
  143. expect(tabsOf(b.slots)).toEqual([
  144. { id: 'chat', label: 'Chat' },
  145. { id: 'trajectory', label: 'Trajectory' },
  146. { id: 'waterfall', label: 'Waterfall' },
  147. ])
  148. })
  149. it('fiber disposal removes both tabs and leaves chat standing', async () => {
  150. const b = await bench()
  151. await b.fiber.dispose()
  152. expect(tabsOf(b.slots).map((v) => v.id)).toEqual(['chat'])
  153. })
  154. })
  155. describe('tab switching in ConversationRoot', () => {
  156. it('renders all three tabs, defaults to chat, and switches to trajectory without stats chrome', async () => {
  157. const b = await bench()
  158. mount(b.slots)
  159. expect(screen.getByTestId('chat-body')).toBeTruthy()
  160. expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
  161. fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
  162. // Trajectory no longer mounts the span stats bar; the turn-list chrome owns the body.
  163. expect(screen.queryByText(/turns ·/)).toBeNull()
  164. expect(screen.getByText('Turn 1')).toBeTruthy()
  165. expect(screen.getByText('Turn 2')).toBeTruthy()
  166. expect(screen.getAllByText('Message').length).toBeGreaterThan(0)
  167. expect(screen.getAllByText('Step 1').length).toBeGreaterThan(0)
  168. expect(screen.getAllByText('Input').length).toBeGreaterThan(0)
  169. expect(screen.queryByTestId('chat-body')).toBeNull()
  170. })
  171. it('waterfall renders bars and switching back to chat does not collapse it', async () => {
  172. const b = await bench()
  173. mount(b.slots)
  174. fireEvent.click(screen.getByRole('tab', { name: 'Waterfall' }))
  175. expect(screen.getByTitle('2 nodes')).toBeTruthy()
  176. expect(screen.getByTitle('1 tool calls')).toBeTruthy()
  177. fireEvent.click(screen.getByRole('tab', { name: 'Chat' }))
  178. expect(screen.getByTestId('chat-body')).toBeTruthy()
  179. })
  180. it('empty window: placeholder copy in the body, the stats header renders nothing', async () => {
  181. const b = await bench()
  182. mount(b.slots, [] as unknown as ConversationSnapshot['nodes'])
  183. fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
  184. expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
  185. expect(screen.queryByText(/turns ·/)).toBeNull()
  186. })
  187. })
  188. describe('span derivation', () => {
  189. it('attributes prologue to turn 0 and follows steering turn tags', () => {
  190. const nodes = [
  191. { kind: 'user', seq: 1 },
  192. { kind: 'steering', seq: 2, turn: 5 },
  193. { kind: 'user', seq: 3 },
  194. ] as unknown as ConversationSnapshot['nodes']
  195. const spans = deriveSpans(nodes)
  196. expect(spans).toEqual([
  197. { turn: 0, steps: 0, calls: 0, nodes: 1 },
  198. { turn: 5, steps: 0, calls: 0, nodes: 2 },
  199. ])
  200. expect(deriveSpanStats(spans)).toEqual({ turns: 2, steps: 0, calls: 0 })
  201. })
  202. it('empty inputs produce zero stats and standalone components render their empty forms', () => {
  203. expect(deriveSpanStats(deriveSpans([] as unknown as ConversationSnapshot['nodes']))).toEqual({ turns: 0, steps: 0, calls: 0 })
  204. const { useSession } = fakeSession([] as unknown as ConversationSnapshot['nodes'])
  205. const { container } = render(createElement(TrajectoryStatsHeader, { useSession: useSession as never }))
  206. expect(container.firstChild).toBeNull()
  207. render(createElement(TrajectoryView as FC<ConvViewProps>,
  208. standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
  209. expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
  210. })
  211. })
  212. describe('WaterfallView standalone branches', () => {
  213. it('empty window renders the placeholder copy', () => {
  214. render(createElement(WaterfallView as FC<ConvViewProps>,
  215. standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
  216. expect(screen.getByText('暂无瀑布数据')).toBeTruthy()
  217. })
  218. it('a turn without tool calls renders the node bar only', () => {
  219. const nodes = [{ kind: 'user', seq: 1 }] as unknown as ConversationSnapshot['nodes']
  220. render(createElement(WaterfallView as FC<ConvViewProps>, standaloneProps(nodes)))
  221. expect(screen.getByTitle('1 nodes')).toBeTruthy()
  222. expect(screen.queryByTitle(/tool calls/)).toBeNull()
  223. })
  224. })
  225. describe('node half', () => {
  226. it('node apply is an intentional no-op (loader-managed lifecycle only)', () => {
  227. expect(nodeApply()).toBeUndefined()
  228. })
  229. })