Files
deepseek-harness/packages/lsp/lsp-local/tests/host.spec.ts
Dudu-0223 d0029d8d60 feat(lsp): LSP capability seam, generic stdio provider, and lsp tool
Implements the LSP capability seam RFC as three packages: dsh-lsp (the
ctx.lsp interface — provider registry by branded id + exclusive extension
mapping, per-query order-independent selection, closed request/result
vocabulary, LspError taxonomy), dsh-lsp-local (a generic stdio language-server
provider — Content-Length JSON-RPC framing, per-(provider, workspace) process
single-flight, transient didOpen/query/didClose, an abortable per-instance
queue, UTF-16 negotiation, host-namespace source reads outside ctx.fs, and
bounded shutdown/kill teardown), and dsh-tool-lsp (the model-facing lsp tool —
four operations, one-based UTF-16 cursor conversion, workspace-grouped location
rendering, hover capping, a required session workspace, and a timeout budget).

Why: an agent had text search and file reads but no way to identify a program
symbol — follow an alias, connect an interface to implementations, or read an
inferred type — before changing code. Splitting model contract, seam, and local
subprocess behavior keeps the four semantic queries stable across future remote
or sandbox-native providers without leaking a JSON-RPC escape hatch.
2026-07-16 12:05:35 +08:00

106 lines
3.9 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { realpath } from 'node:fs/promises'
import { canonicalizeWorkspace, readHostSource } from '@deepseek-ai/dsh-lsp-local'
let root: string
let ws: string
beforeEach(async () => {
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-host-')))
ws = join(root, 'ws')
await mkdir(ws)
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
const BIG = 1_000_000
describe('canonicalizeWorkspace', () => {
it('returns the realpath of a directory', async () => {
expect(await canonicalizeWorkspace(ws)).toBe(ws)
})
it('resolves a symlinked workspace to its target so aliases share identity', async () => {
const link = join(root, 'ws-link')
await symlink(ws, link)
expect(await canonicalizeWorkspace(link)).toBe(ws)
})
it('rejects a missing workspace', async () => {
await expect(canonicalizeWorkspace(join(root, 'nope'))).rejects.toThrow(/cannot be resolved/)
})
it('rejects a non-directory workspace', async () => {
const file = join(root, 'file.txt')
await writeFile(file, 'x')
await expect(canonicalizeWorkspace(file)).rejects.toThrow(/not a directory/)
})
})
describe('readHostSource', () => {
it('reads a relative path against the workspace', async () => {
await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
const source = await readHostSource('a.ts', ws, BIG)
expect(source.canonicalPath).toBe(join(ws, 'a.ts'))
expect(source.text).toBe('const x = 1\n')
})
it('reads an absolute path inside the workspace', async () => {
const abs = join(ws, 'b.ts')
await writeFile(abs, 'b')
const source = await readHostSource(abs, ws, BIG)
expect(source.canonicalPath).toBe(abs)
})
it('accepts a source reached through a symlink that stays inside the workspace', async () => {
await mkdir(join(ws, 'real'))
await writeFile(join(ws, 'real', 'c.ts'), 'c')
await symlink(join(ws, 'real'), join(ws, 'linked'))
const source = await readHostSource('linked/c.ts', ws, BIG)
expect(source.canonicalPath).toBe(join(ws, 'real', 'c.ts'))
})
it('rejects a source whose canonical path escapes the workspace via symlink', async () => {
const outside = join(root, 'outside.ts')
await writeFile(outside, 'secret')
await symlink(outside, join(ws, 'escape.ts'))
await expect(readHostSource('escape.ts', ws, BIG)).rejects.toThrow(/outside the workspace/)
})
it('rejects an absolute source outside the workspace', async () => {
const outside = join(root, 'out.ts')
await writeFile(outside, 'x')
await expect(readHostSource(outside, ws, BIG)).rejects.toThrow(/outside the workspace/)
})
it('rejects a missing source', async () => {
await expect(readHostSource('nope.ts', ws, BIG)).rejects.toThrow(/cannot be resolved/)
})
it('rejects a non-regular source (directory)', async () => {
await mkdir(join(ws, 'dir'))
await expect(readHostSource('dir', ws, BIG)).rejects.toThrow(/not a regular file/)
})
it('treats the workspace root itself as inside, then rejects it as non-regular', async () => {
// filePath '.' canonicalizes to the workspace dir: isInside's identity branch is taken, and the
// directory then fails the regular-file check.
await expect(readHostSource('.', ws, BIG)).rejects.toThrow(/not a regular file/)
})
it('rejects an oversized source', async () => {
await writeFile(join(ws, 'big.ts'), 'x'.repeat(100))
await expect(readHostSource('big.ts', ws, 10)).rejects.toThrow(/over the 10-byte limit/)
})
it('rejects a non-UTF-8 source', async () => {
await writeFile(join(ws, 'bin.ts'), Buffer.from([0xff, 0xfe, 0x00]))
await expect(readHostSource('bin.ts', ws, BIG)).rejects.toThrow(/not valid UTF-8/)
})
})