workspace-flow.snapshot.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. // @vitest-environment jsdom
  2. import { readFileSync } from 'node:fs'
  3. import { join } from 'node:path'
  4. import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
  5. import { afterEach, beforeEach, expect, it, vi } from 'vitest'
  6. import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
  7. import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
  8. const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
  9. { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
  10. { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
  11. { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
  12. { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', url: '/plugins/i18n.js', rev: 'fx', inject: [], immediately: true },
  13. { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
  14. { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
  15. { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
  16. {
  17. id: '@deepseek-ai/dsh-client-ui-workspace',
  18. dir: 'ui-workspace',
  19. url: '/plugins/ui-workspace.js',
  20. rev: 'fx',
  21. inject: [
  22. '@deepseek-ai/dsh-client-runtime',
  23. '@deepseek-ai/dsh-client-ui-conversation',
  24. '@deepseek-ai/dsh-client-ui-sidebar',
  25. ],
  26. },
  27. { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
  28. ]
  29. const bundles = new Map(PLUGINS.map(plugin => [
  30. plugin.url,
  31. readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
  32. ]))
  33. interface FixtureWindow extends Window {
  34. __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
  35. __ModuleLoader__?: unknown
  36. }
  37. class ResizeObserverStub {
  38. observe(): void {}
  39. disconnect(): void {}
  40. unobserve(): void {}
  41. }
  42. const win = window as FixtureWindow
  43. let unmount: (() => void) | undefined
  44. beforeEach(() => {
  45. localStorage.clear()
  46. document.title = 'DeepSeek Harness'
  47. vi.stubGlobal('ResizeObserver', ResizeObserverStub)
  48. vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
  49. setTimeout(() => { callback(0) }, 0) as unknown as number)
  50. vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
  51. })
  52. afterEach(() => {
  53. act(() => { unmount?.() })
  54. unmount = undefined
  55. cleanup()
  56. delete win.__DSH_BOOT__
  57. delete win.__ModuleLoader__
  58. delete (globalThis as Record<string, unknown>).__fxTiming
  59. document.body.innerHTML = ''
  60. document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
  61. document.title = ''
  62. history.replaceState(null, '', '/')
  63. vi.unstubAllGlobals()
  64. })
  65. /** Boot the complete built client graph against one keyless fixture branch. */
  66. function boot(search: string): void {
  67. history.replaceState(null, '', `/${search}`)
  68. const root = document.createElement('div')
  69. root.id = 'root'
  70. document.body.appendChild(root)
  71. win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
  72. act(() => {
  73. const entry = new AppWebEntry(root, {
  74. fetchBundle: (url) => {
  75. const code = bundles.get(url)
  76. return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
  77. },
  78. executeBundle: (code) => { (0, eval)(code) },
  79. })
  80. void entry.run()
  81. unmount = () => { entry.dispose() }
  82. })
  83. }
  84. /** Recreate the built client graph while preserving browser-persistent state. */
  85. function refresh(search: string): void {
  86. act(() => { unmount?.() })
  87. unmount = undefined
  88. cleanup()
  89. delete win.__DSH_BOOT__
  90. delete win.__ModuleLoader__
  91. delete (globalThis as Record<string, unknown>).__fxTiming
  92. document.body.innerHTML = ''
  93. document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
  94. boot(search)
  95. }
  96. /** Collapse decorative whitespace while preserving the text a user sees. */
  97. function visibleText(element: Element): string {
  98. return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
  99. }
  100. /** Identify the interactive Workspace chip by its menu contract. */
  101. function workspaceChip(): HTMLElement {
  102. const chip = screen.getAllByRole('button', { name: 'Choose workspace' })
  103. .find(element => element.getAttribute('aria-haspopup') === 'menu')
  104. if (chip === undefined) throw new Error('Workspace chip missing')
  105. return chip
  106. }
  107. /** Wait for the runtime-owned controlled input to echo a browser edit. */
  108. async function setComposerText(composer: HTMLElement, value: string): Promise<void> {
  109. fireEvent.change(composer, { target: { value } })
  110. await waitFor(() => { expect((composer as HTMLTextAreaElement).value).toBe(value) })
  111. }
  112. it('starts a writable page-local draft without inventing a sidebar Workspace', async () => {
  113. boot('?fixture=empty')
  114. const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
  115. const tree = screen.getByRole('tree', { name: 'Sessions' })
  116. await setComposerText(composer, 'keep this local')
  117. expect({
  118. headline: visibleText(screen.getByText("Let's start building")),
  119. workspaceDraft: visibleText(workspaceChip()),
  120. sidebar: visibleText(tree),
  121. composerDisabled: (composer as HTMLTextAreaElement).disabled,
  122. prompt: (composer as HTMLTextAreaElement).value,
  123. }).toMatchInlineSnapshot(`
  124. {
  125. "composerDisabled": false,
  126. "headline": "Let's start building",
  127. "prompt": "keep this local",
  128. "sidebar": "No sessions yet",
  129. "workspaceDraft": "workspace",
  130. }
  131. `)
  132. })
  133. it('creates a real empty Workspace immediately and focuses its Session draft', async () => {
  134. boot('?fixture=empty')
  135. await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
  136. const workspaceSection = screen.getByText('Workspaces').parentElement
  137. if (workspaceSection === null) throw new Error('Workspace section missing')
  138. fireEvent.click(within(workspaceSection).getByRole('button', { name: 'Create workspace' }))
  139. fireEvent.click(await screen.findByRole('menuitem', { name: 'Create workspace' }))
  140. fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' }))
  141. const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' })
  142. fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), {
  143. target: { value: 'nova' },
  144. })
  145. fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' }))
  146. const tree = await screen.findByRole('tree', { name: 'Sessions' })
  147. await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() })
  148. const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
  149. const draft = within(tree).getByText('New session').closest('[role="treeitem"]')
  150. if (group === null || draft === null) throw new Error('created Workspace projection missing')
  151. expect({
  152. workspace: visibleText(group),
  153. draft: visibleText(draft),
  154. draftSelected: draft.getAttribute('aria-selected'),
  155. composerWorkspace: visibleText(workspaceChip()),
  156. }).toMatchInlineSnapshot(`
  157. {
  158. "composerWorkspace": "nova",
  159. "draft": "New session",
  160. "draftSelected": "true",
  161. "workspace": "nova1 session",
  162. }
  163. `)
  164. })
  165. it('drops the page-local draft on refresh while retaining real Workspaces and Sessions', async () => {
  166. boot('?fixture')
  167. const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
  168. const tree = screen.getByRole('tree', { name: 'Sessions' })
  169. await setComposerText(composer, 'discard this page-local draft')
  170. const beforeGroup = within(tree).getByText('4 sessions').closest('[role="treeitem"]')
  171. if (beforeGroup === null) throw new Error('fixture Workspace projection missing before refresh')
  172. const before = {
  173. workspace: visibleText(beforeGroup),
  174. draft: visibleText(within(tree).getByText('New session')),
  175. prompt: (composer as HTMLTextAreaElement).value,
  176. }
  177. refresh('?fixture')
  178. const refreshedComposer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
  179. const refreshedTree = screen.getByRole('tree', { name: 'Sessions' })
  180. const afterGroup = within(refreshedTree).getByText('4 sessions').closest('[role="treeitem"]')
  181. if (afterGroup === null) throw new Error('fixture Workspace projection missing after refresh')
  182. expect({
  183. before,
  184. after: {
  185. workspace: visibleText(afterGroup),
  186. replacementDraft: visibleText(within(refreshedTree).getByText('New session')),
  187. prompt: (refreshedComposer as HTMLTextAreaElement).value,
  188. },
  189. }).toMatchInlineSnapshot(`
  190. {
  191. "after": {
  192. "prompt": "",
  193. "replacementDraft": "New session",
  194. "workspace": "fixture4 sessions",
  195. },
  196. "before": {
  197. "draft": "New session",
  198. "prompt": "discard this page-local draft",
  199. "workspace": "fixture4 sessions",
  200. },
  201. }
  202. `)
  203. })
  204. it('keeps a published Session with only cwd membership evidence in Ungrouped', async () => {
  205. boot('?fixture&fixtureAttach=fail')
  206. const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
  207. await setComposerText(composer, 'keep this cwd-only session')
  208. fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
  209. const tree = screen.getByRole('tree', { name: 'Sessions' })
  210. await waitFor(() => { expect(within(tree).getByText('Ungrouped')).toBeDefined() }, { timeout: 10_000 })
  211. const workspaceGroup = within(tree).getByText('3 sessions').closest('[role="treeitem"]')
  212. const ungroupedGroup = within(tree).getByText('1 session').closest('[role="treeitem"]')
  213. const ungroupedSection = ungroupedGroup?.parentElement
  214. if (workspaceGroup === null || ungroupedGroup === null || ungroupedSection === null || ungroupedSection === undefined) {
  215. throw new Error('Workspace or Ungrouped projection missing')
  216. }
  217. const session = within(ungroupedSection).getByRole('treeitem', { selected: true })
  218. const retained = screen.getByDisplayValue('keep this cwd-only session')
  219. expect({
  220. workspace: visibleText(workspaceGroup),
  221. ungrouped: visibleText(ungroupedGroup),
  222. session: within(session).getByText('fixture', { exact: true }).textContent,
  223. sessionSelected: session.getAttribute('aria-selected'),
  224. prompt: (retained as HTMLTextAreaElement).value,
  225. }).toMatchInlineSnapshot(`
  226. {
  227. "prompt": "keep this cwd-only session",
  228. "session": "fixture",
  229. "sessionSelected": "true",
  230. "ungrouped": "Ungrouped1 session",
  231. "workspace": "fixture3 sessions",
  232. }
  233. `)
  234. })
  235. it('materializes the automatic Workspace and Session on the first successful send', async () => {
  236. boot('?fixture=empty')
  237. const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
  238. await setComposerText(composer, 'build a lighthouse')
  239. fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
  240. const tree = screen.getByRole('tree', { name: 'Sessions' })
  241. await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
  242. await screen.findByText('build a lighthouse', { exact: true }, { timeout: 10_000 })
  243. const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
  244. const session = within(tree).getByRole('treeitem', { selected: true })
  245. if (group === null) throw new Error('materialized Workspace projection missing')
  246. expect({
  247. workspace: visibleText(group),
  248. session: within(session).getByText('workspace', { exact: true }).textContent,
  249. sessionSelected: session.getAttribute('aria-selected'),
  250. promptVisible: screen.getByText('build a lighthouse', { exact: true }).textContent,
  251. }).toMatchInlineSnapshot(`
  252. {
  253. "promptVisible": "build a lighthouse",
  254. "session": "workspace",
  255. "sessionSelected": "true",
  256. "workspace": "workspace1 session",
  257. }
  258. `)
  259. })
  260. it('keeps the published Workspace, Session, and unsent prompt after rejection', async () => {
  261. boot('?fixture=empty&fixturePrompt=reject')
  262. const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
  263. await setComposerText(composer, 'do not lose this')
  264. fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
  265. const alert = await screen.findByRole('alert', {}, { timeout: 10_000 })
  266. const retained = screen.getByDisplayValue('do not lose this')
  267. const tree = screen.getByRole('tree', { name: 'Sessions' })
  268. await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() })
  269. const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
  270. const session = within(tree).getByRole('treeitem', { selected: true })
  271. if (group === null) throw new Error('rejected-send Workspace projection missing')
  272. expect({
  273. workspace: visibleText(group),
  274. session: within(session).getByText('workspace', { exact: true }).textContent,
  275. error: visibleText(alert),
  276. prompt: (retained as HTMLTextAreaElement).value,
  277. }).toMatchInlineSnapshot(`
  278. {
  279. "error": "Message send failed: agent-busy: fixture: prompt rejected before acceptance",
  280. "prompt": "do not lose this",
  281. "session": "workspace",
  282. "workspace": "workspace1 session",
  283. }
  284. `)
  285. })