workspace-flow.snapshot.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. // @vitest-environment jsdom
  2. // Assembled keyless snapshots of the New Session flow under the agent-parity
  3. // model: startup auto-connects the recent Workspace's blank session when one
  4. // exists; without any Workspace the composer is locked in the pure view
  5. // state until one is chosen. Picking one materializes the full Session+Agent
  6. // (reuse-or-create of the workspace's blank session), the first ACCEPTED
  7. // prompt flips blank and surfaces the session in lists, and failures leave
  8. // no client-side transaction state: a failed attach keeps the view state
  9. // locked, a rejected prompt keeps the session blank with the draft restored.
  10. import { readFileSync } from 'node:fs'
  11. import { join } from 'node:path'
  12. import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
  13. import { afterEach, beforeEach, expect, it, vi } from 'vitest'
  14. import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
  15. import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
  16. const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
  17. { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
  18. { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
  19. { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
  20. { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
  21. { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
  22. { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
  23. { id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar'] },
  24. { id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-client-locale'] },
  25. { id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] },
  26. { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
  27. {
  28. id: '@deepseek-ai/dsh-client-ui-workspace',
  29. dir: 'ui-workspace',
  30. url: '/plugins/ui-workspace.js',
  31. rev: 'fx',
  32. inject: [
  33. '@deepseek-ai/dsh-client-runtime',
  34. '@deepseek-ai/dsh-client-ui-conversation',
  35. '@deepseek-ai/dsh-client-ui-sidebar',
  36. ],
  37. },
  38. { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
  39. // Dual-face host package: its browser half fills the directory-flow holes
  40. // (the same composition row apps/cli mounts for the node-side backend).
  41. {
  42. id: '@deepseek-ai/dsh-host-directory-picker-browse',
  43. dir: '../host/directory-picker-browse',
  44. url: '/plugins/directory-picker-browse.js',
  45. rev: 'fx',
  46. inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-workspace', '@deepseek-ai/dsh-client-locale'],
  47. },
  48. ]
  49. const bundles = new Map(PLUGINS.map(plugin => [
  50. plugin.url,
  51. readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
  52. ]))
  53. interface FixtureWindow extends Window {
  54. __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
  55. __ModuleLoader__?: unknown
  56. }
  57. class ResizeObserverStub {
  58. observe(): void {}
  59. disconnect(): void {}
  60. unobserve(): void {}
  61. }
  62. const win = window as FixtureWindow
  63. let unmount: (() => void) | undefined
  64. beforeEach(() => {
  65. localStorage.clear()
  66. document.title = 'DeepSeek Harness'
  67. vi.stubGlobal('ResizeObserver', ResizeObserverStub)
  68. vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
  69. setTimeout(() => { callback(0) }, 0) as unknown as number)
  70. vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
  71. })
  72. afterEach(() => {
  73. act(() => { unmount?.() })
  74. unmount = undefined
  75. cleanup()
  76. delete win.__DSH_BOOT__
  77. delete win.__ModuleLoader__
  78. delete (globalThis as Record<string, unknown>).__fxTiming
  79. document.body.innerHTML = ''
  80. document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
  81. document.title = ''
  82. history.replaceState(null, '', '/')
  83. vi.unstubAllGlobals()
  84. })
  85. /** Boot the complete built client graph against one keyless fixture branch. */
  86. function boot(search: string): void {
  87. history.replaceState(null, '', `/${search}`)
  88. const root = document.createElement('div')
  89. root.id = 'root'
  90. document.body.appendChild(root)
  91. win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
  92. act(() => {
  93. const entry = new AppWebEntry(root, {
  94. fetchBundle: (url) => {
  95. const code = bundles.get(url)
  96. return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
  97. },
  98. executeBundle: (code) => { (0, eval)(code) },
  99. })
  100. void entry.run()
  101. unmount = () => { entry.dispose() }
  102. })
  103. }
  104. /** Collapse decorative whitespace while preserving the text a user sees. */
  105. function visibleText(element: Element): string {
  106. return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
  107. }
  108. /** Identify the interactive Workspace chip (view state or blank-session hero) by its menu contract. */
  109. function workspaceChip(): HTMLElement {
  110. const chip = screen.getAllByRole('button', { name: 'Choose workspace' })
  111. .find(element => element.getAttribute('aria-haspopup') === 'menu')
  112. if (chip === undefined) throw new Error('Workspace chip missing')
  113. return chip
  114. }
  115. /** The locked view-state composer (no session yet). */
  116. async function findLockedComposer(): Promise<HTMLTextAreaElement> {
  117. return await screen.findByPlaceholderText(
  118. 'Choose a workspace to start', {}, { timeout: 10_000 },
  119. )
  120. }
  121. /** The live blank-session hero composer (session materialized). */
  122. async function findHeroComposer(): Promise<HTMLTextAreaElement> {
  123. return await screen.findByPlaceholderText(
  124. 'Describe what you want to build', {}, { timeout: 10_000 },
  125. )
  126. }
  127. /** Edit the machine-owned controlled input and assert the same-tick echo. */
  128. function setComposerText(composer: HTMLElement, value: string): void {
  129. fireEvent.change(composer, { target: { value } })
  130. expect((composer as HTMLTextAreaElement).value).toBe(value)
  131. }
  132. /** Drive the picker's create flow: chip → Create a new workspace → name dialog. */
  133. async function createWorkspaceViaPicker(name: string): Promise<void> {
  134. fireEvent.click(workspaceChip())
  135. fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' }))
  136. const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' })
  137. fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), {
  138. target: { value: name },
  139. })
  140. fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' }))
  141. }
  142. /** Pick an existing Workspace row from the chip menu. */
  143. async function pickWorkspace(title: string): Promise<void> {
  144. fireEvent.click(workspaceChip())
  145. fireEvent.click(await screen.findByRole('menuitem', { name: title }))
  146. }
  147. it('locks the composer in the New Session view state until a Workspace is chosen', async () => {
  148. boot('?fixture=empty')
  149. const composer = await findLockedComposer()
  150. const tree = screen.getByRole('tree', { name: 'Sessions' })
  151. expect({
  152. headline: visibleText(screen.getByText("Let's start building")),
  153. chip: visibleText(workspaceChip()),
  154. composerDisabled: composer.disabled,
  155. sendDisabled: screen.getByRole<HTMLButtonElement>('button', { name: 'Send message' }).disabled,
  156. sidebar: visibleText(tree),
  157. }).toMatchInlineSnapshot(`
  158. {
  159. "chip": "Choose workspace",
  160. "composerDisabled": true,
  161. "headline": "Let's start building",
  162. "sendDisabled": true,
  163. "sidebar": "No sessions yet",
  164. }
  165. `)
  166. })
  167. it('adopts a directory through the composed in-app browse flow and lands in its blank session', async () => {
  168. boot('?fixture=empty')
  169. await findLockedComposer()
  170. fireEvent.click(workspaceChip())
  171. const menu = await screen.findByRole('menu')
  172. // The composed flow package occupies the directory-flow hole, so the
  173. // picking affordance is present (no advertised-kind read exists anymore).
  174. expect(within(menu).getAllByRole('menuitem').map(item => visibleText(item)))
  175. .toEqual(['Open local folder…', 'Create a new workspace'])
  176. fireEvent.click(within(menu).getByRole('menuitem', { name: 'Open local folder…' }))
  177. // The browse occupant renders the Select Workspace Directory dialog at the
  178. // fixture home; select Documents, advance into project, and adopt it.
  179. const dialog = await screen.findByRole('dialog', { name: '选择工作区目录' }, { timeout: 10_000 })
  180. // Row targeting goes through the visible label text: listitem accessible-name
  181. // computation differs across dom-accessibility-api environments, while the
  182. // row's name span is stable (clicks bubble to the row button).
  183. fireEvent.click(await within(dialog).findByText('Documents', {}, { timeout: 10_000 }))
  184. fireEvent.click(await within(dialog).findByText('project', {}, { timeout: 10_000 }))
  185. // Open disables while the selection's child listing is in flight; wait for
  186. // the enabled state or the click lands on a dead button on slow runners.
  187. await waitFor(() => {
  188. expect(within(dialog).getByRole<HTMLButtonElement>('button', { name: '打开' }).disabled).toBe(false)
  189. }, { timeout: 10_000 })
  190. fireEvent.click(within(dialog).getByRole('button', { name: '打开' }))
  191. await findHeroComposer()
  192. await waitFor(() => {
  193. expect(visibleText(screen.getByRole('tree', { name: 'Sessions' }))).toContain('project')
  194. })
  195. })
  196. it('selects the recent Workspace and opens its blank Session on first load', async () => {
  197. boot('?fixture')
  198. const composer = await findHeroComposer()
  199. const tree = screen.getByRole('tree', { name: 'Sessions' })
  200. await waitFor(() => { expect(within(tree).getByText('4 sessions')).toBeDefined() }, { timeout: 10_000 })
  201. expect({
  202. chip: visibleText(workspaceChip()),
  203. composerDisabled: composer.disabled,
  204. blankRow: within(tree).getByText('New Session').textContent,
  205. }).toMatchInlineSnapshot(`
  206. {
  207. "blankRow": "New Session",
  208. "chip": "fixture",
  209. "composerDisabled": false,
  210. }
  211. `)
  212. })
  213. it('creating a Workspace materializes and lists its selected blank Session', async () => {
  214. boot('?fixture=empty')
  215. await findLockedComposer()
  216. await createWorkspaceViaPicker('nova')
  217. // The pick connected the workspace: full Session+Agent exists, composer live.
  218. const composer = await findHeroComposer()
  219. const tree = screen.getByRole('tree', { name: 'Sessions' })
  220. await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() })
  221. expect(within(tree).getByText('New Session')).toBeDefined()
  222. const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
  223. if (group === null) throw new Error('created Workspace projection missing')
  224. expect({
  225. composerDisabled: composer.disabled,
  226. chip: visibleText(workspaceChip()),
  227. workspace: visibleText(group),
  228. }).toMatchInlineSnapshot(`
  229. {
  230. "chip": "nova",
  231. "composerDisabled": false,
  232. "workspace": "nova1 session",
  233. }
  234. `)
  235. })
  236. it('New Session reuses the Workspace blank session and converts the single visible row', async () => {
  237. boot('?fixture=empty')
  238. await findLockedComposer()
  239. await createWorkspaceViaPicker('nova')
  240. await findHeroComposer()
  241. const tree = screen.getByRole('tree', { name: 'Sessions' })
  242. await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
  243. // New Session resolves through the recent Workspace and reuses its blank
  244. // session in place: no locked interlude, no second entity.
  245. const newSessionButton = screen.getAllByRole('button', { name: 'New session' })
  246. .find(button => visibleText(button) === 'New Session')
  247. if (newSessionButton === undefined) throw new Error('New Session button missing')
  248. fireEvent.click(newSessionButton)
  249. const composer = await findHeroComposer()
  250. await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
  251. setComposerText(composer, 'first light')
  252. fireEvent.keyDown(composer, { key: 'Enter' })
  253. // Conversion: the accepted prompt flips blank without adding a second row.
  254. await screen.findByText('first light', { exact: true }, { timeout: 10_000 })
  255. await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
  256. const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
  257. if (group === null) throw new Error('converted Session projection missing')
  258. expect({
  259. workspace: visibleText(group),
  260. promptVisible: screen.getByText('first light', { exact: true }).textContent,
  261. }).toMatchInlineSnapshot(`
  262. {
  263. "promptVisible": "first light",
  264. "workspace": "nova1 session",
  265. }
  266. `)
  267. })
  268. it('a failed Workspace attach recovers by reusing the published blank session', async () => {
  269. boot('?fixture&fixtureAttach=fail')
  270. // The rejected startup connect surfaces the locked view state first: the
  271. // failure leaves no client-side transaction state to unwind.
  272. await findLockedComposer()
  273. // The host published the session before rejecting attachment (blank, with
  274. // the workspace cwd), so the next connect — retry or manual pick — reuses
  275. // it instead of minting a duplicate, and the hero opens on it.
  276. await pickWorkspace('fixture')
  277. const composer = await findHeroComposer()
  278. const tree = screen.getByRole('tree', { name: 'Sessions' })
  279. const group = within(tree).getByText('3 sessions').closest('[role="treeitem"]')
  280. if (group === null) throw new Error('fixture Workspace projection missing')
  281. expect({
  282. headline: visibleText(screen.getByText("Let's start building")),
  283. composerDisabled: composer.disabled,
  284. chip: visibleText(workspaceChip()),
  285. workspace: visibleText(group),
  286. }).toMatchInlineSnapshot(`
  287. {
  288. "chip": "fixture",
  289. "composerDisabled": false,
  290. "headline": "Let's start building",
  291. "workspace": "fixture3 sessions",
  292. }
  293. `)
  294. })
  295. it('a rejected first prompt keeps the session blank and the draft in the machine', async () => {
  296. boot('?fixture=empty&fixturePrompt=reject')
  297. await findLockedComposer()
  298. await createWorkspaceViaPicker('nova')
  299. const composer = await findHeroComposer()
  300. setComposerText(composer, 'do not lose this')
  301. fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
  302. const alert = await screen.findByRole('alert', {}, { timeout: 10_000 })
  303. // Failure restore rides the machine (no pendingPrompt transaction): the
  304. // draft returns to the same resident textarea one render later. The
  305. // attempt flips the composer out of the hero (engaging = retry chrome),
  306. // but acceptance never happened: the session row stays New Session.
  307. const retained = await screen.findByDisplayValue('do not lose this')
  308. const tree = screen.getByRole('tree', { name: 'Sessions' })
  309. const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
  310. if (group === null) throw new Error('rejected-send Workspace projection missing')
  311. expect({
  312. error: visibleText(alert),
  313. prompt: (retained as HTMLTextAreaElement).value,
  314. blankRow: within(tree).getByText('New Session').textContent,
  315. workspace: visibleText(group),
  316. }).toMatchInlineSnapshot(`
  317. {
  318. "blankRow": "New Session",
  319. "error": "fixture: prompt rejected before acceptance (agent-busy)",
  320. "prompt": "do not lose this",
  321. "workspace": "nova1 session",
  322. }
  323. `)
  324. })
  325. it('switching Workspace before the first message carries the draft to the new blank session', async () => {
  326. boot('?fixture')
  327. const composer = await findHeroComposer()
  328. setComposerText(composer, 'carry me')
  329. // Switch = session switch: the new workspace's blank session takes over,
  330. // the typed draft moves machine-to-machine, the old blank stays hidden.
  331. await createWorkspaceViaPicker('nova')
  332. await waitFor(() => { expect(visibleText(workspaceChip())).toBe('nova') }, { timeout: 10_000 })
  333. const carried = await screen.findByDisplayValue('carry me')
  334. const tree = screen.getByRole('tree', { name: 'Sessions' })
  335. const fixtureGroup = within(tree).getByText('3 sessions').closest('[role="treeitem"]')
  336. const novaGroup = within(tree).getByText('1 session').closest('[role="treeitem"]')
  337. if (fixtureGroup === null || novaGroup === null) throw new Error('Workspace projections missing after switch')
  338. expect({
  339. chip: visibleText(workspaceChip()),
  340. prompt: (carried as HTMLTextAreaElement).value,
  341. fixtureWorkspace: visibleText(fixtureGroup),
  342. novaWorkspace: visibleText(novaGroup),
  343. }).toMatchInlineSnapshot(`
  344. {
  345. "chip": "nova",
  346. "fixtureWorkspace": "fixture3 sessions",
  347. "novaWorkspace": "nova1 session",
  348. "prompt": "carry me",
  349. }
  350. `)
  351. })