todo-display.snapshot.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. // @vitest-environment jsdom
  2. // Todo display snapshot over the BUILT client graph (the code-mode-fixture
  3. // idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
  4. // Opens the fixture history session and pins the todo_write turn's two
  5. // surfaces: the dedicated TodoRow in the chat flow (keyed toolview, summary
  6. // derived from the call args) and the TodoPanel plan strip riding the
  7. // 'conversation.input.dock' slot (fed by ConversationSnapshot.todos, seeded
  8. // by the tail history page), including the collapse interaction.
  9. import { readFileSync } from 'node:fs'
  10. import { join } from 'node:path'
  11. import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
  12. import { afterEach, beforeEach, expect, it, vi } from 'vitest'
  13. import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
  14. import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
  15. const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
  16. { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
  17. { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
  18. { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
  19. { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
  20. { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
  21. { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
  22. { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
  23. {
  24. id: '@deepseek-ai/dsh-client-ui-workspace',
  25. dir: 'ui-workspace',
  26. url: '/plugins/ui-workspace.js',
  27. rev: 'fx',
  28. inject: [
  29. '@deepseek-ai/dsh-client-runtime',
  30. '@deepseek-ai/dsh-client-ui-conversation',
  31. '@deepseek-ai/dsh-client-ui-sidebar',
  32. ],
  33. },
  34. ]
  35. const bundles = new Map(PLUGINS.map(plugin => [
  36. plugin.url,
  37. readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
  38. ]))
  39. interface FixtureWindow extends Window {
  40. __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
  41. __ModuleLoader__?: unknown
  42. }
  43. class ResizeObserverStub {
  44. observe(): void {}
  45. disconnect(): void {}
  46. unobserve(): void {}
  47. }
  48. const win = window as FixtureWindow
  49. let unmount: (() => void) | undefined
  50. beforeEach(() => {
  51. localStorage.clear()
  52. document.title = 'DeepSeek Harness'
  53. vi.stubGlobal('ResizeObserver', ResizeObserverStub)
  54. vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
  55. setTimeout(() => { callback(0) }, 0) as unknown as number)
  56. vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
  57. })
  58. afterEach(() => {
  59. act(() => { unmount?.() })
  60. unmount = undefined
  61. cleanup()
  62. delete win.__DSH_BOOT__
  63. delete win.__ModuleLoader__
  64. delete (globalThis as Record<string, unknown>).__fxTiming
  65. document.body.innerHTML = ''
  66. document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
  67. document.title = ''
  68. history.replaceState(null, '', '/')
  69. vi.unstubAllGlobals()
  70. })
  71. /** Boot the complete built client graph against the populated fixture branch. */
  72. function boot(): void {
  73. history.replaceState(null, '', '/?fixture')
  74. const root = document.createElement('div')
  75. root.id = 'root'
  76. document.body.appendChild(root)
  77. win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
  78. act(() => {
  79. const entry = new AppWebEntry(root, {
  80. fetchBundle: (url) => {
  81. const code = bundles.get(url)
  82. return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
  83. },
  84. executeBundle: (code) => { (0, eval)(code) },
  85. })
  86. void entry.run()
  87. unmount = () => { entry.dispose() }
  88. })
  89. }
  90. /** Collapse decorative whitespace while preserving the text a user sees. */
  91. function visibleText(element: Element): string {
  92. return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
  93. }
  94. /** Open the fixture history session (the alpha log carrying the todo_write turn) and wait for its tail. */
  95. async function openFixtureSession(): Promise<void> {
  96. const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
  97. // Anchor on the expandable Workspace group row: the title and the blank
  98. // session row can both read "fixture", and the session-count meta shifts
  99. // when a blank session joins the group.
  100. const group = (await within(tree).findAllByText('fixture'))
  101. .map(el => el.closest<HTMLElement>('[role="treeitem"]'))
  102. .find(el => el?.getAttribute('aria-expanded') !== null)
  103. if (group === null || group === undefined) throw new Error('fixture Workspace group missing')
  104. if (group.getAttribute('aria-expanded') === 'false') {
  105. fireEvent.click(within(group).getByText('fixture'))
  106. await waitFor(() => {
  107. expect(group.getAttribute('aria-expanded')).toBe('true')
  108. })
  109. }
  110. const session = await within(tree).findByText('Fixture 历史会话')
  111. fireEvent.click(session)
  112. await waitFor(() => {
  113. expect(document.querySelector('[data-sample="todo-row"]')).not.toBeNull()
  114. }, { timeout: 10_000 })
  115. }
  116. it('renders the todo_write turn: dedicated tool row + the dock plan strip', async () => {
  117. boot()
  118. await openFixtureSession()
  119. const row = document.querySelector('[data-sample="todo-row"]')
  120. if (row === null) throw new Error('todo row missing')
  121. const panel = document.querySelector('[data-testid="todo-panel"]')
  122. if (panel === null) throw new Error('todo panel missing from the input dock')
  123. // Header spans are adjacent inline nodes; textContent joins "To-dos" +
  124. // "1/3…" with no space (visual gap is CSS gap: 10px, not a text node).
  125. expect({
  126. row: visibleText(row),
  127. rowState: row.getAttribute('data-state'),
  128. panelHeader: visibleText(panel.querySelector('button') ?? panel),
  129. panelItems: [...panel.querySelectorAll('li')].map(item => ({
  130. status: item.getAttribute('data-status'),
  131. text: visibleText(item),
  132. })),
  133. }).toMatchInlineSnapshot(`
  134. {
  135. "panelHeader": "To-dos1/3 tasks · 1 in progress",
  136. "panelItems": [
  137. {
  138. "status": "completed",
  139. "text": "梳理需求",
  140. },
  141. {
  142. "status": "in_progress",
  143. "text": "实现 fixture 样本",
  144. },
  145. {
  146. "status": "pending",
  147. "text": "浏览器验收",
  148. },
  149. ],
  150. "row": "更新任务清单1/3 已完成 · 实现 fixture 样本",
  151. "rowState": "ok",
  152. }
  153. `)
  154. })
  155. it('collapses the plan strip to the count summary and restores it', async () => {
  156. boot()
  157. await openFixtureSession()
  158. const panel = document.querySelector('[data-testid="todo-panel"]')
  159. if (panel === null) throw new Error('todo panel missing from the input dock')
  160. const header = panel.querySelector('button')
  161. if (header === null) throw new Error('todo panel header missing')
  162. fireEvent.click(header)
  163. expect({
  164. collapsedHeader: visibleText(header),
  165. listGone: panel.querySelector('ul') === null,
  166. }).toMatchInlineSnapshot(`
  167. {
  168. "collapsedHeader": "To-dos1/3 tasks · 1 in progress",
  169. "listGone": true,
  170. }
  171. `)
  172. fireEvent.click(header)
  173. expect(panel.querySelectorAll('li')).toHaveLength(3)
  174. })