office-registration.client.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. /** Authorized Host PDFs remain binary through Client reuse and disposal. */
  2. import type { OfficeToPdfGeneration } from '@deepseek-ai/dsh-office-to-pdf/types'
  3. import { Context } from '@deepseek-ai/cordis'
  4. import { expect, it, vi } from 'vitest'
  5. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  6. import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'
  7. import type {} from '@deepseek-ai/dsh-office-to-pdf/remote'
  8. import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
  9. import { DocumentPreviewRegistry } from '../src/client/document/registry.ts'
  10. import { apply } from '../src/client/office/index.ts'
  11. import { Config } from '../src/config.ts'
  12. import { OfficeBody, type OfficeBodyInjected } from '../src/client/office/OfficeBody.tsx'
  13. import type { OfficeStore } from '../src/client/office/store.ts'
  14. import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
  15. import { en, zh } from '../src/client/office/locales.ts'
  16. import { en as documentEn } from '../src/client/locales.ts'
  17. const file = { sessionId: 's1' as SessionId, path: 'report.DOCX' }
  18. const generation = ('renderer' as OfficeToPdfGeneration)
  19. const source = { absolutePath: '/report.docx', version: 'v1', offset: 0, eof: true, bytes: 4, data: 'JVBERg==' }
  20. const pdf = new Uint8Array([37, 80, 68, 70])
  21. const converted = { ok: true as const, value: { ...source, generation, missingFonts: ['Missing Serif'] } }
  22. it('retains Office view state across remounts and releases it on tab close or plugin disposal', async () => {
  23. const h = await harness()
  24. const first = 'first' as TabId
  25. const second = 'second' as TabId
  26. const closed = new AbortController()
  27. const retained = new AbortController()
  28. try {
  29. h.instance.actions.loading(first, 1)
  30. h.injected.retainTab(first, closed.signal)
  31. h.injected.retainTab(first, closed.signal)
  32. expect(h.instance.getSnapshot().byTab[first]).toBeDefined()
  33. closed.abort()
  34. expect(h.instance.getSnapshot().byTab[first]).toBeUndefined()
  35. h.instance.actions.loading(first, 2)
  36. h.injected.retainTab(first, closed.signal)
  37. expect(h.instance.getSnapshot().byTab[first]).toBeUndefined()
  38. h.instance.actions.loading(second, 1)
  39. h.injected.retainTab(second, retained.signal)
  40. const message = documentEn['error.unavailable'].replace('{message}', 'conversion stopped')
  41. expect(h.injected.describeFailure(new RemoteError('gateway/internal', 'conversion stopped', {}))).toBe(message)
  42. expect(h.injected.describeFailure({ message: 'conversion stopped' })).toBe(message)
  43. await h.close()
  44. expect(h.instance.getSnapshot().byTab[second]).toBeUndefined()
  45. } finally { closed.abort(); retained.abort(); await h.close() }
  46. })
  47. async function harness(config: Partial<Config['office']> = {}, missing?: 'remote' | 'render' | 'files') {
  48. const ctx = new Context()
  49. const registry = new DocumentPreviewRegistry()
  50. const removeLocale = vi.fn()
  51. const locale = { register: vi.fn(() => removeLocale), bind: (name: string) => name === 'sidebarDocumentPreview' ? makeTranslate(documentEn) : makeTranslate(en) }
  52. const render = vi.fn<ClientRemote['officeToPdf']['render']>().mockResolvedValue(converted)
  53. const rendererGeneration = vi.fn<ClientRemote['officeToPdf']['generation']>().mockResolvedValue({ ok: true, value: generation })
  54. const stat = vi.fn<ClientRemote['workspaceFiles']['stat']>().mockResolvedValue({ ok: true, value: source })
  55. const readBytes = vi.fn<ClientRemote['workspaceFiles']['readBytes']>().mockResolvedValue({ ok: true, value: source })
  56. const removeNotice = vi.fn()
  57. const recorded: { options: { name: string; store: OfficeStore; inject: (id: SessionId, actions: ReturnType<OfficeStore['create']>['actions']) => OfficeBodyInjected }; component: unknown }[] = []
  58. const register = vi.fn((options: typeof recorded[number]['options'], component: unknown) => { recorded.push({ options, component }); return removeNotice })
  59. ctx.provide('documentPreviews', registry)
  60. ctx.provide('slots', { inject: (_name: string, effect: () => () => void) => effect(), register } as never)
  61. ctx.provide('locale', locale as never)
  62. if (missing !== 'remote') {
  63. ctx.provide('remote', { workspaceFiles: { stat, readBytes }, officeToPdf: { render, generation: rendererGeneration } } as never)
  64. if (missing !== 'files') ctx.provide('remote.workspaceFiles', { stat, readBytes } as never)
  65. if (missing !== 'render') ctx.provide('remote.officeToPdf', { render, generation: rendererGeneration } as never)
  66. }
  67. const fiber = ctx.plugin({ apply: (scope: Context) => {
  68. apply(scope, Config({ office: config }).office)
  69. } })
  70. await fiber.await()
  71. const entry = recorded.find(entry => entry.component === OfficeBody)!.options
  72. const instance = entry.store.create()
  73. const injected = entry.inject(file.sessionId, instance.actions)
  74. return { ctx, registry, instance, injected, recorded, locale, removeLocale, render, rendererGeneration,
  75. stat, readBytes, register, removeNotice,
  76. read: (signal = new AbortController().signal, path = file.path) => injected.read({ ...file, path }, signal),
  77. close: () => fiber.dispose(),
  78. }
  79. }
  80. it.each(['remote', 'render', 'files'] as const)('keeps Office registration and guidance when %s is absent', async (missing) => {
  81. const h = await harness(undefined, missing)
  82. try {
  83. expect(h.locale.register).toHaveBeenCalledWith('sidebarOffice', { zh, en })
  84. for (const path of ['a.DOC', 'b.DOCX', 'c.XLS', 'd.xlsx', 'e.PPT', 'f.pptx']) {
  85. expect(h.registry.candidates(path)[0]!.binaryExtensions).toEqual(['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'])
  86. expect(h.registry.candidates(path)[0]!.title()).toBe(en.title)
  87. expect(h.registry.candidates(path)[0]!.loading).toBe('renderer')
  88. expect(h.registry.candidates(path)[0]).not.toHaveProperty('read')
  89. await expect(h.read(undefined, path)).rejects.toThrow(en.unavailable)
  90. }
  91. expect(h.render).not.toHaveBeenCalled()
  92. } finally { await h.close() }
  93. expect(h.registry.getSnapshot()).toEqual([])
  94. expect(h.removeLocale).toHaveBeenCalledOnce()
  95. expect(h.register).toHaveBeenCalledWith(expect.objectContaining({
  96. name: 'sidebar.right.tab.document', key: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office', locale: 'sidebarOffice',
  97. }), OfficeBody)
  98. expect(h.removeNotice).toHaveBeenCalledTimes(2)
  99. })
  100. it('requests a Host PDF with source identity and borrows the same binary cache result', async () => {
  101. const h = await harness({ maxCachedBytes: pdf.byteLength })
  102. try {
  103. const result = await h.read()
  104. expect(h.render).toHaveBeenCalledExactlyOnceWith(file.sessionId, file.path, 'foreground', expect.any(AbortSignal))
  105. expect(result).toEqual({ ok: true, value: { ...source, data: pdf, generation, missingFonts: ['Missing Serif'] } })
  106. expect(await h.read()).toBe(result)
  107. expect(h.stat).toHaveBeenCalledTimes(2)
  108. expect(h.readBytes).toHaveBeenCalledTimes(2)
  109. expect(h.readBytes).toHaveBeenCalledWith(file.sessionId, file.path, { offset: 0, length: 1 }, expect.any(AbortSignal))
  110. expect(h.render).toHaveBeenCalledOnce()
  111. } finally { await h.close() }
  112. })
  113. it.each([
  114. ['rendererGeneration', 'gateway/invocation-unavailable'], ['rendererGeneration', 'gateway/service-unavailable'],
  115. ['render', 'gateway/invocation-unavailable'], ['render', 'gateway/service-unavailable'],
  116. ] as const)('shows configuration guidance when %s returns %s', async (method, code) => {
  117. const h = await harness()
  118. try {
  119. const endpoint = method === 'render' ? 'officeToPdf/render' : 'officeToPdf/generation'
  120. const failure = new RemoteError(code, 'Office provider is unavailable.', { endpoint })
  121. h[method].mockResolvedValueOnce({ ok: false, error: failure })
  122. await expect(h.read()).rejects.toMatchObject({ message: en.unavailable, cause: failure })
  123. expect((await h.read()).ok).toBe(true)
  124. } finally { await h.close() }
  125. })
  126. it('shows configuration guidance when the Host exposes no Office HTTP endpoint', async () => {
  127. const h = await harness()
  128. try {
  129. const failure = new RemoteError('gateway/internal', 'transport failure for /api/officeToPdf/generation: HTTP 404', {})
  130. h.rendererGeneration.mockResolvedValueOnce({ ok: false, error: failure })
  131. await expect(h.read()).rejects.toMatchObject({ message: en.unavailable, cause: failure })
  132. expect(h.readBytes).not.toHaveBeenCalled()
  133. expect(h.render).not.toHaveBeenCalled()
  134. } finally { await h.close() }
  135. })
  136. it('refuses Client cached bytes when metadata still succeeds but the read probe fails', async () => {
  137. const h = await harness()
  138. try {
  139. await h.read()
  140. const failure = new Error('read denied')
  141. h.readBytes.mockRejectedValueOnce(failure)
  142. await expect(h.read()).rejects.toBe(failure)
  143. expect(h.render).toHaveBeenCalledOnce()
  144. expect(h.stat).toHaveBeenCalledOnce()
  145. } finally { await h.close() }
  146. })
  147. it('returns a declared authorization failure without consulting metadata or cached bytes', async () => {
  148. const h = await harness()
  149. try {
  150. await h.read()
  151. const denied = { ok: false as const, error: new RemoteError('workspace-file/not-found', 'File missing', { path: file.path }) }
  152. h.readBytes.mockResolvedValueOnce(denied)
  153. expect(await h.read()).toBe(denied)
  154. expect(h.stat).toHaveBeenCalledOnce()
  155. expect(h.render).toHaveBeenCalledOnce()
  156. } finally { await h.close() }
  157. })
  158. it('localizes Client capacity rejection while another reader waits for source metadata', async () => {
  159. const h = await harness({ maxReaders: 1 })
  160. const entered = Promise.withResolvers<undefined>()
  161. const completed = Promise.withResolvers<Awaited<ReturnType<typeof h.stat>>>()
  162. h.stat.mockImplementationOnce(() => { entered.resolve(undefined); return completed.promise })
  163. const first = h.read()
  164. try {
  165. await entered.promise
  166. await expect(h.read()).rejects.toThrow(en.busy)
  167. expect(h.stat).toHaveBeenCalledOnce()
  168. expect(h.render).not.toHaveBeenCalled()
  169. completed.resolve({ ok: true, value: source })
  170. expect((await first).ok).toBe(true)
  171. } finally {
  172. completed.resolve({ ok: true, value: source })
  173. await first
  174. await h.close()
  175. }
  176. })
  177. it('refuses a source replaced during the Client authorization probe', async () => {
  178. const h = await harness()
  179. try {
  180. h.stat.mockResolvedValueOnce({ ok: true, value: { ...source, version: 'v2' } })
  181. await expect(h.read()).rejects.toThrow(en.changed)
  182. expect(h.render).not.toHaveBeenCalled()
  183. } finally { await h.close() }
  184. })
  185. it('passes source authorization failures through and rechecks permission before reuse', async () => {
  186. const h = await harness()
  187. try {
  188. const missing = { ok: false as const, error: new RemoteError('workspace-file/not-found', 'File missing', { path: file.path }) }
  189. h.render.mockResolvedValueOnce(missing)
  190. expect(await h.read()).toBe(missing)
  191. await h.read()
  192. h.stat.mockResolvedValueOnce(missing)
  193. expect(await h.read()).toBe(missing)
  194. expect(h.render).toHaveBeenCalledTimes(2)
  195. } finally { await h.close() }
  196. })
  197. it.each([
  198. ['input-too-large', 'tooLarge'], ['output-too-large', 'tooLarge'], ['invalid-document', 'invalid'],
  199. ['unsupported-format', 'invalid'], ['invalid-output', 'failed'], ['timeout', 'timeout'],
  200. ['unavailable', 'unavailable'], ['failed', 'failed'], ['busy', 'busy'], ['source-changed', 'changed'],
  201. ] as const)('localizes %s Host conversion failures and allows retry', async (code, key) => {
  202. const h = await harness()
  203. try {
  204. const failure = new RemoteError('document-render/failed', 'Office conversion failed.', { reason: code })
  205. h.render.mockResolvedValueOnce({ ok: false, error: failure })
  206. await expect(h.read()).rejects.toMatchObject({ message: en[key], cause: failure })
  207. expect((await h.read()).ok).toBe(true)
  208. } finally { await h.close() }
  209. })
  210. it('cancels the Host request on disposal and waits for its completion', async () => {
  211. const h = await harness()
  212. const completed = Promise.withResolvers<typeof converted>()
  213. const started = Promise.withResolvers<AbortSignal>()
  214. h.render.mockImplementation((_session, _path, _priority, signal) => { started.resolve(signal!); return completed.promise })
  215. const pending = h.read()
  216. const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' })
  217. const signal = await started.promise
  218. let disposed = false
  219. const closing = h.close().then(() => { disposed = true })
  220. try {
  221. await rejected
  222. expect(signal.aborted).toBe(true)
  223. expect(disposed).toBe(false)
  224. } finally { completed.resolve(converted); await closing }
  225. })
  226. it('replaces cached PDFs on connection reset', async () => {
  227. const h = await harness()
  228. try {
  229. await h.read()
  230. await h.ctx.parallel('connection/reset')
  231. await h.read()
  232. expect(h.render).toHaveBeenCalledTimes(2)
  233. } finally { await h.close() }
  234. })
  235. it('joins a retired request during reset without retaining its late PDF in the new cache', async () => {
  236. const h = await harness()
  237. const completed = Promise.withResolvers<typeof converted>()
  238. const started = Promise.withResolvers<AbortSignal>()
  239. h.render.mockImplementationOnce((_session, _path, _priority, signal) => { started.resolve(signal!); return completed.promise })
  240. const pending = h.read()
  241. const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' })
  242. const signal = await started.promise
  243. const resetting = h.ctx.parallel('connection/reset')
  244. try {
  245. await rejected
  246. expect(signal.aborted).toBe(true)
  247. await h.read()
  248. expect(h.render).toHaveBeenCalledTimes(2)
  249. let disposed = false
  250. const closing = h.close().then(() => { disposed = true })
  251. expect(disposed).toBe(false)
  252. completed.resolve(converted)
  253. await resetting
  254. await closing
  255. } finally { completed.resolve(converted); await resetting; await h.close() }
  256. })