host.spec.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. import { afterEach, beforeEach, describe, expect, it } from 'vitest'
  2. import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { realpath } from 'node:fs/promises'
  6. import { execFile } from 'node:child_process'
  7. import { pathToFileURL } from 'node:url'
  8. import { promisify } from 'node:util'
  9. import { Context } from '@deepseek-ai/cordis'
  10. import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
  11. import { deadline } from '@deepseek-ai/dsh-timeout'
  12. import { canonicalizeWorkspace, readHostSource } from '@deepseek-ai/dsh-lsp-stdio'
  13. const execFileAsync = promisify(execFile)
  14. let root: string
  15. let ws: string
  16. let ctx: Context
  17. let fs: LocalFileSystem
  18. beforeEach(async () => {
  19. root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-host-')))
  20. ws = join(root, 'ws')
  21. await mkdir(ws)
  22. ctx = new Context()
  23. await ctx.plugin(LocalFileSystem, { cwd: root })
  24. fs = ctx.fs as LocalFileSystem
  25. })
  26. afterEach(async () => {
  27. await ctx.fiber.dispose()
  28. await rm(root, { recursive: true, force: true })
  29. })
  30. const BIG = 1_000_000
  31. async function workspace() {
  32. return await canonicalizeWorkspace(fs, ws)
  33. }
  34. async function readSource(filePath: string, maxBytes = BIG, signal?: AbortSignal) {
  35. return await readHostSource(fs, filePath, await workspace(), maxBytes, signal)
  36. }
  37. describe('canonicalizeWorkspace', () => {
  38. it('returns the realpath of a directory', async () => {
  39. expect((await workspace()).canonicalPath).toBe(ws)
  40. })
  41. it('resolves a symlinked workspace to its target so aliases share identity', async () => {
  42. const link = join(root, 'ws-link')
  43. await symlink(ws, link)
  44. expect((await canonicalizeWorkspace(fs, link)).canonicalPath).toBe(ws)
  45. })
  46. it('rejects a missing workspace', async () => {
  47. await expect(canonicalizeWorkspace(fs, join(root, 'nope'))).rejects.toThrow(/not a directory/)
  48. })
  49. it('wraps a provider failure while resolving the workspace', async () => {
  50. fs.resolve = async () => { throw 'raw workspace resolve failure' }
  51. await expect(canonicalizeWorkspace(fs, ws))
  52. .rejects.toThrow(`workspace root "${ws}" cannot be resolved: raw workspace resolve failure`)
  53. })
  54. it('rejects a non-directory workspace', async () => {
  55. const file = join(root, 'file.txt')
  56. await writeFile(file, 'x')
  57. await expect(canonicalizeWorkspace(fs, file)).rejects.toThrow(/not a directory/)
  58. })
  59. it('normalizes workspace metadata cancellation and preserves other provider failures', async () => {
  60. const providerFailure = new Error('workspace metadata failed')
  61. fs.stat = async () => { throw providerFailure }
  62. await expect(canonicalizeWorkspace(fs, ws)).rejects.toBe(providerFailure)
  63. const controller = new AbortController()
  64. fs.stat = async () => {
  65. controller.abort(new Error('workspace metadata cancelled'))
  66. throw providerFailure
  67. }
  68. await expect(canonicalizeWorkspace(fs, ws, controller.signal))
  69. .rejects.toThrow('workspace metadata cancelled')
  70. })
  71. })
  72. describe('readHostSource', () => {
  73. it('reads a relative path against the workspace', async () => {
  74. await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
  75. const source = await readSource('a.ts')
  76. expect(source.fileUrl).toBe(pathToFileURL(join(ws, 'a.ts')).href)
  77. expect(source.text).toBe('const x = 1\n')
  78. })
  79. it('reads an absolute path inside the workspace', async () => {
  80. const abs = join(ws, 'b.ts')
  81. await writeFile(abs, 'b')
  82. const source = await readSource(abs)
  83. expect(source.fileUrl).toBe(pathToFileURL(abs).href)
  84. })
  85. it('accepts a source reached through a symlink that stays inside the workspace', async () => {
  86. await mkdir(join(ws, 'real'))
  87. await writeFile(join(ws, 'real', 'c.ts'), 'c')
  88. await symlink(join(ws, 'real'), join(ws, 'linked'))
  89. const source = await readSource('linked/c.ts')
  90. expect(source.fileUrl).toBe(pathToFileURL(join(ws, 'real', 'c.ts')).href)
  91. })
  92. it('rejects a source whose canonical path escapes the workspace via symlink', async () => {
  93. const outside = join(root, 'outside.ts')
  94. await writeFile(outside, 'secret')
  95. await symlink(outside, join(ws, 'escape.ts'))
  96. await expect(readSource('escape.ts')).rejects.toThrow(/outside the workspace/)
  97. })
  98. it('rejects an absolute source outside the workspace', async () => {
  99. const outside = join(root, 'out.ts')
  100. await writeFile(outside, 'x')
  101. await expect(readSource(outside)).rejects.toThrow(/outside the workspace/)
  102. })
  103. it('rejects a missing source', async () => {
  104. await expect(readSource('nope.ts')).rejects.toThrow(/not found/)
  105. })
  106. it('wraps a provider failure while resolving the source', async () => {
  107. const canonical = await workspace()
  108. fs.resolve = async () => { throw 'raw resolve failure' }
  109. await expect(readHostSource(fs, 'broken.ts', canonical, BIG))
  110. .rejects.toThrow('source "broken.ts" cannot be resolved: raw resolve failure')
  111. })
  112. it('rejects a non-regular source (directory)', async () => {
  113. await mkdir(join(ws, 'dir'))
  114. await expect(readSource('dir')).rejects.toThrow(/not a regular file/)
  115. })
  116. // Windows has no filesystem FIFO; the directory case above pins non-regular rejection there.
  117. it.skipIf(process.platform === 'win32')('rejects a FIFO with no writer without blocking in open', async () => {
  118. const fifo = join(ws, 'pipe.ts')
  119. await execFileAsync('mkfifo', [fifo])
  120. using d = deadline(undefined, 1000, 'FIFO_READ_TIMEOUT')
  121. await expect(readSource('pipe.ts', BIG, d.signal)).rejects.toThrow(/not a regular file/)
  122. })
  123. it('honors a pre-aborted source read before filesystem work', async () => {
  124. const controller = new AbortController()
  125. controller.abort(new Error('source read cancelled'))
  126. await expect(readSource('missing.ts', BIG, controller.signal)).rejects.toThrow(/source read cancelled/)
  127. })
  128. it('treats the workspace root itself as inside, then rejects it as non-regular', async () => {
  129. // The filesystem containment primitive accepts the workspace itself; the
  130. // bounded read then rejects the directory as non-regular.
  131. await expect(readSource('.')).rejects.toThrow(/not a regular file/)
  132. })
  133. it('rejects an oversized source and reports the observed lower bound', async () => {
  134. await writeFile(join(ws, 'big.ts'), 'x'.repeat(100))
  135. await expect(readSource('big.ts', 10)).rejects.toMatchObject({
  136. message: 'source "big.ts" exceeds the 10-byte limit; reading stopped after 100 bytes',
  137. })
  138. })
  139. it('counts the complete UTF-8 byte length at the configured boundary', async () => {
  140. await writeFile(join(ws, 'multibyte.ts'), '€abc')
  141. await expect(readSource('multibyte.ts', 6)).resolves.toMatchObject({ text: '€abc' })
  142. await expect(readSource('multibyte.ts', 5)).rejects.toThrow(/5-byte limit/)
  143. })
  144. it('rejects a non-UTF-8 source', async () => {
  145. await writeFile(join(ws, 'bin.ts'), Buffer.from([0xff, 0xfe, 0x00]))
  146. await expect(readSource('bin.ts')).rejects.toThrow(/invalid UTF-8|binary file/)
  147. })
  148. it('keeps a valid U+FFFD replacement character in otherwise-valid UTF-8', async () => {
  149. // The literal replacement char is valid UTF-8; a fatal decoder must accept it (only malformed
  150. // byte sequences are rejected).
  151. await writeFile(join(ws, 'repl.ts'), 'const s = "�"\n')
  152. const source = await readSource('repl.ts')
  153. expect(source.text).toBe('const s = "�"\n')
  154. })
  155. })