views.spec.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  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, WorkspaceListState } 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 { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.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, deriveSubSpans } 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. afterEach(cleanup)
  31. // The chat store persists under its declared key; clear so one case's active
  32. // view cannot rehydrate into the next.
  33. beforeEach(() => {
  34. // Node 22+ exposes an experimental localStorage global that is undefined
  35. // without --localstorage-file; only clear when a real Storage is present.
  36. if (typeof localStorage !== 'undefined') localStorage.clear()
  37. })
  38. /** Node fixture: user prologue, two turns, one tool result inside turn 1. */
  39. const NODES = [
  40. { kind: 'user', seq: 1, time: 1_000, content: [], source: null },
  41. { kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [] },
  42. {
  43. kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: null,
  44. content: [], isError: false, callView: null, resultView: null,
  45. },
  46. { kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [] },
  47. ] as unknown as ConversationSnapshot['nodes']
  48. function fakeSession(nodes: ConversationSnapshot['nodes']) {
  49. const store = createSnapshotStore({
  50. nodes, partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
  51. })
  52. return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
  53. }
  54. /** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
  55. function emptySessions() {
  56. const store = createSnapshotStore<SessionListState>(
  57. { ids: [], byId: {}, current: undefined, phase: 'ready' })
  58. return bindSnapshotSelector(store)
  59. }
  60. function emptyWorkspaces() {
  61. const store = createSnapshotStore<WorkspaceListState>({
  62. items: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true,
  63. recentWorkspaceId: undefined,
  64. })
  65. return bindSnapshotSelector(store)
  66. }
  67. /** Standalone view props: the session-scope standard kit the outlet would bake. */
  68. function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
  69. return {
  70. sessionId: SID,
  71. useSession: fakeSession(nodes).useSession,
  72. useSessions: emptySessions(),
  73. useWorkspaces: emptyWorkspaces(),
  74. } as unknown as ConvViewProps
  75. }
  76. /** Real-stack bench: root Context + real SlotsService ring + the plugin fiber. */
  77. async function bench() {
  78. const ctx = new Context()
  79. const slots = new SlotsService(ctx)
  80. // The conversation entry's role: declare the ring, then seed the chat entry.
  81. slots.register({
  82. name: 'root',
  83. children: { 'conversation.view': { kind: 'list', scope: 'session' } },
  84. }, (_p: { renderSlot?: unknown }) => null)
  85. const chatBody = vi.fn(() => <div data-testid="chat-body" />)
  86. slots.register(
  87. { name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never)
  88. // 'conversation' inject is an ordering edge; the bench declares the ring
  89. // itself, so a stub satisfies the wait.
  90. ctx.provide('conversation', {})
  91. const fiber = ctx.plugin({ inject: [...inject], apply })
  92. await fiber.await()
  93. return { ctx, slots, fiber }
  94. }
  95. /** Tab projection twin of apply's viewTabs (the render-side consumption path). */
  96. function tabsOf(slots: SlotsService): ViewTab[] {
  97. return slots.entries('conversation.view')
  98. .map(e => ({ id: e.options.id!, label: e.options.label ?? e.options.id! }))
  99. }
  100. /** Mount the strict session content over the ring ledger with an outlet-faithful renderSlot. */
  101. function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
  102. const sessionSnapshot = createSnapshotStore({
  103. running: false, removed: false, promptError: null, nodes,
  104. partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
  105. })
  106. const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
  107. const chat = createChatStore().create()
  108. // Minimal outlet twin: resolve the ring entry by the `only` filter and
  109. // render it with the session standard kit (what SlotOutlet does for a
  110. // list-kind session slot, minus machinery).
  111. const renderSlot = ((key: string, _owner: object, opts?: { only?: string }): ReactNode => {
  112. const entry = slots.entries('conversation.view').find(e => e.options.id === opts?.only)
  113. if (entry === undefined) return null
  114. const View = entry.component as FC<ConvViewProps>
  115. return (
  116. <View
  117. {...({ sessionId: SID, useSession, useSessions: emptySessions(), useWorkspaces: emptyWorkspaces() } as unknown as ConvViewProps)}
  118. key={key}
  119. />
  120. )
  121. }) as unknown as ConversationSessionProps['renderSlot']
  122. return render(
  123. <ConversationSession
  124. sessionId={SID}
  125. SessionProvider={({ children }) => children(SID)}
  126. useSession={useSession}
  127. useSessions={emptySessions()}
  128. useWorkspaces={emptyWorkspaces()}
  129. useStore={bindSnapshotSelector(chat)}
  130. actions={chat.actions}
  131. renderSlot={renderSlot}
  132. views={{
  133. list: () => tabsOf(slots),
  134. subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn),
  135. version: () => slots.getVersion('conversation.view'),
  136. }}
  137. useInput={bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never}
  138. inputActions={{ setDraft: vi.fn(), submit: vi.fn() } as never}
  139. bindDraftMirror={() => () => {}}
  140. open={vi.fn()}
  141. />,
  142. )
  143. }
  144. describe('plugin registration', () => {
  145. it('registers trajectory and waterfall after chat on the ring', async () => {
  146. const b = await bench()
  147. expect(tabsOf(b.slots)).toEqual([
  148. { id: 'chat', label: 'Chat' },
  149. { id: 'trajectory', label: 'Trajectory' },
  150. { id: 'waterfall', label: 'Waterfall' },
  151. ])
  152. })
  153. it('fiber disposal removes both tabs and leaves chat standing', async () => {
  154. const b = await bench()
  155. await b.fiber.dispose()
  156. expect(tabsOf(b.slots).map((v) => v.id)).toEqual(['chat'])
  157. })
  158. })
  159. describe('tab switching in ConversationRoot', () => {
  160. it('renders all three tabs, defaults to chat, and switches to trajectory without stats chrome', async () => {
  161. const b = await bench()
  162. mount(b.slots)
  163. expect(screen.getByTestId('chat-body')).toBeTruthy()
  164. expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
  165. fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
  166. expect(screen.queryByText(/turns ·/)).toBeNull()
  167. expect(screen.getByText('Turn 1')).toBeTruthy()
  168. expect(screen.getByText('Turn 2')).toBeTruthy()
  169. expect(screen.getAllByText('Message').length).toBeGreaterThan(0)
  170. expect(screen.getAllByText('Step 1').length).toBeGreaterThan(0)
  171. expect(screen.getAllByText('Input').length).toBeGreaterThan(0)
  172. expect(screen.queryByTestId('chat-body')).toBeNull()
  173. })
  174. it('waterfall renders bars and switching back to chat does not collapse it', async () => {
  175. const b = await bench()
  176. mount(b.slots)
  177. fireEvent.click(screen.getByRole('tab', { name: 'Waterfall' }))
  178. expect(screen.getByTitle('2 nodes')).toBeTruthy()
  179. expect(screen.getByTitle('1 tool calls')).toBeTruthy()
  180. fireEvent.click(screen.getByRole('tab', { name: 'Chat' }))
  181. expect(screen.getByTestId('chat-body')).toBeTruthy()
  182. })
  183. it('empty window: placeholder copy in the body, the stats header renders nothing', async () => {
  184. const b = await bench()
  185. mount(b.slots, [] as unknown as ConversationSnapshot['nodes'])
  186. fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
  187. expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
  188. expect(screen.queryByText(/turns ·/)).toBeNull()
  189. })
  190. })
  191. describe('span derivation', () => {
  192. it('attributes prologue to turn 0 and follows steering turn tags', () => {
  193. const nodes = [
  194. { kind: 'user', seq: 1 },
  195. { kind: 'steering', seq: 2, turn: 5 },
  196. { kind: 'user', seq: 3 },
  197. ] as unknown as ConversationSnapshot['nodes']
  198. const spans = deriveSpans(nodes)
  199. expect(spans).toEqual([
  200. { turn: 0, steps: 0, calls: 0, nodes: 1 },
  201. { turn: 5, steps: 0, calls: 0, nodes: 2 },
  202. ])
  203. expect(deriveSpanStats(spans)).toEqual({ turns: 2, steps: 0, calls: 0 })
  204. })
  205. it('empty inputs produce zero stats and standalone components render their empty forms', () => {
  206. expect(deriveSpanStats(deriveSpans([] as unknown as ConversationSnapshot['nodes']))).toEqual({ turns: 0, steps: 0, calls: 0 })
  207. const { useSession } = fakeSession([] as unknown as ConversationSnapshot['nodes'])
  208. const { container } = render(createElement(TrajectoryStatsHeader, { useSession: useSession as never }))
  209. expect(container.firstChild).toBeNull()
  210. render(createElement(TrajectoryView as FC<ConvViewProps>,
  211. standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
  212. expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
  213. })
  214. })
  215. describe('WaterfallView standalone branches', () => {
  216. it('empty window renders the placeholder copy', () => {
  217. render(createElement(WaterfallView as FC<ConvViewProps>,
  218. standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
  219. expect(screen.getByText('暂无瀑布数据')).toBeTruthy()
  220. })
  221. it('a turn without tool calls renders the node bar only', () => {
  222. const nodes = [{ kind: 'user', seq: 1 }] as unknown as ConversationSnapshot['nodes']
  223. render(createElement(WaterfallView as FC<ConvViewProps>, standaloneProps(nodes)))
  224. expect(screen.getByTitle('1 nodes')).toBeTruthy()
  225. expect(screen.queryByTitle(/tool calls/)).toBeNull()
  226. })
  227. })
  228. describe('node half', () => {
  229. it('node apply is an intentional no-op (loader-managed lifecycle only)', () => {
  230. expect(nodeApply()).toBeUndefined()
  231. })
  232. })
  233. describe('deriveSubSpans (waterfall lanes)', () => {
  234. const dispatchNodes = [
  235. { kind: 'assistant', seq: 2, time: 6_000, turn: 3, step: 1, blocks: [] },
  236. {
  237. kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1',
  238. call: { name: 'run_code', argsRaw: '{}' }, callTime: 6_100,
  239. content: [], isError: false, callView: null, resultView: null,
  240. },
  241. ] as unknown as ConversationSnapshot['nodes']
  242. it('scales settled lanes into the dispatch window with real durations', () => {
  243. const codeDispatches = new Map([['p1', [
  244. {
  245. kind: 'tool-result', seq: 101, time: 7_000, callId: 'p1:code:1',
  246. call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
  247. content: [], isError: false, callView: null, resultView: null,
  248. },
  249. {
  250. kind: 'tool-result', seq: 102, time: 8_200, callId: 'p1:code:2',
  251. call: { name: 'read', argsRaw: '{}' }, callTime: 7_000,
  252. content: [], isError: false, callView: null, resultView: null,
  253. },
  254. ]]]) as unknown as ConversationSnapshot['codeDispatches']
  255. const lanes = deriveSubSpans(dispatchNodes, codeDispatches)
  256. const turn3 = lanes.get(3)
  257. expect(turn3).toHaveLength(2)
  258. // Window = 6200..8200 (2000ms). bash: 0..0.4; read: 0.4..1.0.
  259. expect(turn3?.[0]).toMatchObject({ name: 'bash', durationMs: 800, timing: 'measured', offsetFraction: 0 })
  260. expect(turn3?.[0]?.widthFraction).toBeCloseTo(0.4)
  261. expect(turn3?.[1]).toMatchObject({ name: 'read', durationMs: 1200 })
  262. expect(turn3?.[1]?.offsetFraction).toBeCloseTo(0.4)
  263. })
  264. it('a running lane extends to the window end with a null duration', () => {
  265. const codeDispatches = new Map([['p1', [
  266. {
  267. kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
  268. call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
  269. content: [], isError: false, callView: null, resultView: null,
  270. },
  271. { callId: 'p1:code:2', name: 'grep', argsRaw: '{}', turn: 0, step: 0, time: 7_000, callView: null },
  272. ]]]) as unknown as ConversationSnapshot['codeDispatches']
  273. const lanes = deriveSubSpans(dispatchNodes, codeDispatches)
  274. const running = lanes.get(3)?.find((lane) => lane.name === 'grep')
  275. expect(running).toMatchObject({ durationMs: null, timing: 'running' })
  276. // Extends from its start to the window end.
  277. expect(running!.offsetFraction + running!.widthFraction).toBeCloseTo(1)
  278. })
  279. it('a settle-only entry (null callTime) is unknown timing, never a measured 0 ms', () => {
  280. const codeDispatches = new Map([['p1', [
  281. {
  282. kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
  283. call: { name: 'bash', argsRaw: '{}' }, callTime: null,
  284. content: [], isError: false, callView: null, resultView: null,
  285. },
  286. ]]]) as unknown as ConversationSnapshot['codeDispatches']
  287. const lane = deriveSubSpans(dispatchNodes, codeDispatches).get(3)?.[0]
  288. expect(lane).toMatchObject({ durationMs: null, timing: 'unknown' })
  289. })
  290. it('waterfall renders sub-span lanes under the owning turn row', () => {
  291. const codeDispatches = new Map([['p1', [
  292. {
  293. kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
  294. call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
  295. content: [], isError: false, callView: null, resultView: null,
  296. },
  297. ]]]) as unknown as ConversationSnapshot['codeDispatches']
  298. const store = createSnapshotStore({
  299. nodes: dispatchNodes, partial: null,
  300. runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches,
  301. })
  302. const props = {
  303. sessionId: SID,
  304. useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>,
  305. useSessions: emptySessions(),
  306. useWorkspaces: emptyWorkspaces(),
  307. } as unknown as ConvViewProps
  308. const view = render(createElement(WaterfallView as FC<ConvViewProps>, props))
  309. const lane = view.container.querySelector('[data-subspan]')
  310. expect(lane).not.toBeNull()
  311. expect(lane!.textContent).toContain('bash')
  312. expect(lane!.querySelector('[title*="1.80s"]')).not.toBeNull()
  313. expect(lane!.querySelector('[data-timing="measured"]')).not.toBeNull()
  314. })
  315. it('waterfall labels a settle-only lane as duration unknown', () => {
  316. const codeDispatches = new Map([['p1', [
  317. {
  318. kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
  319. call: { name: 'read', argsRaw: '{}' }, callTime: null,
  320. content: [], isError: false, callView: null, resultView: null,
  321. },
  322. ]]]) as unknown as ConversationSnapshot['codeDispatches']
  323. const store = createSnapshotStore({
  324. nodes: dispatchNodes, partial: null,
  325. runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches,
  326. })
  327. const props = {
  328. sessionId: SID,
  329. useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>,
  330. useSessions: emptySessions(),
  331. useWorkspaces: emptyWorkspaces(),
  332. } as unknown as ConvViewProps
  333. const view = render(createElement(WaterfallView as FC<ConvViewProps>, props))
  334. const bar = view.container.querySelector('[data-timing="unknown"]')
  335. expect(bar).not.toBeNull()
  336. expect(bar!.getAttribute('title')).toContain('duration unknown')
  337. })
  338. })