document-seat.client.spec.tsx 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. // @vitest-environment jsdom
  2. /** Document extension registration and dispatch through the production Sidebar and Slot renderer. */
  3. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  4. import { act, fireEvent, screen, waitFor } from '@testing-library/react'
  5. import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
  6. import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
  7. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  8. import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
  9. import { absoluteFileAddress, sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
  10. import { apply as resourcesApply, inject as resourcesInject } from '@deepseek-ai/dsh-client-resources/src/client/index.ts'
  11. import { apply as sidebarApply, inject as sidebarInject } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/index.ts'
  12. import { apply, inject } from '../src/client/index.ts'
  13. import type { DocumentPreviewProps } from '../src/client/document/contract.ts'
  14. import type { DocumentLoadMode } from '../src/client/document/registry.ts'
  15. import { PLAIN_BODY_ID } from '../src/client/text/index.ts'
  16. const SESSION = 'documents' as SessionId
  17. let runtime: SlotTestRuntime | undefined
  18. let animations: PropertyDescriptor | undefined
  19. beforeEach(() => {
  20. animations = Object.getOwnPropertyDescriptor(Element.prototype, 'getAnimations')
  21. Object.defineProperty(Element.prototype, 'getAnimations', { configurable: true, value: () => [] })
  22. })
  23. afterEach(async () => {
  24. try {
  25. await runtime?.dispose()
  26. runtime = undefined
  27. } finally {
  28. if (animations === undefined) Reflect.deleteProperty(Element.prototype, 'getAnimations')
  29. else Object.defineProperty(Element.prototype, 'getAnimations', animations)
  30. }
  31. })
  32. async function boot() {
  33. const rt = await SlotTestRuntime.create()
  34. runtime = rt
  35. rt.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never)
  36. const locale = new LocaleRuntime(rt.ctx)
  37. rt.ctx.provide('locale', locale)
  38. rt.slots.installLocale(locale)
  39. await rt.declare({
  40. rightbar: { kind: 'single', scope: 'root' },
  41. 'conversation.session.header.corner': { kind: 'single', scope: 'session' },
  42. })
  43. await rt.sessions.add({ id: SESSION })
  44. await rt.sessions.retainFor(rt.ctx, SESSION, { source: 'mainView' }).ready
  45. await rt.mount({ inject: [...resourcesInject], apply: resourcesApply })
  46. const read = vi.fn<ClientRemote['workspaceFiles']['read']>().mockImplementation(async (_sessionId, _path, range) => ({
  47. ok: true,
  48. value: { absolutePath: '/host/notes', version: 'v1', bytes: 18, offset: range.offset ?? 1, text: range.offset === 3 ? 'third' : 'first\nsecond', lines: range.offset === 3 ? 1 : 2, eof: range.offset === 3 },
  49. }))
  50. const bytes = vi.fn<ClientRemote['workspaceFiles']['readAll']>().mockResolvedValue({
  51. ok: true, value: { absolutePath: '/host/notes', version: 'v1', offset: 0, data: btoa('all'), bytes: 3, eof: true },
  52. })
  53. const workspaceFiles = { read, readAll: bytes }
  54. rt.remote.provideNamespaces({ workspaceFiles })
  55. rt.ctx.effect(() => rt.ctx.resources.register({
  56. protocol: 'file',
  57. open: async function* (_address, { signal }) {
  58. if (!signal.aborted) yield { ok: true as const, value: { absolutePath: '/host/notes', version: 'v1', bytes: 18 } }
  59. },
  60. }))
  61. await rt.mount({ inject: [...sidebarInject], apply: sidebarApply })
  62. await rt.mount({ inject: [...inject], apply })
  63. const view = rt.renderSlot('rightbar', { width: 600, viewportWidth: 1440, canShow: true })
  64. const open = (name: string): void => {
  65. act(() => { rt.ctx.sidebarRight.openResource('dsh-resource://file/session/documents/' + name) })
  66. }
  67. const register = (id: string, loading: DocumentLoadMode, priority: 'builtin' | 'extension') => rt.ctx.effect(() => {
  68. const removeDefinition = rt.ctx.documentPreviews.register({ id, extensions: ['md'], priority, title: () => id, loading, wrap: loading === 'text-pages' })
  69. const removeBody = rt.slots.inject('sidebar.right.tab.document', () => rt.slots.register(
  70. { name: 'sidebar.right.tab.document', key: id },
  71. (props: DocumentPreviewProps) => {
  72. const { tab } = props.useTabInfo()
  73. const resource = props.useResource<'file'>(props.resourceAddress)
  74. expect(resource).not.toHaveProperty('operations')
  75. expect('ctx' in props).toBe(false)
  76. expect('useDocumentPreview' in props).toBe(false)
  77. return (
  78. <div
  79. data-renderer={id} data-renderer-tab={tab.id}
  80. data-renderer-path={resource.value?.absolutePath} data-renderer-version={resource.value?.version}
  81. >
  82. {props.content.kind === 'text' ? props.content.text : props.content.kind === 'bytes' ? new TextDecoder().decode(props.content.data) : 'renderer'}
  83. </div>
  84. )
  85. },
  86. ))
  87. return () => { removeBody(); removeDefinition() }
  88. })
  89. return { rt, view, open, register, read, bytes }
  90. }
  91. describe('document extension seat', () => {
  92. it.each(['notes.unknown', '/other/notes.unknown', 'C:/other/notes.unknown', '//host/share/notes.unknown'])(
  93. 'reads %s through its addressed Session rather than the mounted Tab Session',
  94. async (path) => {
  95. const h = await boot()
  96. act(() => { h.rt.ctx.sidebarRight.openResource(sessionFileAddress('address-session', path)) })
  97. await waitFor(() => { expect(h.view.container.querySelectorAll('[data-textpreview-line]')).toHaveLength(2) })
  98. expect(h.read).toHaveBeenCalledExactlyOnceWith('address-session', path, { offset: 1 }, expect.any(AbortSignal))
  99. expect(h.bytes).not.toHaveBeenCalled()
  100. },
  101. )
  102. it('rejects an unscoped absolute address through candidates before mounting a reader', async () => {
  103. const h = await boot()
  104. const address = absoluteFileAddress('/other/notes.unknown')
  105. expect(h.rt.ctx.sidebarRightTabs.candidates(address)).toEqual([])
  106. expect(() => {
  107. act(() => { h.rt.ctx.sidebarRight.openResource(address) })
  108. }).toThrow('no registered tab type claims')
  109. expect(() => {
  110. act(() => { h.rt.ctx.sidebarRight.openResource(address, { kind: 'text' }) })
  111. }).toThrow('tab type "text" refuses')
  112. expect(h.read).not.toHaveBeenCalled()
  113. expect(h.bytes).not.toHaveBeenCalled()
  114. expect(h.view.container.querySelector('[data-textpreview-state]')).toBeNull()
  115. })
  116. it('uses the plain-text body for an unknown extension and loads another page at the scroll edge', async () => {
  117. const h = await boot()
  118. h.open('notes.unknown')
  119. await waitFor(() => { expect(h.view.container.querySelectorAll('[data-textpreview-line]')).toHaveLength(2) })
  120. expect(h.view.container.querySelector('[data-document-preview]')?.getAttribute('data-document-preview')).toBe(PLAIN_BODY_ID)
  121. const body = h.view.container.querySelector<HTMLElement>('[data-textpreview-body]')!
  122. Object.defineProperties(body, { clientHeight: { configurable: true, value: 100 }, scrollHeight: { configurable: true, value: 200 } })
  123. fireEvent.scroll(body, { target: { scrollTop: 100 } })
  124. await waitFor(() => { expect(h.view.container.querySelectorAll('[data-textpreview-line]')).toHaveLength(3) })
  125. expect(h.read.mock.calls.map(([_sessionId, _path, range]) => range.offset)).toEqual([1, 3])
  126. })
  127. it('defaults to an extension, switches implementations without changing tabs, and restores a builtin on removal', async () => {
  128. const h = await boot()
  129. await act(async () => {
  130. h.register('builtin-reader', 'text-pages', 'builtin')
  131. h.register('extension-reader', 'bytes-complete', 'extension')
  132. })
  133. h.open('notes.md')
  134. await waitFor(() => {
  135. const body = h.view.container.querySelector('[data-renderer="extension-reader"]')
  136. expect(body?.textContent).toBe('all')
  137. expect(body?.getAttribute('data-renderer-path')).toBe('/host/notes')
  138. expect(body?.getAttribute('data-renderer-version')).toBe('v1')
  139. })
  140. const tab = h.view.container.querySelector('[data-renderer-tab]')?.getAttribute('data-renderer-tab')
  141. expect(h.read).not.toHaveBeenCalled()
  142. expect(h.bytes).toHaveBeenCalledTimes(1)
  143. fireEvent.click(h.view.container.querySelector('[data-document-viewer-menu]')!)
  144. fireEvent.click(screen.getByRole('menuitem', { name: 'builtin-reader' }))
  145. await waitFor(() => { expect(h.view.container.querySelector('[data-renderer="builtin-reader"]')?.textContent).toBe('first\nsecond') })
  146. expect(h.view.container.querySelector('[data-renderer-tab]')?.getAttribute('data-renderer-tab')).toBe(tab)
  147. await act(async () => { h.register('later-reader', 'text-pages', 'extension') })
  148. expect(h.view.container.querySelector('[data-renderer="builtin-reader"]')).not.toBeNull()
  149. })
  150. it('returns to the matching builtin when the selected implementation is unregistered', async () => {
  151. const h = await boot()
  152. let remove: ReturnType<typeof h.register> | undefined
  153. await act(async () => {
  154. h.register('builtin-reader', 'text-pages', 'builtin')
  155. remove = h.register('extension-reader', 'text-pages', 'extension')
  156. })
  157. h.open('notes.md')
  158. await waitFor(() => {
  159. expect(h.view.container.querySelector('[data-renderer="extension-reader"]')?.getAttribute('data-renderer-version')).toBe('v1')
  160. })
  161. await act(async () => { await remove!() })
  162. await waitFor(() => { expect(h.view.container.querySelector('[data-document-markdown]')).not.toBeNull() })
  163. expect(h.read).toHaveBeenCalledTimes(1)
  164. })
  165. })