document-toolbar.client.spec.tsx 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  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 { ABSOLUTE_PATH, FILE, harness, page, settle, TAB_ID } from './fixtures.client.ts'
  11. afterEach(cleanup)
  12. const binary: DocumentPreviewDefinition = {
  13. id: 'complete-document', extensions: ['md'], title: () => 'Complete document', loading: 'bytes-complete',
  14. }
  15. function codeProps(h: ReturnType<typeof harness>): TextPreviewProps {
  16. const props = h.props()
  17. const definition: DocumentPreviewDefinition = {
  18. id: 'code', extensions: ['md'], title: () => 'Code', loading: 'text-pages', wrap: true,
  19. }
  20. return {
  21. ...props,
  22. useDocumentPreviews: selector => selector([definition]),
  23. // This adapter only receives the concrete document slot, not an arbitrary generic key.
  24. renderSlot: (_key, owner) => <CodeBody {...props} {...owner as unknown as OwnerOf<'sidebar.right.tab.document'>} t={key => key} />,
  25. }
  26. }
  27. describe('document toolbar', () => {
  28. it('keeps Reading above the first code page and reload, retaining code during pagination', async () => {
  29. const h = harness()
  30. const first = Promise.withResolvers<Awaited<ReturnType<typeof h.read>>>()
  31. const next = Promise.withResolvers<Awaited<ReturnType<typeof h.read>>>()
  32. const reload = Promise.withResolvers<Awaited<ReturnType<typeof h.read>>>()
  33. onTestFinished(async () => {
  34. h.controller.abort()
  35. for (const pending of [first, next, reload]) pending.resolve(page(1, [], true))
  36. await Promise.all([first.promise, next.promise, reload.promise])
  37. })
  38. h.read.mockReturnValueOnce(first.promise).mockReturnValueOnce(next.promise).mockReturnValueOnce(reload.promise)
  39. const view = render(<TextPreview {...codeProps(h)} />)
  40. const body = view.container.querySelector('[data-textpreview-body]')
  41. expect(view.container.querySelector('[data-code-preview]')).toBeNull()
  42. expect(body?.firstElementChild).toBe(view.getByRole('status'))
  43. expect(view.getByRole('status').textContent).toBe('loading')
  44. expect(view.container.querySelector('[data-textpreview-more]')).toBeNull()
  45. await act(async () => { first.resolve(page(1, ['const prefix = 1;'], false)); await first.promise })
  46. const renderer = view.container.querySelector('[data-code-preview]')
  47. expect(renderer).not.toBeNull()
  48. expect(renderer?.querySelector('pre')?.textContent).toBe('const prefix = 1;')
  49. expect(view.queryByRole('status')).toBeNull()
  50. fireEvent.click(view.getByRole('button', { name: 'loadMore' }))
  51. expect(view.container.querySelector('[data-code-preview]')).toBe(renderer)
  52. expect(renderer?.querySelector('pre')?.textContent).toBe('const prefix = 1;')
  53. expect(view.getByRole('status').closest('[data-textpreview-more]')).not.toBeNull()
  54. await act(async () => { next.resolve(page(2, ['const tail = 2;'], true)); await next.promise })
  55. expect(view.container.querySelector('[data-code-preview]')).toBe(renderer)
  56. expect(renderer?.querySelector('pre')?.textContent).toBe('const prefix = 1;\nconst tail = 2;')
  57. expect(view.queryByRole('status')).toBeNull()
  58. expect(view.container.querySelector('[data-textpreview-more]')).toBeNull()
  59. fireEvent.click(view.getByRole('button', { name: 'reload' }))
  60. expect(view.container.querySelector('[data-code-preview]')).toBeNull()
  61. expect(renderer?.isConnected).toBe(false)
  62. expect(body?.firstElementChild).toBe(view.getByRole('status'))
  63. expect(view.container.querySelector('[data-textpreview-more]')).toBeNull()
  64. await act(async () => { reload.resolve(page(1, ['const refreshed = 3;'], true, 'v2')); await reload.promise })
  65. expect(view.container.querySelector('[data-code-preview] pre')?.textContent).toBe('const refreshed = 3;')
  66. expect(view.queryByRole('status')).toBeNull()
  67. })
  68. it('mounts the code renderer after an empty file page completes', async () => {
  69. const h = harness()
  70. const pending = Promise.withResolvers<Awaited<ReturnType<typeof h.read>>>()
  71. onTestFinished(async () => {
  72. h.controller.abort()
  73. pending.resolve(page(1, [], true))
  74. await pending.promise
  75. })
  76. h.read.mockReturnValueOnce(pending.promise)
  77. const view = render(<TextPreview {...codeProps(h)} />)
  78. expect(view.container.querySelector('[data-code-preview]')).toBeNull()
  79. expect(view.getByRole('status').textContent).toBe('loading')
  80. await act(async () => { pending.resolve(page(1, [], true)); await pending.promise })
  81. expect(view.container.querySelector('[data-code-preview]')).not.toBeNull()
  82. expect(view.container.querySelector('[data-code-preview] pre')?.textContent).toBe('')
  83. expect(h.instance.getSnapshot().byTab[TAB_ID]?.pages).toEqual({ 1: { text: '', lines: 0 } })
  84. expect(view.queryByRole('status')).toBeNull()
  85. expect(view.container.querySelector('[data-textpreview-more]')).toBeNull()
  86. })
  87. it('shows the shared loading indicator until a complete read settles', async () => {
  88. const h = harness()
  89. const pending = Promise.withResolvers<Awaited<ReturnType<typeof h.bytes>>>()
  90. const result = { ok: true as const, value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, data: btoa('all'), bytes: 3, eof: true } }
  91. onTestFinished(async () => {
  92. h.controller.abort()
  93. pending.resolve(result)
  94. await pending.promise
  95. })
  96. h.bytes.mockReturnValueOnce(pending.promise)
  97. const renderSlot = vi.fn(() => null)
  98. const props: TextPreviewProps = { ...h.props(), useDocumentPreviews: selector => selector([binary]), renderSlot }
  99. const view = render(<TextPreview {...props} />)
  100. expect(view.getByRole('status').hasAttribute('data-document-loading')).toBe(true)
  101. expect(view.getByRole('status').textContent).toBe('loading')
  102. expect(view.container.querySelector('[data-textpreview-body]')?.firstElementChild).toBe(view.getByRole('status'))
  103. expect(renderSlot).not.toHaveBeenCalled()
  104. await act(async () => {
  105. pending.resolve(result)
  106. await pending.promise
  107. })
  108. expect(renderSlot).toHaveBeenCalled()
  109. expect(view.queryByRole('status')).toBeNull()
  110. })
  111. it('retries and refreshes complete content and uses the read result path before metadata arrives', async () => {
  112. const h = harness()
  113. const result = { ok: true as const, value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, data: btoa('all'), bytes: 3, eof: true } }
  114. const read = h.bytes.mockResolvedValueOnce({
  115. ok: false, error: new RemoteError('workspace-file/not-found', 'Missing file', { path: ABSOLUTE_PATH }),
  116. }).mockResolvedValue(result)
  117. h.useResource.mockReturnValue({ status: 'loading', value: undefined, failure: undefined })
  118. const props: TextPreviewProps = {
  119. ...h.props(), useDocumentPreviews: selector => selector([binary]),
  120. }
  121. const view = render(<TextPreview {...props} />)
  122. await settle()
  123. expect(view.queryByRole('status')).toBeNull()
  124. fireEvent.click(view.container.querySelector('[data-textpreview-retry]')!)
  125. await settle()
  126. expect(read).toHaveBeenCalledTimes(2)
  127. expect(view.container.querySelector('[data-textpreview-path]')?.textContent).toBe(ABSOLUTE_PATH)
  128. expect(view.container.querySelector('[data-textpreview-tool="wrap"]')).toBeNull()
  129. expect(view.container.querySelector('[data-textpreview-failed]')).toBeNull()
  130. fireEvent.click(view.container.querySelector('[data-textpreview-tool="reload"]')!)
  131. await settle()
  132. expect(read).toHaveBeenCalledTimes(3)
  133. expect(read).toHaveBeenLastCalledWith(FILE, h.controller.signal)
  134. h.controller.abort()
  135. })
  136. it('keeps displayed content but does not request reads while the provider is absent', async () => {
  137. const h = harness({ 1: page(1, ['held'], false) })
  138. const view = render(<TextPreview {...h.props()} />)
  139. await settle()
  140. h.useResource.mockReturnValue({ status: 'none', value: undefined, failure: undefined })
  141. view.rerender(<TextPreview {...h.props()} />)
  142. fireEvent.click(view.container.querySelector('[data-textpreview-more]')!)
  143. fireEvent.click(view.container.querySelector('[data-textpreview-tool="reload"]')!)
  144. expect(h.read).toHaveBeenCalledTimes(1)
  145. expect(view.container.textContent).toContain('held')
  146. h.controller.abort()
  147. })
  148. it('dismisses the implementation picker with Escape without changing the selected implementation', async () => {
  149. const h = harness({ 1: page(1, ['held'], true) })
  150. const view = render(<TextPreview {...h.props()} />)
  151. await settle()
  152. fireEvent.click(view.container.querySelector('[data-document-viewer-menu]')!)
  153. expect(screen.getByRole('menuitem', { name: 'viewer.text' })).toBeDefined()
  154. fireEvent.keyDown(document, { key: 'Escape' })
  155. expect(screen.queryByRole('menu')).toBeNull()
  156. expect(h.instance.getSnapshot().byTab[TAB_ID]?.rendererId).toBeUndefined()
  157. h.controller.abort()
  158. })
  159. })