document-toolbar.client.spec.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  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 { textBodyDefinition, PLAIN_BODY_ID } from '../src/client/text/index.ts'
  11. import { pdfBodyDefinition } from '../src/client/pdf/index.ts'
  12. import { imageBodyDefinition } from '../src/client/image/index.ts'
  13. import { htmlBodyDefinition } from '../src/client/html/index.ts'
  14. import { documentSlots, ABSOLUTE_PATH, FILE, harness, page, settle, TAB_ID } from './fixtures.client.ts'
  15. afterEach(cleanup)
  16. const binary: DocumentPreviewDefinition = {
  17. id: 'complete-document', extensions: ['md'], title: () => 'Complete document', loading: 'bytes-complete',
  18. }
  19. function codeProps(h: ReturnType<typeof harness>): TextPreviewProps {
  20. const props = h.props()
  21. const definition: DocumentPreviewDefinition = {
  22. id: 'code', extensions: ['md'], title: () => 'Code', loading: 'text-pages', wrap: true,
  23. }
  24. return {
  25. ...props,
  26. useDocumentPreviews: selector => selector([definition]),
  27. renderSlot: documentSlots((_key, owner) => <CodeBody {...props} {...owner as unknown as OwnerOf<'sidebar.right.tab.document'>} t={key => key} />),
  28. }
  29. }
  30. describe('document toolbar', () => {
  31. it.each([
  32. ['report.doc', false], ['sheet.xls', false], ['slides.ppt', false],
  33. ['report.docx', false], ['sheet.xlsx', false], ['slides.pptx', false],
  34. ['report.pdf', false], ['image.png', false], ['image.SVG', true], ['page.html', true],
  35. ])('offers plain text for %s only when supported', async (path, supportsText) => {
  36. const h = harness()
  37. const props = h.props()
  38. const info = props.useTabInfo()
  39. const definitions = [
  40. textBodyDefinition(() => 'Plain text'), pdfBodyDefinition(() => 'PDF'),
  41. imageBodyDefinition(() => 'Image'), htmlBodyDefinition(() => 'HTML'),
  42. { ...binary, extensions: ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'], binaryExtensions: ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'] },
  43. ]
  44. h.bytes.mockResolvedValue({ ok: true, value: {
  45. absolutePath: path, version: 'v1', offset: 0, data: new TextEncoder().encode('all'), bytes: 3, eof: true,
  46. } })
  47. if (!supportsText) h.instance.actions.selected(TAB_ID, PLAIN_BODY_ID)
  48. const view = render(<TextPreview
  49. {...props}
  50. useTabInfo={() => ({ ...info, tab: { ...info.tab, contentId: `dsh-resource://file/session/s-1/${path}` } })}
  51. useDocumentPreviews={selector => selector(definitions)}
  52. renderSlot={() => null}
  53. />)
  54. await settle()
  55. const picker = view.queryByRole('button', { name: 'openWith' })
  56. expect(picker !== null).toBe(supportsText)
  57. expect(h.read).not.toHaveBeenCalled()
  58. expect(h.bytes).toHaveBeenCalledTimes(1)
  59. if (picker !== null) {
  60. fireEvent.click(picker)
  61. expect(screen.getByRole('menuitem', { name: 'Plain text' })).toBeDefined()
  62. } else {
  63. expect(view.container.textContent).not.toContain('Plain text')
  64. expect(view.queryByRole('button', { name: 'wrap.aria' })).toBeNull()
  65. }
  66. })
  67. it('shows no implementation picker when plain text is the only viewer', async () => {
  68. const h = harness({ 1: page(1, ['held'], true) })
  69. const view = render(<TextPreview {...h.props()} />)
  70. await settle()
  71. expect(view.queryByRole('button', { name: 'openWith' })).toBeNull()
  72. expect(view.container.textContent).toContain('held')
  73. })
  74. it('keeps Reading above the first code page and reload, retaining code during pagination', async () => {
  75. const h = harness()
  76. const first = Promise.withResolvers<Awaited<ReturnType<typeof h.read>>>()
  77. const next = Promise.withResolvers<Awaited<ReturnType<typeof h.read>>>()
  78. const reload = Promise.withResolvers<Awaited<ReturnType<typeof h.read>>>()
  79. onTestFinished(async () => {
  80. h.controller.abort()
  81. for (const pending of [first, next, reload]) pending.resolve(page(1, [], true))
  82. await Promise.all([first.promise, next.promise, reload.promise])
  83. })
  84. h.read.mockReturnValueOnce(first.promise).mockReturnValueOnce(next.promise).mockReturnValueOnce(reload.promise)
  85. const view = render(<TextPreview {...codeProps(h)} />)
  86. const body = view.container.querySelector('[data-textpreview-body]')
  87. expect(view.container.querySelector('[data-code-preview]')).toBeNull()
  88. expect(body?.firstElementChild).toBe(view.getByRole('status'))
  89. expect(view.getByRole('status').getAttribute('aria-label')).toBe('loading')
  90. expect(view.getByRole('status').textContent).toBe('')
  91. expect(view.container.querySelector('[data-textpreview-more]')).toBeNull()
  92. await act(async () => { first.resolve(page(1, ['const prefix = 1;'], false)); await first.promise })
  93. const renderer = view.container.querySelector('[data-code-preview]')
  94. expect(renderer).not.toBeNull()
  95. expect(renderer?.querySelector('pre')?.textContent).toBe('const prefix = 1;')
  96. expect(view.queryByRole('status')).toBeNull()
  97. fireEvent.click(view.getByRole('button', { name: 'loadMore' }))
  98. expect(view.container.querySelector('[data-code-preview]')).toBe(renderer)
  99. expect(renderer?.querySelector('pre')?.textContent).toBe('const prefix = 1;')
  100. expect(view.getByRole('status').closest('[data-textpreview-more]')).not.toBeNull()
  101. await act(async () => { next.resolve(page(2, ['const tail = 2;'], true)); await next.promise })
  102. expect(view.container.querySelector('[data-code-preview]')).toBe(renderer)
  103. expect(renderer?.querySelector('pre')?.textContent).toBe('const prefix = 1;\nconst tail = 2;')
  104. expect(view.queryByRole('status')).toBeNull()
  105. expect(view.container.querySelector('[data-textpreview-more]')).toBeNull()
  106. fireEvent.click(view.getByRole('button', { name: 'reload' }))
  107. expect(view.container.querySelector('[data-code-preview]')).toBeNull()
  108. expect(renderer?.isConnected).toBe(false)
  109. expect(body?.firstElementChild).toBe(view.getByRole('status'))
  110. expect(view.container.querySelector('[data-textpreview-more]')).toBeNull()
  111. await act(async () => { reload.resolve(page(1, ['const refreshed = 3;'], true, 'v2')); await reload.promise })
  112. expect(view.container.querySelector('[data-code-preview] pre')?.textContent).toBe('const refreshed = 3;')
  113. expect(view.queryByRole('status')).toBeNull()
  114. })
  115. it('mounts the code renderer after an empty file page completes', async () => {
  116. const h = harness()
  117. const pending = Promise.withResolvers<Awaited<ReturnType<typeof h.read>>>()
  118. onTestFinished(async () => {
  119. h.controller.abort()
  120. pending.resolve(page(1, [], true))
  121. await pending.promise
  122. })
  123. h.read.mockReturnValueOnce(pending.promise)
  124. const view = render(<TextPreview {...codeProps(h)} />)
  125. expect(view.container.querySelector('[data-code-preview]')).toBeNull()
  126. expect(view.getByRole('status').getAttribute('aria-label')).toBe('loading')
  127. await act(async () => { pending.resolve(page(1, [], true)); await pending.promise })
  128. expect(view.container.querySelector('[data-code-preview]')).not.toBeNull()
  129. expect(view.container.querySelector('[data-code-preview] pre')?.textContent).toBe('')
  130. expect(h.instance.getSnapshot().byTab[TAB_ID]?.pages).toEqual({ 1: { text: '', lines: 0 } })
  131. expect(view.queryByRole('status')).toBeNull()
  132. expect(view.container.querySelector('[data-textpreview-more]')).toBeNull()
  133. })
  134. it('shows the shared loading indicator until a complete read settles', async () => {
  135. const h = harness()
  136. const pending = Promise.withResolvers<Awaited<ReturnType<typeof h.bytes>>>()
  137. const result = { ok: true as const, value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, data: new TextEncoder().encode('all'), bytes: 3, eof: true } }
  138. onTestFinished(async () => {
  139. h.controller.abort()
  140. pending.resolve(result)
  141. await pending.promise
  142. })
  143. h.bytes.mockReturnValueOnce(pending.promise)
  144. const renderSlot = vi.fn(() => null)
  145. const props: TextPreviewProps = { ...h.props(), useDocumentPreviews: selector => selector([binary]), renderSlot }
  146. const view = render(<TextPreview {...props} />)
  147. expect(view.getByRole('status').hasAttribute('data-document-loading')).toBe(true)
  148. expect(view.getByRole('status').getAttribute('aria-label')).toBe('loading')
  149. expect(view.container.querySelector('[data-textpreview-body]')?.firstElementChild).toBe(view.getByRole('status'))
  150. expect(renderSlot).not.toHaveBeenCalled()
  151. await act(async () => {
  152. pending.resolve(result)
  153. await pending.promise
  154. })
  155. expect(renderSlot).toHaveBeenCalled()
  156. expect(view.queryByRole('status')).toBeNull()
  157. })
  158. it('mounts a renderer-owned body before content is available and withholds the previous pages', async () => {
  159. const h = harness({ 1: page(1, ['previous reader content'], true) })
  160. const view = render(<TextPreview {...h.props()} />)
  161. await settle()
  162. const renderSlot = vi.fn(() => null)
  163. view.rerender(<TextPreview {...h.props()} useDocumentPreviews={selector => selector([{ ...binary, loading: 'renderer' }])} renderSlot={renderSlot} />)
  164. expect(view.container.textContent).not.toContain('previous reader content')
  165. expect(renderSlot).toHaveBeenCalledWith('sidebar.right.tab.document', expect.objectContaining({
  166. content: expect.objectContaining({ kind: 'renderer' }) as unknown,
  167. }), expect.any(Object))
  168. expect(h.instance.getSnapshot().byTab[TAB_ID]?.complete).toBeUndefined()
  169. expect(h.bytes).not.toHaveBeenCalled()
  170. })
  171. it('retries and refreshes complete content when metadata lookup fails', async () => {
  172. const h = harness()
  173. const result = { ok: true as const, value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, data: new TextEncoder().encode('all'), bytes: 3, eof: true } }
  174. const read = h.bytes.mockResolvedValueOnce({
  175. ok: false, error: new RemoteError('workspace-file/not-found', 'Missing file', { path: ABSOLUTE_PATH }),
  176. }).mockResolvedValue(result)
  177. h.useResource.mockReturnValue({ status: 'failed', value: undefined,
  178. failure: new RemoteError('workspace-file/not-found', 'Missing file', { path: ABSOLUTE_PATH }) })
  179. const props: TextPreviewProps = {
  180. ...h.props(), useDocumentPreviews: selector => selector([binary]),
  181. }
  182. const view = render(<TextPreview {...props} />)
  183. await settle()
  184. expect(view.queryByRole('status')).toBeNull()
  185. fireEvent.click(view.container.querySelector('[data-textpreview-retry]')!)
  186. await settle()
  187. expect(read).toHaveBeenCalledTimes(2)
  188. expect(view.container.querySelector('[data-textpreview-path]')?.textContent).toBe(ABSOLUTE_PATH)
  189. expect(view.container.querySelector('[data-textpreview-tool="wrap"]')).toBeNull()
  190. expect(view.container.querySelector('[data-textpreview-failed]')).toBeNull()
  191. fireEvent.click(view.container.querySelector('[data-textpreview-tool="reload"]')!)
  192. await settle()
  193. expect(read).toHaveBeenCalledTimes(3)
  194. expect(read).toHaveBeenLastCalledWith(FILE, expect.any(AbortSignal))
  195. h.controller.abort()
  196. })
  197. it('keeps displayed content but does not request reads while the provider is absent', async () => {
  198. const h = harness({ 1: page(1, ['held'], false) })
  199. const view = render(<TextPreview {...h.props()} />)
  200. await settle()
  201. h.useResource.mockReturnValue({ status: 'none', value: undefined, failure: undefined })
  202. view.rerender(<TextPreview {...h.props()} />)
  203. fireEvent.click(view.container.querySelector('[data-textpreview-more]')!)
  204. fireEvent.click(view.container.querySelector('[data-textpreview-tool="reload"]')!)
  205. expect(h.read).toHaveBeenCalledTimes(1)
  206. expect(view.container.textContent).toContain('held')
  207. h.controller.abort()
  208. })
  209. it('drops the plain-text fallback for a declared binary suffix and hides the viewer control entirely', async () => {
  210. const h = harness()
  211. h.bytes.mockResolvedValue({ ok: true, value: { absolutePath: '/host/project/work/photo.png', version: 'v1', offset: 0, data: new TextEncoder().encode('x'), bytes: 1, eof: true } })
  212. const image: DocumentPreviewDefinition = {
  213. id: 'image', extensions: ['png', 'svg'], binaryExtensions: ['png'], title: () => 'Image', loading: 'bytes-complete',
  214. }
  215. const plain: DocumentPreviewDefinition = {
  216. id: PLAIN_BODY_ID, extensions: [], title: () => 'viewer.text', loading: 'text-pages', wrap: true,
  217. }
  218. const base = h.props()
  219. const info = base.useTabInfo()
  220. const address = 'dsh-resource://file/session/s-1/work/photo.png'
  221. const props: TextPreviewProps = {
  222. ...base,
  223. useTabInfo: () => ({ ...info, tab: { ...info.tab, contentId: address, navigation: { ...info.tab.navigation, address } } }),
  224. useDocumentPreviews: selector => selector([image, plain]),
  225. renderSlot: vi.fn(() => null),
  226. } as TextPreviewProps
  227. const view = render(<TextPreview {...props} />)
  228. await settle()
  229. expect(view.container.querySelector('[data-document-viewer-menu]')).toBeNull()
  230. expect(view.container.querySelector('[data-textpreview-tool="reload"]')).not.toBeNull()
  231. h.controller.abort()
  232. })
  233. it('keeps the plain-text fallback in the picker for a non-binary suffix of the same viewer', async () => {
  234. const h = harness()
  235. h.bytes.mockResolvedValue({ ok: true, value: { absolutePath: '/host/project/work/logo.svg', version: 'v1', offset: 0, data: new TextEncoder().encode('<svg/>'), bytes: 6, eof: true } })
  236. const image: DocumentPreviewDefinition = {
  237. id: 'image', extensions: ['png', 'svg'], binaryExtensions: ['png'], title: () => 'Image', loading: 'bytes-complete',
  238. }
  239. const plain: DocumentPreviewDefinition = {
  240. id: PLAIN_BODY_ID, extensions: [], title: () => 'viewer.text', loading: 'text-pages', wrap: true,
  241. }
  242. const base = h.props()
  243. const info = base.useTabInfo()
  244. const address = 'dsh-resource://file/session/s-1/work/logo.svg'
  245. const props: TextPreviewProps = {
  246. ...base,
  247. useTabInfo: () => ({ ...info, tab: { ...info.tab, contentId: address, navigation: { ...info.tab.navigation, address } } }),
  248. useDocumentPreviews: selector => selector([image, plain]),
  249. renderSlot: vi.fn(() => null),
  250. } as TextPreviewProps
  251. const view = render(<TextPreview {...props} />)
  252. await settle()
  253. fireEvent.click(view.container.querySelector('[data-document-viewer-menu]')!)
  254. expect(screen.getByRole('menuitem', { name: 'Image' })).toBeDefined()
  255. expect(screen.getByRole('menuitem', { name: 'viewer.text' })).toBeDefined()
  256. h.controller.abort()
  257. })
  258. it('dismisses the implementation picker with Escape without changing the selected implementation', async () => {
  259. const h = harness({ 1: page(1, ['held'], true) })
  260. const props = codeProps(h)
  261. const view = render(<TextPreview {...props} useDocumentPreviews={selector => selector([
  262. ...props.useDocumentPreviews(value => value), textBodyDefinition(() => 'viewer.text'),
  263. ])} />)
  264. await settle()
  265. fireEvent.click(view.container.querySelector('[data-document-viewer-menu]')!)
  266. expect(screen.getByRole('menuitem', { name: 'viewer.text' })).toBeDefined()
  267. fireEvent.keyDown(document, { key: 'Escape' })
  268. expect(screen.queryByRole('menu')).toBeNull()
  269. expect(h.instance.getSnapshot().byTab[TAB_ID]?.rendererId).toBeUndefined()
  270. h.controller.abort()
  271. })
  272. it('shows the unsupported empty state for a known binary suffix with no renderer, reading nothing', async () => {
  273. const h = harness()
  274. const base = h.props()
  275. const info = base.useTabInfo()
  276. const address = 'dsh-resource://file/session/s-1/work/clip.mp4'
  277. const props: TextPreviewProps = {
  278. ...base,
  279. useTabInfo: () => ({ ...info, tab: { ...info.tab, contentId: address, navigation: { ...info.tab.navigation, address } } }),
  280. }
  281. const view = render(<TextPreview {...props} />)
  282. await settle()
  283. expect(view.container.querySelector('[data-textpreview-state="unsupported"]')).not.toBeNull()
  284. expect(view.container.querySelector('[data-textpreview-unsupported]')?.textContent).toContain('unsupportedFile')
  285. expect(view.container.querySelector('[data-textpreview-path]')).not.toBeNull()
  286. expect(view.container.querySelector('[data-document-viewer-menu]')).toBeNull()
  287. expect(view.container.querySelector('[data-textpreview-tool="reload"]')).toBeNull()
  288. expect(h.read).not.toHaveBeenCalled()
  289. expect(h.bytes).not.toHaveBeenCalled()
  290. h.controller.abort()
  291. })
  292. it('keeps the plain-text fallback for an unlisted unknown suffix', async () => {
  293. const h = harness({ 1: page(1, ['plain line'], true) })
  294. const base = h.props()
  295. const info = base.useTabInfo()
  296. const address = 'dsh-resource://file/session/s-1/work/server.log'
  297. const props: TextPreviewProps = {
  298. ...base,
  299. useTabInfo: () => ({ ...info, tab: { ...info.tab, contentId: address, navigation: { ...info.tab.navigation, address } } }),
  300. }
  301. const view = render(<TextPreview {...props} />)
  302. await settle()
  303. expect(view.container.querySelector('[data-textpreview-state="unsupported"]')).toBeNull()
  304. expect(h.read).toHaveBeenCalledTimes(1)
  305. expect(view.container.textContent).toContain('plain line')
  306. h.controller.abort()
  307. })
  308. })