document-toolbar.client.spec.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. // @vitest-environment jsdom
  2. import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
  3. import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
  4. import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
  5. import type { OwnerOf } from '@deepseek-ai/dsh-client-ui-slots'
  6. import { TextPreview } from '../src/client/TextPreview.tsx'
  7. import type { TextPreviewProps } from '../src/client/TextPreview.tsx'
  8. import type { DocumentPreviewDefinition } from '../src/client/document/registry.ts'
  9. import { CodeBody } from '../src/client/code/CodeBody.tsx'
  10. import { PLAIN_BODY_ID } from '../src/client/text/index.ts'
  11. import { ABSOLUTE_PATH, FILE, harness, page, settle, TAB_ID } from './fixtures.client.ts'
  12. afterEach(cleanup)
  13. const binary: DocumentPreviewDefinition = {
  14. id: 'complete-document', extensions: ['md'], title: () => 'Complete document', loading: 'bytes-complete',
  15. }
  16. function codeProps(h: ReturnType<typeof harness>): TextPreviewProps {
  17. const props = h.props()
  18. const definition: DocumentPreviewDefinition = {
  19. id: 'code', extensions: ['md'], title: () => 'Code', loading: 'text-pages', wrap: true,
  20. }
  21. return {
  22. ...props,
  23. useDocumentPreviews: selector => selector([definition]),
  24. // This adapter only receives the concrete document slot, not an arbitrary generic key.
  25. renderSlot: (_key, owner) => <CodeBody {...props} {...owner as unknown as OwnerOf<'sidebar.right.tab.document'>} t={key => key} />,
  26. }
  27. }
  28. describe('document toolbar', () => {
  29. it('keeps Reading above the first code page and reload, retaining code during pagination', async () => {
  30. const h = harness()
  31. const first = Promise.withResolvers<Awaited<ReturnType<typeof h.read>>>()
  32. const next = Promise.withResolvers<Awaited<ReturnType<typeof h.read>>>()
  33. const reload = Promise.withResolvers<Awaited<ReturnType<typeof h.read>>>()
  34. onTestFinished(async () => {
  35. h.controller.abort()
  36. for (const pending of [first, next, reload]) pending.resolve(page(1, [], true))
  37. await Promise.all([first.promise, next.promise, reload.promise])
  38. })
  39. h.read.mockReturnValueOnce(first.promise).mockReturnValueOnce(next.promise).mockReturnValueOnce(reload.promise)
  40. const view = render(<TextPreview {...codeProps(h)} />)
  41. const body = view.container.querySelector('[data-textpreview-body]')
  42. expect(view.container.querySelector('[data-code-preview]')).toBeNull()
  43. expect(body?.firstElementChild).toBe(view.getByRole('status'))
  44. expect(view.getByRole('status').getAttribute('aria-label')).toBe('loading')
  45. expect(view.getByRole('status').textContent).toBe('')
  46. expect(view.container.querySelector('[data-textpreview-more]')).toBeNull()
  47. await act(async () => { first.resolve(page(1, ['const prefix = 1;'], false)); await first.promise })
  48. const renderer = view.container.querySelector('[data-code-preview]')
  49. expect(renderer).not.toBeNull()
  50. expect(renderer?.querySelector('pre')?.textContent).toBe('const prefix = 1;')
  51. expect(view.queryByRole('status')).toBeNull()
  52. fireEvent.click(view.getByRole('button', { name: 'loadMore' }))
  53. expect(view.container.querySelector('[data-code-preview]')).toBe(renderer)
  54. expect(renderer?.querySelector('pre')?.textContent).toBe('const prefix = 1;')
  55. expect(view.getByRole('status').closest('[data-textpreview-more]')).not.toBeNull()
  56. await act(async () => { next.resolve(page(2, ['const tail = 2;'], true)); await next.promise })
  57. expect(view.container.querySelector('[data-code-preview]')).toBe(renderer)
  58. expect(renderer?.querySelector('pre')?.textContent).toBe('const prefix = 1;\nconst tail = 2;')
  59. expect(view.queryByRole('status')).toBeNull()
  60. expect(view.container.querySelector('[data-textpreview-more]')).toBeNull()
  61. fireEvent.click(view.getByRole('button', { name: 'reload' }))
  62. expect(view.container.querySelector('[data-code-preview]')).toBeNull()
  63. expect(renderer?.isConnected).toBe(false)
  64. expect(body?.firstElementChild).toBe(view.getByRole('status'))
  65. expect(view.container.querySelector('[data-textpreview-more]')).toBeNull()
  66. await act(async () => { reload.resolve(page(1, ['const refreshed = 3;'], true, 'v2')); await reload.promise })
  67. expect(view.container.querySelector('[data-code-preview] pre')?.textContent).toBe('const refreshed = 3;')
  68. expect(view.queryByRole('status')).toBeNull()
  69. })
  70. it('mounts the code renderer after an empty file page completes', async () => {
  71. const h = harness()
  72. const pending = Promise.withResolvers<Awaited<ReturnType<typeof h.read>>>()
  73. onTestFinished(async () => {
  74. h.controller.abort()
  75. pending.resolve(page(1, [], true))
  76. await pending.promise
  77. })
  78. h.read.mockReturnValueOnce(pending.promise)
  79. const view = render(<TextPreview {...codeProps(h)} />)
  80. expect(view.container.querySelector('[data-code-preview]')).toBeNull()
  81. expect(view.getByRole('status').getAttribute('aria-label')).toBe('loading')
  82. await act(async () => { pending.resolve(page(1, [], true)); await pending.promise })
  83. expect(view.container.querySelector('[data-code-preview]')).not.toBeNull()
  84. expect(view.container.querySelector('[data-code-preview] pre')?.textContent).toBe('')
  85. expect(h.instance.getSnapshot().byTab[TAB_ID]?.pages).toEqual({ 1: { text: '', lines: 0 } })
  86. expect(view.queryByRole('status')).toBeNull()
  87. expect(view.container.querySelector('[data-textpreview-more]')).toBeNull()
  88. })
  89. it('shows the shared loading indicator until a complete read settles', async () => {
  90. const h = harness()
  91. const pending = Promise.withResolvers<Awaited<ReturnType<typeof h.bytes>>>()
  92. const result = { ok: true as const, value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, data: btoa('all'), bytes: 3, eof: true } }
  93. onTestFinished(async () => {
  94. h.controller.abort()
  95. pending.resolve(result)
  96. await pending.promise
  97. })
  98. h.bytes.mockReturnValueOnce(pending.promise)
  99. const renderSlot = vi.fn(() => null)
  100. const props: TextPreviewProps = { ...h.props(), useDocumentPreviews: selector => selector([binary]), renderSlot }
  101. const view = render(<TextPreview {...props} />)
  102. expect(view.getByRole('status').hasAttribute('data-document-loading')).toBe(true)
  103. expect(view.getByRole('status').getAttribute('aria-label')).toBe('loading')
  104. expect(view.container.querySelector('[data-textpreview-body]')?.firstElementChild).toBe(view.getByRole('status'))
  105. expect(renderSlot).not.toHaveBeenCalled()
  106. await act(async () => {
  107. pending.resolve(result)
  108. await pending.promise
  109. })
  110. expect(renderSlot).toHaveBeenCalled()
  111. expect(view.queryByRole('status')).toBeNull()
  112. })
  113. it('retries and refreshes complete content and uses the read result path before metadata arrives', async () => {
  114. const h = harness()
  115. const result = { ok: true as const, value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, data: btoa('all'), bytes: 3, eof: true } }
  116. const read = h.bytes.mockResolvedValueOnce({
  117. ok: false, error: new RemoteError('workspace-file/not-found', 'Missing file', { path: ABSOLUTE_PATH }),
  118. }).mockResolvedValue(result)
  119. h.useResource.mockReturnValue({ status: 'loading', value: undefined, failure: undefined })
  120. const props: TextPreviewProps = {
  121. ...h.props(), useDocumentPreviews: selector => selector([binary]),
  122. }
  123. const view = render(<TextPreview {...props} />)
  124. await settle()
  125. expect(view.queryByRole('status')).toBeNull()
  126. fireEvent.click(view.container.querySelector('[data-textpreview-retry]')!)
  127. await settle()
  128. expect(read).toHaveBeenCalledTimes(2)
  129. expect(view.container.querySelector('[data-textpreview-path]')?.textContent).toBe(ABSOLUTE_PATH)
  130. expect(view.container.querySelector('[data-textpreview-tool="wrap"]')).toBeNull()
  131. expect(view.container.querySelector('[data-textpreview-failed]')).toBeNull()
  132. fireEvent.click(view.container.querySelector('[data-textpreview-tool="reload"]')!)
  133. await settle()
  134. expect(read).toHaveBeenCalledTimes(3)
  135. expect(read).toHaveBeenLastCalledWith(FILE, h.controller.signal)
  136. h.controller.abort()
  137. })
  138. it('keeps displayed content but does not request reads while the provider is absent', async () => {
  139. const h = harness({ 1: page(1, ['held'], false) })
  140. const view = render(<TextPreview {...h.props()} />)
  141. await settle()
  142. h.useResource.mockReturnValue({ status: 'none', value: undefined, failure: undefined })
  143. view.rerender(<TextPreview {...h.props()} />)
  144. fireEvent.click(view.container.querySelector('[data-textpreview-more]')!)
  145. fireEvent.click(view.container.querySelector('[data-textpreview-tool="reload"]')!)
  146. expect(h.read).toHaveBeenCalledTimes(1)
  147. expect(view.container.textContent).toContain('held')
  148. h.controller.abort()
  149. })
  150. it('drops the plain-text fallback for a declared binary suffix and hides the viewer control entirely', async () => {
  151. const h = harness()
  152. h.bytes.mockResolvedValue({ ok: true, value: { absolutePath: '/host/project/work/photo.png', version: 'v1', offset: 0, data: btoa('x'), bytes: 1, eof: true } })
  153. const image: DocumentPreviewDefinition = {
  154. id: 'image', extensions: ['png', 'svg'], binaryExtensions: ['png'], title: () => 'Image', loading: 'bytes-complete',
  155. }
  156. const plain: DocumentPreviewDefinition = {
  157. id: PLAIN_BODY_ID, extensions: [], title: () => 'viewer.text', loading: 'text-pages', wrap: true,
  158. }
  159. const base = h.props()
  160. const info = base.useTabInfo()
  161. const address = 'dsh-resource://file/session/s-1/work/photo.png'
  162. const props: TextPreviewProps = {
  163. ...base,
  164. useTabInfo: () => ({ ...info, tab: { ...info.tab, contentId: address, navigation: { ...info.tab.navigation, address } } }),
  165. useDocumentPreviews: selector => selector([image, plain]),
  166. renderSlot: vi.fn(() => null),
  167. } as TextPreviewProps
  168. const view = render(<TextPreview {...props} />)
  169. await settle()
  170. expect(view.container.querySelector('[data-document-viewer-menu]')).toBeNull()
  171. expect(view.container.querySelector('[data-textpreview-tool="reload"]')).not.toBeNull()
  172. h.controller.abort()
  173. })
  174. it('keeps the plain-text fallback in the picker for a non-binary suffix of the same viewer', async () => {
  175. const h = harness()
  176. h.bytes.mockResolvedValue({ ok: true, value: { absolutePath: '/host/project/work/logo.svg', version: 'v1', offset: 0, data: btoa('<svg/>'), bytes: 6, eof: true } })
  177. const image: DocumentPreviewDefinition = {
  178. id: 'image', extensions: ['png', 'svg'], binaryExtensions: ['png'], title: () => 'Image', loading: 'bytes-complete',
  179. }
  180. const plain: DocumentPreviewDefinition = {
  181. id: PLAIN_BODY_ID, extensions: [], title: () => 'viewer.text', loading: 'text-pages', wrap: true,
  182. }
  183. const base = h.props()
  184. const info = base.useTabInfo()
  185. const address = 'dsh-resource://file/session/s-1/work/logo.svg'
  186. const props: TextPreviewProps = {
  187. ...base,
  188. useTabInfo: () => ({ ...info, tab: { ...info.tab, contentId: address, navigation: { ...info.tab.navigation, address } } }),
  189. useDocumentPreviews: selector => selector([image, plain]),
  190. renderSlot: vi.fn(() => null),
  191. } as TextPreviewProps
  192. const view = render(<TextPreview {...props} />)
  193. await settle()
  194. fireEvent.click(view.container.querySelector('[data-document-viewer-menu]')!)
  195. expect(screen.getByRole('menuitem', { name: 'Image' })).toBeDefined()
  196. expect(screen.getByRole('menuitem', { name: 'viewer.text' })).toBeDefined()
  197. h.controller.abort()
  198. })
  199. it('dismisses the implementation picker with Escape without changing the selected implementation', async () => {
  200. const h = harness({ 1: page(1, ['held'], true) })
  201. const base = h.props()
  202. const code: DocumentPreviewDefinition = {
  203. id: 'code', extensions: ['md'], title: () => 'Code', loading: 'text-pages', wrap: true,
  204. }
  205. const plain: DocumentPreviewDefinition = {
  206. id: PLAIN_BODY_ID, extensions: [], title: () => 'viewer.text', loading: 'text-pages', wrap: true,
  207. }
  208. const props: TextPreviewProps = { ...base, useDocumentPreviews: selector => selector([code, plain]) }
  209. const view = render(<TextPreview {...props} />)
  210. await settle()
  211. fireEvent.click(view.container.querySelector('[data-document-viewer-menu]')!)
  212. expect(screen.getByRole('menuitem', { name: 'viewer.text' })).toBeDefined()
  213. fireEvent.keyDown(document, { key: 'Escape' })
  214. expect(screen.queryByRole('menu')).toBeNull()
  215. expect(h.instance.getSnapshot().byTab[TAB_ID]?.rendererId).toBeUndefined()
  216. h.controller.abort()
  217. })
  218. it('shows the unsupported empty state for a known binary suffix with no renderer, reading nothing', async () => {
  219. const h = harness()
  220. const base = h.props()
  221. const info = base.useTabInfo()
  222. const address = 'dsh-resource://file/session/s-1/work/clip.mp4'
  223. const props: TextPreviewProps = {
  224. ...base,
  225. useTabInfo: () => ({ ...info, tab: { ...info.tab, contentId: address, navigation: { ...info.tab.navigation, address } } }),
  226. }
  227. const view = render(<TextPreview {...props} />)
  228. await settle()
  229. expect(view.container.querySelector('[data-textpreview-state="unsupported"]')).not.toBeNull()
  230. expect(view.container.querySelector('[data-textpreview-unsupported]')?.textContent).toContain('unsupportedFile')
  231. expect(view.container.querySelector('[data-textpreview-path]')).not.toBeNull()
  232. expect(view.container.querySelector('[data-document-viewer-menu]')).toBeNull()
  233. expect(view.container.querySelector('[data-textpreview-tool="reload"]')).toBeNull()
  234. expect(h.read).not.toHaveBeenCalled()
  235. expect(h.bytes).not.toHaveBeenCalled()
  236. h.controller.abort()
  237. })
  238. it('keeps the plain-text fallback for an unlisted unknown suffix', async () => {
  239. const h = harness({ 1: page(1, ['plain line'], true) })
  240. const base = h.props()
  241. const info = base.useTabInfo()
  242. const address = 'dsh-resource://file/session/s-1/work/server.log'
  243. const props: TextPreviewProps = {
  244. ...base,
  245. useTabInfo: () => ({ ...info, tab: { ...info.tab, contentId: address, navigation: { ...info.tab.navigation, address } } }),
  246. }
  247. const view = render(<TextPreview {...props} />)
  248. await settle()
  249. expect(view.container.querySelector('[data-textpreview-state="unsupported"]')).toBeNull()
  250. expect(h.read).toHaveBeenCalledTimes(1)
  251. expect(view.container.textContent).toContain('plain line')
  252. h.controller.abort()
  253. })
  254. })