document-preview.spec.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. /** The Web bundle's Office rows retain independent configuration and Session file authorization. */
  2. import { mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { fileURLToPath, pathToFileURL } from 'node:url'
  6. import { Context } from '@deepseek-ai/cordis'
  7. import Loader from '@deepseek-ai/cordis-plugin-loader'
  8. import Include, { applyEntryPatches } from '@deepseek-ai/cordis-plugin-include'
  9. import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
  10. import WorkspaceFiles, { type WorkspaceFileScope } from '@deepseek-ai/dsh-api-workspace-files'
  11. import OfficeToPdf from '@deepseek-ai/dsh-office-to-pdf'
  12. import * as DocumentPreview from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview'
  13. import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
  14. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  15. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  16. import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
  17. import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
  18. import { FsError } from '@deepseek-ai/dsh-fs'
  19. import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
  20. import type { Converter, ConverterOptions } from '@deepseek-ai/libreoffice-kit'
  21. import { expect, it, onTestFinished, vi } from 'vitest'
  22. const kit = vi.hoisted(() => ({ create: vi.fn<(options?: ConverterOptions) => Promise<Converter>>() }))
  23. vi.mock('@deepseek-ai/libreoffice-kit', () => ({ createConverter: kit.create }))
  24. it('loads the shipped Office rows with separately patched settings and authorized PDF output', async () => {
  25. const directory = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-office-')))
  26. const ctx = new Context()
  27. onTestFinished(async () => {
  28. try { await ctx.fiber.dispose() }
  29. finally { vi.restoreAllMocks(); await rm(directory, { recursive: true, force: true }) }
  30. })
  31. const configPath = join(directory, 'cordis.yml')
  32. const expectedRows = {
  33. 'office-to-pdf': '@deepseek-ai/dsh-office-to-pdf',
  34. 'ui-sidebar-documentpreview': '@deepseek-ai/dsh-client-ui-sidebar-documentpreview',
  35. }
  36. const rows = loadOverlayPatches('web-office-test', fileURLToPath(new URL('../cordis.patch.yml', import.meta.url)))
  37. .flatMap(patch => patch.insert ?? []).filter(row => row.id !== undefined && Object.hasOwn(expectedRows, row.id))
  38. expect(rows.map(row => [row.id, row.name])).toEqual(Object.entries(expectedRows))
  39. const providerConfig = { maxInputBytes: 4096, maxConcurrentConversions: 1, fontFallbacks: [['Missing Serif', 'Available Serif']] }
  40. const clientConfig = DocumentPreview.Config({ office: { maxCachedEntries: 3, maxCachedBytes: 8192 } })
  41. const configured = applyEntryPatches(rows, [
  42. { id: 'office-to-pdf', config: providerConfig },
  43. { id: 'ui-sidebar-documentpreview', config: clientConfig },
  44. ], (message) => { throw new Error(message) })
  45. expect(configured.find(row => row.id === 'ui-sidebar-documentpreview')!.config).toEqual(clientConfig)
  46. await writeFile(configPath, JSON.stringify([
  47. { name: '@deepseek-ai/dsh-session' },
  48. { name: '@deepseek-ai/dsh-session-projection' },
  49. { name: '@deepseek-ai/dsh-sandbox-policy', config: { workspaceRoot: directory } },
  50. { name: '@deepseek-ai/dsh-fs-local', config: { cwd: directory } },
  51. { name: '@deepseek-ai/dsh-typert-registry' },
  52. { name: '@deepseek-ai/dsh-api-workspace-files', config: { maxFileBytes: 1 } },
  53. ...configured,
  54. ]))
  55. const pdf = Buffer.from('%PDF-1.7\nLoader preview\n%%EOF\n')
  56. const render = vi.fn<Converter['render']>().mockImplementation(async ({ inputPath, outputPath }) => {
  57. expect(await readFile(inputPath)).toEqual(Buffer.from('authorized OOXML'))
  58. await writeFile(outputPath, pdf)
  59. return { backend: 'native', missingFonts: ['Missing Serif'] }
  60. })
  61. kit.create.mockReset().mockResolvedValue({ backend: 'native', render, dispose: async () => {} })
  62. ctx.baseUrl = pathToFileURL(directory).href + '/'
  63. await ctx.plugin(Loader)
  64. ctx.loader.builtins.include = Include
  65. // Loader's native imports must share the test's source-plane Service classes.
  66. const modules = new Map<string, unknown>([
  67. ['@deepseek-ai/dsh-session', SessionStore],
  68. ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry],
  69. ['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService],
  70. ['@deepseek-ai/dsh-fs-local', LocalFileSystem],
  71. ['@deepseek-ai/dsh-typert-registry', TypertRegistry],
  72. ['@deepseek-ai/dsh-api-workspace-files', WorkspaceFiles],
  73. ['@deepseek-ai/dsh-office-to-pdf', OfficeToPdf],
  74. ['@deepseek-ai/dsh-client-ui-sidebar-documentpreview', DocumentPreview],
  75. ])
  76. ctx.loader.internal = {
  77. version: 'v2',
  78. async import(specifier: string) {
  79. if (!modules.has(specifier)) throw new Error(`Unexpected Loader import: ${specifier}`)
  80. return modules.get(specifier)
  81. },
  82. } as unknown as NonNullable<typeof ctx.loader.internal>
  83. await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
  84. await ctx.loader.await()
  85. const entries = new Map([...ctx.loader.entries()].map(entry => [entry.options.id, entry]))
  86. for (const row of configured) await entries.get(row.id)!.fiber!.await()
  87. const injections: IndexInjection[] = []
  88. ctx.emit('webserver/index-inject', injections)
  89. expect(injections).toEqual([
  90. { kind: 'global', name: '__DSH_DOCUMENT_PREVIEW_CONFIG__', value: clientConfig },
  91. ])
  92. const id = SessionId('office-loader')
  93. const session = ctx.sessions.create(id, { meta: { cwd: directory } })
  94. const lookup = ctx.typert.lookups.get('workspaceFileScope')!
  95. const scope = await lookup.resolve(id) as WorkspaceFileScope | undefined
  96. if (scope === undefined) throw new Error('Expected the Session workspace scope')
  97. expect(await lookup.resolve(SessionId('missing'))).toBeUndefined()
  98. const sourcePath = join(directory, 'report.docx')
  99. await writeFile(sourcePath, 'authorized OOXML')
  100. const signal = new AbortController().signal
  101. const version = (await ctx.workspaceFiles.stat(scope, 'report.docx', signal)).version
  102. const before = session.seq
  103. const result = await ctx.officeToPdf.render(scope, 'report.docx', 'foreground', signal)
  104. expect(result).toEqual({ absolutePath: sourcePath, version, offset: 0, eof: true, bytes: pdf.length,
  105. data: pdf.toString('base64'), missingFonts: ['Missing Serif'], generation: ctx.officeToPdf.generation })
  106. const readAgain = vi.spyOn(ctx.fs, 'readBytes')
  107. expect(await ctx.officeToPdf.render(scope, 'report.docx', 'foreground', signal)).toEqual(result)
  108. expect(readAgain).not.toHaveBeenCalled()
  109. expect(await readFile(sourcePath, 'utf8')).toBe('authorized OOXML')
  110. expect(session.seq).toBe(before)
  111. expect(ctx.get('agents')).toBeUndefined()
  112. const { maxConcurrentConversions: _count, ...kitOptions } = providerConfig
  113. expect(kit.create).toHaveBeenCalledWith(expect.objectContaining(kitOptions))
  114. await expect(ctx.officeToPdf.render(scope, 'missing.docx', 'foreground', signal))
  115. .rejects.toMatchObject({ code: 'workspace-file/not-found' })
  116. const refusal = new FsError('read refused', 'FS_SANDBOX_DENIED')
  117. vi.spyOn(ctx.fs, 'stat').mockRejectedValueOnce(refusal)
  118. await expect(ctx.officeToPdf.render(scope, 'report.docx', 'foreground', signal)).rejects.toBe(refusal)
  119. if (process.platform !== 'win32') {
  120. await symlink(sourcePath, join(directory, 'link.docx'))
  121. await expect(ctx.officeToPdf.render(scope, 'link.docx', 'foreground', signal))
  122. .rejects.toMatchObject({ code: 'workspace-file/not-regular-file' })
  123. }
  124. expect(render).toHaveBeenCalledOnce()
  125. })