renderer-loading.client.spec.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. // @vitest-environment jsdom
  2. /** Renderer-owned loads retain displayed versions and retire work on reload, replacement, and close. */
  3. import { useEffect, useState, useSyncExternalStore } from 'react'
  4. import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
  5. import { afterEach, beforeEach, expect, it, onTestFinished, vi } from 'vitest'
  6. import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
  7. import type { OwnerOf } from '@deepseek-ai/dsh-client-ui-slots'
  8. import type { OfficeToPdfGeneration } from '@deepseek-ai/dsh-office-to-pdf/types'
  9. import type { DocumentPreviewDefinition } from '../src/client/document/registry.ts'
  10. import type { DocumentContent } from '../src/client/document/contract.ts'
  11. import { TextPreview, type TextPreviewProps } from '../src/client/TextPreview.tsx'
  12. import { OfficeBody, type OfficeBodyProps } from '../src/client/office/OfficeBody.tsx'
  13. import { createOfficeStore, type OfficeState } from '../src/client/office/store.ts'
  14. import type { ReadOfficeDocument } from '../src/client/office/cache.ts'
  15. import { en } from '../src/client/office/locales.ts'
  16. import { harness, ABSOLUTE_PATH, TAB_ID, settle } from './fixtures.client.ts'
  17. beforeEach(() => {
  18. vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
  19. })
  20. afterEach(() => { cleanup(); vi.unstubAllGlobals() })
  21. it('lets a non-Office renderer load content, report its version, and reload through the shared toolbar', async () => {
  22. const h = harness()
  23. const custom: DocumentPreviewDefinition = {
  24. id: 'custom-viewer', extensions: ['md'], binaryExtensions: ['md'], title: () => 'Custom', loading: 'renderer',
  25. }
  26. const read = vi.fn<(signal: AbortSignal) => Promise<{ text: string; version: string }>>()
  27. .mockResolvedValueOnce({ text: 'Custom content v1', version: 'v1' })
  28. .mockResolvedValueOnce({ text: 'Custom content v2', version: 'v2' })
  29. function CustomBody({ content }: { content: DocumentContent }) {
  30. const [file, setFile] = useState<{ text: string; version: string; revision: number }>()
  31. const request = content.kind === 'renderer' ? content : undefined
  32. const revision = request?.revision
  33. const displayed = file?.revision === revision ? file : undefined
  34. useEffect(() => {
  35. if (revision === undefined) return
  36. const controller = new AbortController()
  37. void read(controller.signal).then((file) => {
  38. if (controller.signal.aborted) return
  39. setFile({ ...file, revision })
  40. })
  41. return () => { controller.abort() }
  42. }, [revision])
  43. useEffect(() => { if (displayed !== undefined) request?.loaded(displayed.version) }, [displayed, request?.loaded])
  44. return <p>{displayed?.text ?? 'Loading custom content'}</p>
  45. }
  46. const renderSlot: TextPreviewProps['renderSlot'] = (_name, input) => {
  47. const owner = input as unknown as OwnerOf<'sidebar.right.tab.document'>
  48. return <CustomBody content={owner.content} />
  49. }
  50. const useDocumentPreviews: TextPreviewProps['useDocumentPreviews'] = selector => selector([custom])
  51. const view = render(<TextPreview {...h.props()} renderSlot={renderSlot} useDocumentPreviews={useDocumentPreviews} />)
  52. expect(read).toHaveBeenCalledTimes(1)
  53. expect(await screen.findByText('Custom content v1')).toBeTruthy()
  54. expect(h.instance.getSnapshot().byTab[TAB_ID]?.version).toBe('v1')
  55. h.setVersion('v2')
  56. view.rerender(<TextPreview {...h.props()} renderSlot={renderSlot} useDocumentPreviews={useDocumentPreviews} />)
  57. expect(screen.getByText('changed')).toBeTruthy()
  58. expect(read).toHaveBeenCalledTimes(1)
  59. fireEvent.click(screen.getByRole('button', { name: 'reloadNow' }))
  60. expect(await screen.findByText('Custom content v2')).toBeTruthy()
  61. expect(read).toHaveBeenCalledTimes(2)
  62. expect(h.instance.getSnapshot().byTab[TAB_ID]?.version).toBe('v2')
  63. expect(screen.queryByText('changed')).toBeNull()
  64. expect(h.read).not.toHaveBeenCalled()
  65. expect(h.bytes).not.toHaveBeenCalled()
  66. expect(h.instance.getSnapshot().byTab[TAB_ID]?.complete).toBeUndefined()
  67. })
  68. const definition: DocumentPreviewDefinition = {
  69. id: 'office', extensions: ['md'], binaryExtensions: ['md'], title: () => 'Office', loading: 'renderer',
  70. }
  71. type Result = Awaited<ReturnType<ReadOfficeDocument>>
  72. const result = (version = 'v1'): Result => ({ ok: true, value: {
  73. absolutePath: ABSOLUTE_PATH, version, data: new TextEncoder().encode(`PDF ${version}`),
  74. offset: 0, eof: true, missingFonts: [], generation: 'engine' as OfficeToPdfGeneration,
  75. } })
  76. function setup() {
  77. const h = harness()
  78. const office = createOfficeStore().create()
  79. const pending: { signal: AbortSignal; deferred: ReturnType<typeof Promise.withResolvers<Result>> }[] = []
  80. const read = vi.fn<ReadOfficeDocument>().mockImplementation((_file, signal) => {
  81. const deferred = Promise.withResolvers<Result>()
  82. pending.push({ signal, deferred })
  83. return deferred.promise
  84. })
  85. const retained = new Set<AbortSignal>()
  86. const retainTab: OfficeBodyProps['retainTab'] = (tab, signal) => {
  87. if (retained.has(signal)) return
  88. retained.add(signal)
  89. signal.addEventListener('abort', () => { office.actions.forget(tab) }, { once: true })
  90. }
  91. const subscribe = (listener: () => void) => office.subscribe(listener)
  92. const snapshot = () => office.getSnapshot()
  93. function useOffice<T>(selector: (state: OfficeState) => T): T {
  94. return selector(useSyncExternalStore(subscribe, snapshot))
  95. }
  96. const describeFailure: OfficeBodyProps['describeFailure'] = error => error.message
  97. let request: Extract<DocumentContent, { kind: 'renderer' }> | undefined
  98. const slots: TextPreviewProps['renderSlot'] = (_key, input, options) => {
  99. const owner = input as unknown as OwnerOf<'sidebar.right.tab.document'>
  100. if (owner.content.kind !== 'renderer') return <p>Raw bytes</p>
  101. request = owner.content
  102. // The component fixture supplies the standard seats used by Office; the real slot binding is exercised by the browser scenario.
  103. const props = { ...h.props(), ...owner, useTabInfo: options.hookContext, useStore: useOffice,
  104. actions: office.actions, read, retainTab, describeFailure,
  105. t: makeTranslate(en), renderSlot: (_name: string, child: { content: DocumentContent }) => (
  106. <p data-test-pdf>{child.content.kind === 'bytes' ? new TextDecoder().decode(child.content.data) : ''}</p>
  107. ),
  108. } as unknown as OfficeBodyProps
  109. return <OfficeBody {...props} />
  110. }
  111. function View({ renderer = true }: { renderer?: boolean }) {
  112. return <TextPreview {...h.props()} renderSlot={slots}
  113. useDocumentPreviews={selector => selector([renderer ? definition : { ...definition, id: 'raw', loading: 'bytes-complete' }])} />
  114. }
  115. onTestFinished(async () => {
  116. h.controller.abort()
  117. for (const { deferred } of pending) deferred.resolve(result())
  118. await Promise.allSettled(pending.map(item => item.deferred.promise))
  119. })
  120. return { h, office, pending, read, View, request: () => request! }
  121. }
  122. it('loads without reading raw bytes, retains content across remounts, and reloads only after a source-change action', async () => {
  123. const h = setup()
  124. let mounted = render(<h.View />)
  125. expect(screen.getByRole('status').getAttribute('aria-label')).toBe(en.loading)
  126. expect(h.h.bytes).not.toHaveBeenCalled()
  127. await act(async () => { h.pending[0]!.deferred.resolve(result()) })
  128. expect(screen.getByText('PDF v1')).toBeTruthy()
  129. expect(h.h.instance.getSnapshot().byTab[TAB_ID]?.version).toBe('v1')
  130. expect(h.h.instance.getSnapshot().byTab[TAB_ID]?.complete).toBeUndefined()
  131. mounted.unmount()
  132. mounted = render(<h.View />)
  133. expect(screen.getByText('PDF v1')).toBeTruthy()
  134. expect(h.read).toHaveBeenCalledTimes(1)
  135. h.h.setVersion('v2')
  136. mounted.rerender(<h.View />)
  137. expect(screen.getByText('changed')).toBeTruthy()
  138. expect(h.read).toHaveBeenCalledTimes(1)
  139. fireEvent.click(screen.getByRole('button', { name: 'reloadNow' }))
  140. expect(h.read).toHaveBeenCalledTimes(2)
  141. await act(async () => { h.pending[1]!.deferred.resolve(result('v2')) })
  142. expect(screen.queryByText('changed')).toBeNull()
  143. expect(screen.getByText('PDF v2')).toBeTruthy()
  144. })
  145. it('aborts a superseded load and rejects its late bytes and version report', async () => {
  146. const h = setup()
  147. render(<h.View />)
  148. const previous = h.request()
  149. fireEvent.click(screen.getByRole('button', { name: 'reload' }))
  150. expect(h.pending[0]!.signal.aborted).toBe(true)
  151. await act(async () => { h.pending[1]!.deferred.resolve(result('v2')) })
  152. await act(async () => { h.pending[0]!.deferred.resolve(result('v1')); previous.loaded('v1') })
  153. expect(screen.getByText('PDF v2')).toBeTruthy()
  154. expect(h.h.instance.getSnapshot().byTab[TAB_ID]?.version).toBe('v2')
  155. })
  156. it.each(['replace', 'close', 'hide'] as const)('retires pending conversion on %s and ignores a late rejection', async (transition) => {
  157. const h = setup()
  158. h.h.bytes.mockResolvedValue({ ok: true, value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, eof: true, data: new Uint8Array() } })
  159. const mounted = render(<h.View />)
  160. if (transition === 'replace') mounted.rerender(<h.View renderer={false} />)
  161. else if (transition === 'close') act(() => { h.h.controller.abort() })
  162. else mounted.unmount()
  163. expect(h.pending[0]!.signal.aborted).toBe(true)
  164. await act(async () => { h.pending[0]!.deferred.reject(new Error('late error')) })
  165. if (transition === 'close') {
  166. expect(h.office.getSnapshot().byTab[TAB_ID]).toBeUndefined()
  167. expect(h.h.instance.getSnapshot().byTab[TAB_ID]).toBeUndefined()
  168. } else expect(h.office.getSnapshot().byTab[TAB_ID]?.failure).toBeUndefined()
  169. if (transition === 'replace') {
  170. await settle()
  171. expect(screen.getByText('Raw bytes')).toBeTruthy()
  172. }
  173. })
  174. it.each(['declared', 'exception', 'foreign'] as const)('shows %s conversion failures and retries only while the resource is available', async (kind) => {
  175. const h = setup()
  176. const mounted = render(<h.View />)
  177. await act(async () => {
  178. if (kind === 'declared') h.pending[0]!.deferred.resolve({ ok: false, error: new RemoteError('workspace-file/not-found', 'Missing file', { path: ABSOLUTE_PATH }) })
  179. else h.pending[0]!.deferred.reject(kind === 'exception' ? new Error('Conversion failed') : 'Conversion failed')
  180. })
  181. expect(screen.getByText(kind === 'declared' ? 'Missing file' : 'Conversion failed')).toBeTruthy()
  182. const resource = h.h.useResource()
  183. h.h.useResource.mockReturnValue({ status: 'none', value: undefined, failure: undefined })
  184. mounted.rerender(<h.View />)
  185. fireEvent.click(screen.getByRole('button', { name: en.retry }))
  186. expect(h.read).toHaveBeenCalledTimes(1)
  187. expect(screen.getByText(kind === 'declared' ? 'Missing file' : 'Conversion failed')).toBeTruthy()
  188. h.h.useResource.mockReturnValue(resource)
  189. mounted.rerender(<h.View />)
  190. fireEvent.click(screen.getByRole('button', { name: en.retry }))
  191. expect(h.read).toHaveBeenCalledTimes(2)
  192. await act(async () => { h.pending[1]!.deferred.resolve(result()) })
  193. expect(screen.getByText('PDF v1')).toBeTruthy()
  194. expect(h.h.bytes).not.toHaveBeenCalled()
  195. })
  196. it('starts no conversion when a body receives ordinary shared content', () => {
  197. const h = setup()
  198. const props = { ...h.h.props(), content: { kind: 'bytes', data: new Uint8Array() },
  199. useStore: () => undefined, read: h.read, retainTab: vi.fn(), describeFailure: vi.fn(),
  200. } as unknown as OfficeBodyProps
  201. const view = render(<OfficeBody {...props} />)
  202. expect(view.container.childElementCount).toBe(0)
  203. expect(h.read).not.toHaveBeenCalled()
  204. })