refactor: apply repository naming contract
Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
80
packages/lsp/lsp-stdio/tests/built-lib.e2e.ts
Normal file
80
packages/lsp/lsp-stdio/tests/built-lib.e2e.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Keyless built-artifact smoke: plain Node imports `@deepseek-ai/dsh-lsp` and
|
||||
* `@deepseek-ai/dsh-lsp-stdio` by name through their exports maps, spawns the fixture server, runs
|
||||
* one query (exercising real `Content-Length` framing over `lib/index.js`), and disposes (exercising
|
||||
* subprocess cleanup). Unit tests use `src/`; this pins the downstream `lib/` path. Skips when `lib/`
|
||||
* is absent; CI runs it after the build.
|
||||
*/
|
||||
|
||||
const pkgDir = fileURLToPath(new URL('..', import.meta.url))
|
||||
const seamLib = join(pkgDir, '../lsp/lib/index.js')
|
||||
const fsLib = join(pkgDir, '../../fs/fs-local/lib/index.js')
|
||||
const subprocessLib = join(pkgDir, '../../subprocess/subprocess-local/lib/index.js')
|
||||
const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib) && existsSync(fsLib) && existsSync(subprocessLib)
|
||||
|
||||
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
|
||||
|
||||
let root: string
|
||||
let ws: string
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-built-')))
|
||||
ws = join(root, 'ws')
|
||||
await mkdir(ws)
|
||||
await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (root) await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe.skipIf(!built)('built lib real load path (plain node)', () => {
|
||||
it('runs a query through lib/index.js and disposes cleanly, framing over the base protocol', async () => {
|
||||
const location = JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } })
|
||||
const script = `
|
||||
const { Context } = await import('@deepseek-ai/cordis')
|
||||
const { default: Lsp } = await import('@deepseek-ai/dsh-lsp')
|
||||
const LspLocal = await import('@deepseek-ai/dsh-lsp-stdio')
|
||||
const { default: LocalFileSystem } = await import('@deepseek-ai/dsh-fs-local')
|
||||
const { default: LocalSubprocessRuntime } = await import('@deepseek-ai/dsh-subprocess-local')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
await ctx.plugin(LspLocal, {
|
||||
servers: {
|
||||
fake: {
|
||||
command: ${JSON.stringify(process.execPath)},
|
||||
args: [${JSON.stringify(fixtureServer)}],
|
||||
env: { LSP_FAKE_DEF: ${JSON.stringify(location)} },
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = await ctx.lsp.query({ operation: 'goToDefinition', filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ${JSON.stringify(ws)} })
|
||||
console.log(JSON.stringify(result))
|
||||
await ctx.fiber.dispose()
|
||||
`
|
||||
const { exitCode, stdout, stderr } = await execa(process.execPath, ['--input-type=module', '-e', script], {
|
||||
cwd: pkgDir,
|
||||
stdin: 'ignore',
|
||||
timeout: 55_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
})
|
||||
|
||||
expect(exitCode, `stderr:\n${stderr}`).toBe(0)
|
||||
const lastLine = stdout.trim().split('\n').at(-1) ?? ''
|
||||
const result = JSON.parse(lastLine) as { kind: string; locations: unknown[] }
|
||||
expect(result.kind).toBe('locations')
|
||||
expect(result.locations).toHaveLength(1)
|
||||
}, 60_000)
|
||||
})
|
||||
261
packages/lsp/lsp-stdio/tests/connection.spec.ts
Normal file
261
packages/lsp/lsp-stdio/tests/connection.spec.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { LspConnection } from '@deepseek-ai/dsh-lsp-stdio'
|
||||
import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-stdio/src/connection.ts'
|
||||
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
|
||||
import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts'
|
||||
|
||||
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
|
||||
|
||||
/** A recorded server→client request the test's handler saw. */
|
||||
interface SeenRequest { method: string; params: unknown }
|
||||
|
||||
let open: LspConnection[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const conn of open) {
|
||||
conn.terminate()
|
||||
await conn.closed
|
||||
}
|
||||
open = []
|
||||
})
|
||||
|
||||
/** Spawn the fixture as a raw connection, with a scripted server-request handler. */
|
||||
function connect(
|
||||
env: Record<string, string>,
|
||||
onServerRequest: (method: string, params: unknown) => Promise<unknown> = () => Promise.resolve(null),
|
||||
seen?: SeenRequest[],
|
||||
): LspConnection {
|
||||
const conn = new LspConnection({
|
||||
command: process.execPath,
|
||||
args: [fixtureServer],
|
||||
cwd: process.cwd(),
|
||||
env: { ...scrubbedParentEnv(), ...env },
|
||||
maxMessageBytes: 16_000_000,
|
||||
maxStderrBytes: 100_000,
|
||||
killGraceMs: 3_000,
|
||||
configuration: { setting: 42 },
|
||||
}, spawnSubprocess, (method, params) => {
|
||||
seen?.push({ method, params })
|
||||
return onServerRequest(method, params)
|
||||
})
|
||||
open.push(conn)
|
||||
return conn
|
||||
}
|
||||
|
||||
describe('LspConnection', () => {
|
||||
it('completes an initialize request/response round-trip and exposes a pid', async () => {
|
||||
const conn = connect({})
|
||||
const result = await conn.request('initialize', { capabilities: {} })
|
||||
expect(result).toMatchObject({ capabilities: { hoverProvider: true } })
|
||||
expect(conn.pid).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('forwards explicit DSH_* env entries to the child', async () => {
|
||||
// A configured DSH_* fact must reach the child: the seam scrubs only the
|
||||
// ambient namespace, and the explicit entry merges after that scrub. The
|
||||
// fixture echoes the named variable back as hover text.
|
||||
const conn = connect({ LSP_FAKE_ECHO_ENV: 'DSH_LSP_TEST_FACT', DSH_LSP_TEST_FACT: 'managed' })
|
||||
await conn.request('initialize', { capabilities: {} })
|
||||
expect(await conn.request('textDocument/hover', {})).toEqual({ contents: 'managed' })
|
||||
})
|
||||
|
||||
it('rejects a request when the server replies with an error', async () => {
|
||||
const conn = connect({ LSP_FAKE_ERROR: '1' })
|
||||
await conn.request('initialize', { capabilities: {} })
|
||||
await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/server refused the request/)
|
||||
})
|
||||
|
||||
it('treats terminating an already-closed child as a teardown race', async () => {
|
||||
const conn = connectScript('')
|
||||
await conn.closed
|
||||
expect(() => { conn.terminate() }).not.toThrow()
|
||||
})
|
||||
|
||||
it('answers a server workspace/configuration request from static config', async () => {
|
||||
const seen: SeenRequest[] = []
|
||||
const conn = connect(
|
||||
{ LSP_FAKE_ON_OPEN: 'configuration' },
|
||||
(method, params) => {
|
||||
if (method === 'workspace/configuration') {
|
||||
const items = (params as { items: unknown[] }).items
|
||||
return Promise.resolve(items.map(() => ({ setting: 42 })))
|
||||
}
|
||||
return Promise.resolve(null)
|
||||
},
|
||||
seen,
|
||||
)
|
||||
await conn.request('initialize', { capabilities: {} })
|
||||
await conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } })
|
||||
await waitFor(() => seen.some(s => s.method === 'workspace/configuration'))
|
||||
expect(seen[0]?.method).toBe('workspace/configuration')
|
||||
})
|
||||
|
||||
it('drops a server→client notification without replying', async () => {
|
||||
const conn = connect({ LSP_FAKE_ON_OPEN: 'notification' })
|
||||
await conn.request('initialize', { capabilities: {} })
|
||||
await conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } })
|
||||
// No throw and the connection stays usable.
|
||||
await expect(conn.request('textDocument/hover', {})).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('sends an error response when the server-request handler rejects', async () => {
|
||||
const seen: SeenRequest[] = []
|
||||
const conn = connect(
|
||||
{ LSP_FAKE_ON_OPEN: 'applyEdit' },
|
||||
method => method === 'workspace/applyEdit' ? Promise.reject(new Error('not permitted')) : Promise.resolve(null),
|
||||
seen,
|
||||
)
|
||||
await conn.request('initialize', { capabilities: {} })
|
||||
await conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } })
|
||||
await waitFor(() => seen.some(s => s.method === 'workspace/applyEdit'))
|
||||
// The connection remains healthy after emitting the error response.
|
||||
await expect(conn.request('textDocument/hover', {})).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('fails all pending requests and kills the process on a framing error', async () => {
|
||||
const conn = connect({ LSP_FAKE_GARBAGE: '1' })
|
||||
// The garbage byte precedes a valid initialize reply; unframed bytes are tolerated until a
|
||||
// Content-Length header, so initialize still resolves. This exercises the decoder's resilience.
|
||||
await expect(conn.request('initialize', { capabilities: {} })).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('rejects a new request issued after the process closes', async () => {
|
||||
const conn = connect({})
|
||||
await conn.request('initialize', { capabilities: {} })
|
||||
conn.terminate()
|
||||
await conn.closed
|
||||
await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/exited|closed/)
|
||||
})
|
||||
|
||||
it('cancel is a no-op-safe write after close', async () => {
|
||||
const conn = connect({})
|
||||
await conn.request('initialize', { capabilities: {} })
|
||||
conn.terminate()
|
||||
await conn.closed
|
||||
expect(() => { conn.cancel(1) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('caps the retained stderr tail', async () => {
|
||||
const conn = connect({})
|
||||
await conn.request('initialize', { capabilities: {} })
|
||||
expect(conn.stderrTail.length).toBeLessThanOrEqual(100_000)
|
||||
})
|
||||
})
|
||||
|
||||
/** Spawn a raw connection running an inline node script as the "server". */
|
||||
function connectScript(script: string, maxStderrBytes = 100_000, writer?: ConnectionWriter): LspConnection {
|
||||
const conn = new LspConnection({
|
||||
command: process.execPath,
|
||||
args: ['-e', script],
|
||||
cwd: process.cwd(),
|
||||
env: scrubbedParentEnv(),
|
||||
maxMessageBytes: 16_000_000,
|
||||
maxStderrBytes,
|
||||
killGraceMs: 3_000,
|
||||
configuration: null,
|
||||
}, spawnSubprocess, () => Promise.resolve(null), writer)
|
||||
open.push(conn)
|
||||
return conn
|
||||
}
|
||||
|
||||
describe('LspConnection edge behavior', () => {
|
||||
it('fails a request when the command cannot be spawned', async () => {
|
||||
const conn = new LspConnection({
|
||||
command: '/definitely/not/a/real/binary/xyz',
|
||||
args: [],
|
||||
cwd: process.cwd(),
|
||||
env: {},
|
||||
maxMessageBytes: 1000,
|
||||
maxStderrBytes: 1000,
|
||||
killGraceMs: 3_000,
|
||||
configuration: null,
|
||||
}, spawnSubprocess, () => Promise.resolve(null))
|
||||
open.push(conn)
|
||||
await expect(conn.request('initialize', {})).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('kills the process and fails pending requests on a framing error', async () => {
|
||||
// Emit an invalid Content-Length header, corrupting the stream irrecoverably.
|
||||
const conn = connectScript('process.stdout.write("Content-Length: abc\\r\\n\\r\\n{}"); setInterval(()=>{}, 1000)')
|
||||
await expect(conn.request('initialize', {})).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('ignores a framed non-object message', async () => {
|
||||
// Send a framed JSON number and a framed null (both non-objects) then a proper response to id 1.
|
||||
const script = 'let b=Buffer.alloc(0);'
|
||||
+ 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
|
||||
+ 'process.stdout.write(fr("42"));process.stdout.write(fr("null"));'
|
||||
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{ok:true}})));});'
|
||||
const conn = connectScript(script)
|
||||
await expect(conn.request('initialize', {})).resolves.toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('drops a response for an unknown id', async () => {
|
||||
// Emit a response for id 999 (never sent), then answer our real request.
|
||||
const script = 'let b=Buffer.alloc(0);'
|
||||
+ 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
|
||||
+ 'process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:999,result:{stray:true}})));'
|
||||
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{ok:true}})));});'
|
||||
const conn = connectScript(script)
|
||||
await expect(conn.request('initialize', {})).resolves.toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('caps the retained stderr tail at maxStderrBytes across chunks', async () => {
|
||||
// Write stderr repeatedly so a later chunk arrives after the cap is already reached.
|
||||
const conn = connectScript('setInterval(()=>process.stderr.write("E".repeat(200)), 5); setInterval(()=>{}, 1000)', 100)
|
||||
await waitFor(() => conn.stderrTail.length >= 100)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 50))
|
||||
expect(conn.stderrTail.length).toBe(100)
|
||||
})
|
||||
|
||||
it('caps the retained stderr tail by bytes for multibyte UTF-8', async () => {
|
||||
const conn = connectScript('process.stderr.write("😀😀")', 4)
|
||||
await conn.closed
|
||||
expect(conn.stderrTail).toBe('😀')
|
||||
expect(Buffer.byteLength(conn.stderrTail)).toBe(4)
|
||||
})
|
||||
|
||||
it('rejects with a fallback message when the error response has no message string', async () => {
|
||||
const script = 'let b=Buffer.alloc(0);'
|
||||
+ 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
|
||||
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,error:{code:-1}})));});'
|
||||
const conn = connectScript(script)
|
||||
await expect(conn.request('initialize', {})).rejects.toThrow(/LSP error response/)
|
||||
})
|
||||
|
||||
it('rejects a pending request when the process exits mid-flight', async () => {
|
||||
// Never responds, then exits shortly: the pending request must reject on close.
|
||||
const conn = connectScript('setTimeout(()=>process.exit(0), 100)')
|
||||
await expect(conn.request('initialize', {})).rejects.toThrow(/exited|closed/)
|
||||
})
|
||||
|
||||
it('rejects a pending request when child stdin fails but the process stays alive', async () => {
|
||||
const failure = new Error('fixture stdin failure')
|
||||
const writer: ConnectionWriter = (_stdin, _message, done) => {
|
||||
queueMicrotask(() => { done(failure) })
|
||||
}
|
||||
const conn = connectScript('setInterval(()=>{}, 1000)', 100_000, writer)
|
||||
await expect(conn.request('initialize', {})).rejects.toThrow(/fixture stdin failure/)
|
||||
})
|
||||
|
||||
it('ignores a frame that is neither a valid request nor a numeric-id response', async () => {
|
||||
// A frame with a string id and no method: not dispatchable; the client must ignore it and still
|
||||
// answer our real request.
|
||||
const script = 'let b=Buffer.alloc(0);'
|
||||
+ 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
|
||||
+ 'process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:"str-id"})));'
|
||||
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{ok:true}})));});'
|
||||
const conn = connectScript(script)
|
||||
await expect(conn.request('initialize', {})).resolves.toEqual({ ok: true })
|
||||
})
|
||||
})
|
||||
|
||||
/** Poll a predicate until it holds or a deadline elapses. */
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise<void> {
|
||||
const start = Date.now()
|
||||
while (!predicate()) {
|
||||
if (Date.now() - start > timeoutMs) throw new Error('waitFor timed out')
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
207
packages/lsp/lsp-stdio/tests/fixture-server.ts
Normal file
207
packages/lsp/lsp-stdio/tests/fixture-server.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* A scriptable fake LSP server over stdio for lsp-stdio tests. It speaks the real
|
||||
* `Content-Length`-framed base protocol so it exercises the client's framing, initialize handshake,
|
||||
* transient open/close, request mapping, and teardown — without a real language server.
|
||||
*
|
||||
* Behavior is driven by env vars so one file backs many scenarios:
|
||||
* - LSP_FAKE_ENCODING: advertised positionEncoding (default utf-16; "utf-8" forces a mismatch).
|
||||
* - LSP_FAKE_SYNC: textDocumentSync value as JSON (default 1/Full).
|
||||
* - LSP_FAKE_CAPS: JSON of extra capability flags merged into the defaults.
|
||||
* - LSP_FAKE_DEF / LSP_FAKE_REFS / LSP_FAKE_IMPL / LSP_FAKE_HOVER: JSON result per request.
|
||||
* - LSP_FAKE_HANG: "1" makes textDocument/* requests never respond (for abort/timeout tests).
|
||||
* - LSP_FAKE_CRASH_ON_OPEN: "1" exits the process when a didOpen arrives (crash test).
|
||||
* - LSP_FAKE_EXIT_AFTER_REPLY: "1" exits the process right after answering a textDocument/* request,
|
||||
* simulating a server that dies while idle so the pool holds a dead instance (eviction test).
|
||||
* - LSP_FAKE_REPLY_DELAY_MS: delays each textDocument/* response by this many milliseconds.
|
||||
* - LSP_FAKE_OPEN_MARKER: appends each didOpen document text as one JSON line to this path.
|
||||
* - LSP_FAKE_INITIALIZED_MARKER: records when the initialized notification is received.
|
||||
* - LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: "1" stops consuming stdin after initialized.
|
||||
* - LSP_FAKE_EXIT_DELAY_MS / LSP_FAKE_EXIT_MARKER: delay protocol exit and record exit/termination.
|
||||
* - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation).
|
||||
* - LSP_FAKE_ON_OPEN: server→client request to emit when a didOpen arrives, one of
|
||||
* "configuration" | "applyEdit" | "notification" | "unknown"; the reply is logged to stderr.
|
||||
* - LSP_FAKE_ERROR: "1" answers textDocument/* requests with a JSON-RPC error response.
|
||||
* - LSP_FAKE_GARBAGE: "1" emits an unframed garbage byte before the initialize reply.
|
||||
*
|
||||
* Run: node fixture-server.ts (Node's erasable TypeScript syntax support).
|
||||
*/
|
||||
|
||||
import { appendFileSync } from 'node:fs'
|
||||
|
||||
const enc = process.env.LSP_FAKE_ENCODING ?? 'utf-16'
|
||||
const sync: unknown = process.env.LSP_FAKE_SYNC !== undefined ? JSON.parse(process.env.LSP_FAKE_SYNC) : 1
|
||||
const extraCaps: unknown = process.env.LSP_FAKE_CAPS !== undefined ? JSON.parse(process.env.LSP_FAKE_CAPS) : {}
|
||||
const hang = process.env.LSP_FAKE_HANG === '1'
|
||||
const crashOnOpen = process.env.LSP_FAKE_CRASH_ON_OPEN === '1'
|
||||
const exitAfterReply = process.env.LSP_FAKE_EXIT_AFTER_REPLY === '1'
|
||||
const replyDelayMs = Number(process.env.LSP_FAKE_REPLY_DELAY_MS ?? 0)
|
||||
const openMarker = process.env.LSP_FAKE_OPEN_MARKER
|
||||
const initializedMarker = process.env.LSP_FAKE_INITIALIZED_MARKER
|
||||
const pauseStdinAfterInitialized = process.env.LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED === '1'
|
||||
const exitDelayMs = Number(process.env.LSP_FAKE_EXIT_DELAY_MS ?? 0)
|
||||
const exitMarker = process.env.LSP_FAKE_EXIT_MARKER
|
||||
const noShutdown = process.env.LSP_FAKE_NO_SHUTDOWN === '1'
|
||||
const onOpen = process.env.LSP_FAKE_ON_OPEN
|
||||
const errorReply = process.env.LSP_FAKE_ERROR === '1'
|
||||
const garbage = process.env.LSP_FAKE_GARBAGE === '1'
|
||||
|
||||
let serverRequestId = 10_000
|
||||
const pendingServerRequests = new Map<number, string>()
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
markExit('TERM')
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
function resultFor(method: string): unknown {
|
||||
switch (method) {
|
||||
case 'textDocument/definition': return envJson('LSP_FAKE_DEF', null)
|
||||
case 'textDocument/references': return envJson('LSP_FAKE_REFS', null)
|
||||
case 'textDocument/implementation': return envJson('LSP_FAKE_IMPL', null)
|
||||
case 'textDocument/hover': {
|
||||
// LSP_FAKE_ECHO_ENV names a variable whose VALUE becomes the hover
|
||||
// contents — a test can assert exactly what env reached this process.
|
||||
const echoName = process.env.LSP_FAKE_ECHO_ENV
|
||||
if (echoName !== undefined) return { contents: process.env[echoName] ?? `<${echoName} unset>` }
|
||||
return envJson('LSP_FAKE_HOVER', null)
|
||||
}
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
function envJson(name: string, fallback: unknown): unknown {
|
||||
const raw = process.env[name]
|
||||
return raw === undefined ? fallback : JSON.parse(raw)
|
||||
}
|
||||
|
||||
let buffer = Buffer.alloc(0)
|
||||
process.stdin.on('data', (chunk: Buffer) => {
|
||||
buffer = Buffer.concat([buffer, chunk])
|
||||
for (;;) {
|
||||
const sep = buffer.indexOf('\r\n\r\n')
|
||||
if (sep < 0) break
|
||||
const header = buffer.toString('ascii', 0, sep)
|
||||
const match = /content-length:\s*(\d+)/i.exec(header)
|
||||
if (!match) { buffer = buffer.subarray(sep + 4); continue }
|
||||
const length = Number(match[1])
|
||||
const start = sep + 4
|
||||
if (buffer.length < start + length) break
|
||||
const body = buffer.toString('utf8', start, start + length)
|
||||
buffer = buffer.subarray(start + length)
|
||||
handle(JSON.parse(body) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: unknown })
|
||||
}
|
||||
})
|
||||
|
||||
function handle(message: { id?: number; method?: string; params?: unknown; result?: unknown; error?: unknown }): void {
|
||||
const { id, method } = message
|
||||
// A frame with an id but no method is the client's REPLY to a server→client request; log it.
|
||||
if (method === undefined && id !== undefined && pendingServerRequests.has(id)) {
|
||||
const kind = pendingServerRequests.get(id)
|
||||
pendingServerRequests.delete(id)
|
||||
process.stderr.write(`REPLY ${kind} ${JSON.stringify({ result: message.result, error: message.error })}\n`)
|
||||
return
|
||||
}
|
||||
if (method === 'initialize') {
|
||||
if (garbage) process.stdout.write('this is not a framed message\r\n')
|
||||
send({
|
||||
id,
|
||||
result: {
|
||||
capabilities: {
|
||||
positionEncoding: enc,
|
||||
textDocumentSync: sync,
|
||||
definitionProvider: true,
|
||||
referencesProvider: true,
|
||||
implementationProvider: true,
|
||||
hoverProvider: true,
|
||||
...(extraCaps as Record<string, unknown>),
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (method === 'shutdown') {
|
||||
if (noShutdown) return
|
||||
send({ id, result: null })
|
||||
return
|
||||
}
|
||||
if (method === 'exit') {
|
||||
markExit('EXIT')
|
||||
if (exitDelayMs > 0) {
|
||||
setTimeout(() => {
|
||||
markExit('CLEAN')
|
||||
process.exit(0)
|
||||
}, exitDelayMs)
|
||||
return
|
||||
}
|
||||
markExit('CLEAN')
|
||||
process.exit(0)
|
||||
}
|
||||
if (method === 'textDocument/didOpen') {
|
||||
if (crashOnOpen) process.exit(1)
|
||||
if (openMarker !== undefined) {
|
||||
const params = message.params as { textDocument?: { text?: unknown } } | undefined
|
||||
appendFileSync(openMarker, `${JSON.stringify(params?.textDocument?.text)}\n`)
|
||||
}
|
||||
if (onOpen !== undefined) emitServerRequest(onOpen)
|
||||
return
|
||||
}
|
||||
if (method === 'initialized') {
|
||||
if (initializedMarker !== undefined) appendFileSync(initializedMarker, 'INITIALIZED\n')
|
||||
if (pauseStdinAfterInitialized) process.stdin.pause()
|
||||
return
|
||||
}
|
||||
if (method === 'textDocument/didClose') return
|
||||
if (method?.startsWith('textDocument/')) {
|
||||
if (hang) return
|
||||
const reply = (): void => {
|
||||
if (errorReply) {
|
||||
send({ id, error: { code: -32000, message: 'server refused the request' } })
|
||||
} else {
|
||||
send({ id, result: resultFor(method) })
|
||||
}
|
||||
// Simulate an idle death: answer this request, then exit before the next one arrives so the
|
||||
// pool is left holding a dead instance.
|
||||
if (exitAfterReply) setTimeout(() => process.exit(0), 20)
|
||||
}
|
||||
if (replyDelayMs > 0) setTimeout(reply, replyDelayMs)
|
||||
else reply()
|
||||
return
|
||||
}
|
||||
// Unknown request with an id: answer null so the client never stalls.
|
||||
if (id !== undefined) send({ id, result: null })
|
||||
}
|
||||
|
||||
/** Append one teardown event when the fixture is configured to expose process ordering. */
|
||||
function markExit(event: string): void {
|
||||
if (exitMarker !== undefined) appendFileSync(exitMarker, `${event}\n`)
|
||||
}
|
||||
|
||||
/** Emit a server→client request and log the client's reply to stderr for the test to assert. */
|
||||
function emitServerRequest(kind: string): void {
|
||||
if (kind === 'notification') {
|
||||
send({ method: 'window/logMessage', params: { type: 3, message: 'hello' } })
|
||||
return
|
||||
}
|
||||
const id = serverRequestId++
|
||||
const method = kind === 'configuration'
|
||||
? 'workspace/configuration'
|
||||
: kind === 'applyEdit'
|
||||
? 'workspace/applyEdit'
|
||||
: kind === 'lifecycle'
|
||||
? 'client/registerCapability'
|
||||
: 'window/showMessageRequest'
|
||||
const params = kind === 'configuration' ? { items: [{ section: 'a' }, { section: 'b' }] } : {}
|
||||
pendingServerRequests.set(id, method)
|
||||
send({ id, method, params })
|
||||
}
|
||||
|
||||
function send(message: Record<string, unknown>): void {
|
||||
const body = Buffer.from(JSON.stringify({ jsonrpc: '2.0', ...message }), 'utf8')
|
||||
process.stdout.write(Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'), body]))
|
||||
}
|
||||
|
||||
// Keep the event loop alive.
|
||||
process.stdin.resume()
|
||||
if (pauseStdinAfterInitialized) {
|
||||
setInterval(() => {}, 1000)
|
||||
}
|
||||
82
packages/lsp/lsp-stdio/tests/framing.spec.ts
Normal file
82
packages/lsp/lsp-stdio/tests/framing.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { encodeMessage, MessageDecoder } from '@deepseek-ai/dsh-lsp-stdio'
|
||||
|
||||
/** Frame a message the way a server would, for decoder round-trips. */
|
||||
function frame(body: string): Buffer {
|
||||
return Buffer.concat([Buffer.from(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n`, 'ascii'), Buffer.from(body, 'utf8')])
|
||||
}
|
||||
|
||||
describe('encodeMessage', () => {
|
||||
it('prefixes a Content-Length header with the utf-8 byte length', () => {
|
||||
const buffer = encodeMessage({ jsonrpc: '2.0', method: 'x', params: { s: 'é' } })
|
||||
const text = buffer.toString('utf8')
|
||||
const body = '{"jsonrpc":"2.0","method":"x","params":{"s":"é"}}'
|
||||
expect(text).toBe(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessageDecoder', () => {
|
||||
it('decodes a single framed message', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
expect(decoder.push(frame('{"id":1,"result":42}'))).toEqual([{ id: 1, result: 42 }])
|
||||
})
|
||||
|
||||
it('decodes multiple messages arriving in one chunk', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
const chunk = Buffer.concat([frame('{"a":1}'), frame('{"b":2}')])
|
||||
expect(decoder.push(chunk)).toEqual([{ a: 1 }, { b: 2 }])
|
||||
})
|
||||
|
||||
it('reassembles a message split across chunks', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
const full = frame('{"hello":"world"}')
|
||||
expect(decoder.push(full.subarray(0, 10))).toEqual([])
|
||||
expect(decoder.push(full.subarray(10))).toEqual([{ hello: 'world' }])
|
||||
})
|
||||
|
||||
it('handles a header split from its body', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
const body = '{"x":1}'
|
||||
expect(decoder.push(Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'))).toEqual([])
|
||||
expect(decoder.push(Buffer.from(body, 'utf8'))).toEqual([{ x: 1 }])
|
||||
})
|
||||
|
||||
it('reads a case-insensitive header and ignores other headers', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
const body = '{"ok":true}'
|
||||
const chunk = Buffer.from(`content-length: ${body.length}\r\nContent-Type: x\r\n\r\n${body}`, 'utf8')
|
||||
expect(decoder.push(chunk)).toEqual([{ ok: true }])
|
||||
})
|
||||
|
||||
it('rejects a body over the size limit', () => {
|
||||
const decoder = new MessageDecoder(4)
|
||||
expect(() => decoder.push(frame('{"big":true}'))).toThrow(/exceeds the 4-byte limit/)
|
||||
})
|
||||
|
||||
it('rejects a missing Content-Length header', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
expect(() => decoder.push(Buffer.from('X: 1\r\n\r\n{}', 'utf8'))).toThrow(/missing Content-Length/)
|
||||
})
|
||||
|
||||
it('rejects a non-numeric Content-Length', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
expect(() => decoder.push(Buffer.from('Content-Length: abc\r\n\r\n{}', 'utf8'))).toThrow(/invalid Content-Length/)
|
||||
})
|
||||
|
||||
it('rejects a header block that never terminates', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
const huge = Buffer.alloc((1 << 16) + 1, 0x41)
|
||||
expect(() => decoder.push(huge)).toThrow(/exceeded .* bytes without a terminator/)
|
||||
})
|
||||
|
||||
it('rejects an oversized header block that includes its terminator', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
const huge = Buffer.from(`Content-Length: 2\r\nX-Fill: ${'a'.repeat(70_000)}\r\n\r\n{}`, 'ascii')
|
||||
expect(() => decoder.push(huge)).toThrow(/header exceeded .* bytes/)
|
||||
})
|
||||
|
||||
it('rejects a non-JSON body', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
expect(() => decoder.push(frame('not json'))).toThrow(/not valid JSON/)
|
||||
})
|
||||
})
|
||||
184
packages/lsp/lsp-stdio/tests/host.spec.ts
Normal file
184
packages/lsp/lsp-stdio/tests/host.spec.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
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 { execFile } from 'node:child_process'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import { deadline } from '@deepseek-ai/dsh-timeout'
|
||||
import { canonicalizeWorkspace, readHostSource } from '@deepseek-ai/dsh-lsp-stdio'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
let root: string
|
||||
let ws: string
|
||||
let ctx: Context
|
||||
let fs: LocalFileSystem
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-host-')))
|
||||
ws = join(root, 'ws')
|
||||
await mkdir(ws)
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LocalFileSystem, { cwd: root })
|
||||
fs = ctx.fs as LocalFileSystem
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const BIG = 1_000_000
|
||||
|
||||
async function workspace() {
|
||||
return await canonicalizeWorkspace(fs, ws)
|
||||
}
|
||||
|
||||
async function readSource(filePath: string, maxBytes = BIG, signal?: AbortSignal) {
|
||||
return await readHostSource(fs, filePath, await workspace(), maxBytes, signal)
|
||||
}
|
||||
|
||||
describe('canonicalizeWorkspace', () => {
|
||||
it('returns the realpath of a directory', async () => {
|
||||
expect((await workspace()).canonicalPath).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(fs, link)).canonicalPath).toBe(ws)
|
||||
})
|
||||
|
||||
it('rejects a missing workspace', async () => {
|
||||
await expect(canonicalizeWorkspace(fs, join(root, 'nope'))).rejects.toThrow(/not a directory/)
|
||||
})
|
||||
|
||||
it('wraps a provider failure while resolving the workspace', async () => {
|
||||
fs.resolve = async () => { throw 'raw workspace resolve failure' }
|
||||
await expect(canonicalizeWorkspace(fs, ws))
|
||||
.rejects.toThrow(`workspace root "${ws}" cannot be resolved: raw workspace resolve failure`)
|
||||
})
|
||||
|
||||
it('rejects a non-directory workspace', async () => {
|
||||
const file = join(root, 'file.txt')
|
||||
await writeFile(file, 'x')
|
||||
await expect(canonicalizeWorkspace(fs, file)).rejects.toThrow(/not a directory/)
|
||||
})
|
||||
|
||||
it('normalizes workspace metadata cancellation and preserves other provider failures', async () => {
|
||||
const providerFailure = new Error('workspace metadata failed')
|
||||
fs.stat = async () => { throw providerFailure }
|
||||
await expect(canonicalizeWorkspace(fs, ws)).rejects.toBe(providerFailure)
|
||||
|
||||
const controller = new AbortController()
|
||||
fs.stat = async () => {
|
||||
controller.abort(new Error('workspace metadata cancelled'))
|
||||
throw providerFailure
|
||||
}
|
||||
await expect(canonicalizeWorkspace(fs, ws, controller.signal))
|
||||
.rejects.toThrow('workspace metadata cancelled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('readHostSource', () => {
|
||||
it('reads a relative path against the workspace', async () => {
|
||||
await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
|
||||
const source = await readSource('a.ts')
|
||||
expect(source.fileUrl).toBe(pathToFileURL(join(ws, 'a.ts')).href)
|
||||
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 readSource(abs)
|
||||
expect(source.fileUrl).toBe(pathToFileURL(abs).href)
|
||||
})
|
||||
|
||||
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 readSource('linked/c.ts')
|
||||
expect(source.fileUrl).toBe(pathToFileURL(join(ws, 'real', 'c.ts')).href)
|
||||
})
|
||||
|
||||
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(readSource('escape.ts')).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(readSource(outside)).rejects.toThrow(/outside the workspace/)
|
||||
})
|
||||
|
||||
it('rejects a missing source', async () => {
|
||||
await expect(readSource('nope.ts')).rejects.toThrow(/not found/)
|
||||
})
|
||||
|
||||
it('wraps a provider failure while resolving the source', async () => {
|
||||
const canonical = await workspace()
|
||||
fs.resolve = async () => { throw 'raw resolve failure' }
|
||||
await expect(readHostSource(fs, 'broken.ts', canonical, BIG))
|
||||
.rejects.toThrow('source "broken.ts" cannot be resolved: raw resolve failure')
|
||||
})
|
||||
|
||||
it('rejects a non-regular source (directory)', async () => {
|
||||
await mkdir(join(ws, 'dir'))
|
||||
await expect(readSource('dir')).rejects.toThrow(/not a regular file/)
|
||||
})
|
||||
|
||||
// Windows has no filesystem FIFO; the directory case above pins non-regular rejection there.
|
||||
it.skipIf(process.platform === 'win32')('rejects a FIFO with no writer without blocking in open', async () => {
|
||||
const fifo = join(ws, 'pipe.ts')
|
||||
await execFileAsync('mkfifo', [fifo])
|
||||
using d = deadline(undefined, 1000, 'FIFO_READ_TIMEOUT')
|
||||
await expect(readSource('pipe.ts', BIG, d.signal)).rejects.toThrow(/not a regular file/)
|
||||
})
|
||||
|
||||
it('honors a pre-aborted source read before filesystem work', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('source read cancelled'))
|
||||
await expect(readSource('missing.ts', BIG, controller.signal)).rejects.toThrow(/source read cancelled/)
|
||||
})
|
||||
|
||||
it('treats the workspace root itself as inside, then rejects it as non-regular', async () => {
|
||||
// The filesystem containment primitive accepts the workspace itself; the
|
||||
// bounded read then rejects the directory as non-regular.
|
||||
await expect(readSource('.')).rejects.toThrow(/not a regular file/)
|
||||
})
|
||||
|
||||
it('rejects an oversized source and reports the observed lower bound', async () => {
|
||||
await writeFile(join(ws, 'big.ts'), 'x'.repeat(100))
|
||||
await expect(readSource('big.ts', 10)).rejects.toMatchObject({
|
||||
message: 'source "big.ts" exceeds the 10-byte limit; reading stopped after 100 bytes',
|
||||
})
|
||||
})
|
||||
|
||||
it('counts the complete UTF-8 byte length at the configured boundary', async () => {
|
||||
await writeFile(join(ws, 'multibyte.ts'), '€abc')
|
||||
await expect(readSource('multibyte.ts', 6)).resolves.toMatchObject({ text: '€abc' })
|
||||
await expect(readSource('multibyte.ts', 5)).rejects.toThrow(/5-byte limit/)
|
||||
})
|
||||
|
||||
it('rejects a non-UTF-8 source', async () => {
|
||||
await writeFile(join(ws, 'bin.ts'), Buffer.from([0xff, 0xfe, 0x00]))
|
||||
await expect(readSource('bin.ts')).rejects.toThrow(/invalid UTF-8|binary file/)
|
||||
})
|
||||
|
||||
it('keeps a valid U+FFFD replacement character in otherwise-valid UTF-8', async () => {
|
||||
// The literal replacement char is valid UTF-8; a fatal decoder must accept it (only malformed
|
||||
// byte sequences are rejected).
|
||||
await writeFile(join(ws, 'repl.ts'), 'const s = "<22>"\n')
|
||||
const source = await readSource('repl.ts')
|
||||
expect(source.text).toBe('const s = "<22>"\n')
|
||||
})
|
||||
})
|
||||
398
packages/lsp/lsp-stdio/tests/instance.spec.ts
Normal file
398
packages/lsp/lsp-stdio/tests/instance.spec.ts
Normal file
@@ -0,0 +1,398 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-stdio'
|
||||
import { encodeMessage } from '@deepseek-ai/dsh-lsp-stdio'
|
||||
import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-stdio/src/connection.ts'
|
||||
import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-stdio/src/instance.ts'
|
||||
import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp'
|
||||
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
|
||||
import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts'
|
||||
|
||||
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
|
||||
|
||||
let root: string
|
||||
let ws: string
|
||||
let ctx: Context
|
||||
let fs: LocalFileSystem
|
||||
let live: LspInstance[] = []
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-inst-')))
|
||||
ws = join(root, 'ws')
|
||||
await mkdir(ws)
|
||||
await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LocalFileSystem, { cwd: root })
|
||||
fs = ctx.fs as LocalFileSystem
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
for (const instance of live) await instance.dispose()
|
||||
live = []
|
||||
await ctx.fiber.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function makeInstance(
|
||||
env: Record<string, string> = {},
|
||||
overrides: Partial<InstanceSpec> = {},
|
||||
writer?: ConnectionWriter,
|
||||
): LspInstance {
|
||||
const instance = new LspInstance({
|
||||
command: process.execPath,
|
||||
args: [fixtureServer],
|
||||
cwd: ws,
|
||||
workspaceUri: pathToFileURL(ws).href,
|
||||
env: { ...scrubbedParentEnv(), ...env },
|
||||
configuration: { setting: 42 },
|
||||
initializationOptions: { init: true },
|
||||
maxMessageBytes: 16_000_000,
|
||||
maxStderrBytes: 100_000,
|
||||
shutdownTimeoutMs: 200,
|
||||
killGraceMs: 200,
|
||||
...overrides,
|
||||
}, spawnSubprocess, writer)
|
||||
live.push(instance)
|
||||
return instance
|
||||
}
|
||||
|
||||
function query(operation: LspProviderQuery['operation'] = 'goToDefinition'): LspProviderQuery {
|
||||
return { operation, filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ws, languageId: 'typescript' }
|
||||
}
|
||||
|
||||
/** Run a query against an instance, reading the source first the way the provider does. */
|
||||
async function run(instance: LspInstance, operation: LspProviderQuery['operation'] = 'goToDefinition', signal?: AbortSignal): Promise<LspQueryResult> {
|
||||
const workspace = {
|
||||
target: await fs.resolve(ws),
|
||||
canonicalPath: ws,
|
||||
fileUrl: pathToFileURL(ws).href,
|
||||
}
|
||||
const source = await readHostSource(fs, 'a.ts', workspace, 4_000_000)
|
||||
return instance.query(query(operation), source, signal)
|
||||
}
|
||||
|
||||
/** Build an instance whose "server" is an inline node script (for teardown-escalation control). */
|
||||
function scriptInstance(script: string, overrides: Partial<InstanceSpec> = {}): LspInstance {
|
||||
const instance = new LspInstance({
|
||||
command: process.execPath,
|
||||
args: ['-e', script],
|
||||
cwd: ws,
|
||||
workspaceUri: pathToFileURL(ws).href,
|
||||
env: scrubbedParentEnv(),
|
||||
configuration: null,
|
||||
initializationOptions: null,
|
||||
maxMessageBytes: 16_000_000,
|
||||
maxStderrBytes: 100_000,
|
||||
shutdownTimeoutMs: 150,
|
||||
killGraceMs: 150,
|
||||
...overrides,
|
||||
}, spawnSubprocess)
|
||||
live.push(instance)
|
||||
return instance
|
||||
}
|
||||
|
||||
/** An inline server that answers initialize + definition and echoes a location. */
|
||||
const RESPONDING_SERVER =
|
||||
'let b=Buffer.alloc(0);'
|
||||
+ 'const fr=(o)=>{const x=Buffer.from(JSON.stringify({jsonrpc:"2.0",...o}));return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
|
||||
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);for(;;){const s=b.indexOf("\\r\\n\\r\\n");if(s<0)break;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);if(b.length<s+4+len)break;const m=JSON.parse(b.toString("utf8",s+4,s+4+len));b=b.subarray(s+4+len);'
|
||||
+ 'if(m.method==="initialize")process.stdout.write(fr({id:m.id,result:{capabilities:{positionEncoding:"utf-16",textDocumentSync:1,definitionProvider:true}}}));'
|
||||
+ 'else if(m.method==="textDocument/definition")process.stdout.write(fr({id:m.id,result:null}));'
|
||||
+ '}});'
|
||||
|
||||
const locJson = () => JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } })
|
||||
|
||||
describe('LspInstance server-request handling', () => {
|
||||
it('answers workspace/configuration with the static config per item', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'configuration', LSP_FAKE_DEF: locJson() })
|
||||
// The query drives didOpen, which makes the fake emit workspace/configuration; a healthy answer
|
||||
// keeps the query working.
|
||||
await expect(run(instance, 'goToDefinition')).resolves.toMatchObject({ kind: 'locations' })
|
||||
})
|
||||
|
||||
it('accepts a lifecycle client/registerCapability request', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'lifecycle', LSP_FAKE_DEF: 'null' })
|
||||
await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: pathToFileURL(ws).href })
|
||||
})
|
||||
|
||||
it('rejects a workspace/applyEdit request but keeps serving', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'applyEdit', LSP_FAKE_DEF: 'null' })
|
||||
await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: pathToFileURL(ws).href })
|
||||
})
|
||||
|
||||
it('rejects an unknown server request but keeps serving', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'unknown', LSP_FAKE_DEF: 'null' })
|
||||
await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: pathToFileURL(ws).href })
|
||||
})
|
||||
})
|
||||
|
||||
describe('LspInstance query and abort', () => {
|
||||
it('sends includeDeclaration for references', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_REFS: JSON.stringify([JSON.parse(locJson())]) })
|
||||
await expect(run(instance, 'findReferences')).resolves.toMatchObject({ kind: 'locations' })
|
||||
})
|
||||
|
||||
it('rejects a query aborted before it starts', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('pre-abort'))
|
||||
await expect(run(instance, 'goToDefinition', controller.signal)).rejects.toThrow(/pre-abort/)
|
||||
})
|
||||
|
||||
it('cancels an in-flight request on abort and rejects', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_HANG: '1' })
|
||||
const controller = new AbortController()
|
||||
// Warm the instance first so the abort lands during the hanging request, not during startup.
|
||||
const pending = run(instance, 'goToDefinition', controller.signal)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 300))
|
||||
controller.abort(new Error('mid-flight'))
|
||||
await expect(pending).rejects.toThrow(/mid-flight/)
|
||||
})
|
||||
|
||||
it('terminates the instance when the server ignores $/cancelRequest past the grace', async () => {
|
||||
// The hang server never honors cancellation, so after the bounded grace the instance must be torn
|
||||
// down (its process closed) rather than left with an active request.
|
||||
const instance = makeInstance({ LSP_FAKE_HANG: '1' }, { killGraceMs: 100 })
|
||||
const controller = new AbortController()
|
||||
const pending = run(instance, 'goToDefinition', controller.signal)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 300))
|
||||
controller.abort(new Error('mid-flight'))
|
||||
await expect(pending).rejects.toThrow(/mid-flight/)
|
||||
expect(instance.dead).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves the cancel grace when the server honors $/cancelRequest', async () => {
|
||||
// A server that answers $/cancelRequest by settling the pending request lets the grace race
|
||||
// resolve via the request rather than the timeout, so the instance is NOT force-terminated.
|
||||
const script = 'let b=Buffer.alloc(0),reqId=null;'
|
||||
+ 'const fr=(o)=>{const x=Buffer.from(JSON.stringify({jsonrpc:"2.0",...o}));return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
|
||||
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);for(;;){const s=b.indexOf("\\r\\n\\r\\n");if(s<0)break;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);if(b.length<s+4+len)break;const m=JSON.parse(b.toString("utf8",s+4,s+4+len));b=b.subarray(s+4+len);'
|
||||
+ 'if(m.method==="initialize")process.stdout.write(fr({id:m.id,result:{capabilities:{positionEncoding:"utf-16",textDocumentSync:1,definitionProvider:true}}}));'
|
||||
+ 'else if(m.method==="textDocument/definition")reqId=m.id;'
|
||||
+ 'else if(m.method==="$/cancelRequest"&&reqId!==null)process.stdout.write(fr({id:reqId,error:{code:-32800,message:"request cancelled"}}));'
|
||||
+ 'else if(m.method==="shutdown")process.stdout.write(fr({id:m.id,result:null}));'
|
||||
+ 'else if(m.method==="exit")process.exit(0);'
|
||||
+ '}});'
|
||||
const instance = scriptInstance(script, { killGraceMs: 2_000 })
|
||||
const controller = new AbortController()
|
||||
const pending = run(instance, 'goToDefinition', controller.signal)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 300))
|
||||
controller.abort(new Error('mid-flight'))
|
||||
await expect(pending).rejects.toThrow(/mid-flight/)
|
||||
// The server acknowledged cancellation within grace, so the instance was not force-killed.
|
||||
expect(instance.dead).toBe(false)
|
||||
await instance.dispose()
|
||||
})
|
||||
|
||||
it('observes abort while awaiting a slow initialize handshake', async () => {
|
||||
// A server that answers nothing (not even initialize) leaves `ready` pending; an abort must be
|
||||
// observed during that wait instead of hanging the tool-timeout signal.
|
||||
const instance = scriptInstance('setInterval(()=>{},1000)', { killGraceMs: 100 })
|
||||
const controller = new AbortController()
|
||||
const pending = run(instance, 'goToDefinition', controller.signal)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 150))
|
||||
controller.abort(new Error('handshake-abort'))
|
||||
await expect(pending).rejects.toThrow(/handshake-abort/)
|
||||
await instance.dispose()
|
||||
})
|
||||
|
||||
it('terminates when abort interrupts a backpressured didOpen write', async () => {
|
||||
// The fixture consumes initialized, then stops reading. A document larger than the stdio pipe
|
||||
// keeps didOpen's write callback pending until cancellation forces bounded process teardown.
|
||||
await writeFile(join(ws, 'a.ts'), 'x'.repeat(2_000_000))
|
||||
const marker = join(root, 'initialized.log')
|
||||
const instance = makeInstance({
|
||||
LSP_FAKE_INITIALIZED_MARKER: marker,
|
||||
LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: '1',
|
||||
}, {
|
||||
shutdownTimeoutMs: 100,
|
||||
killGraceMs: 100,
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const pending = run(instance, 'goToDefinition', controller.signal)
|
||||
await waitForFile(marker)
|
||||
// Let the client enter the large didOpen write after the fixture has paused stdin.
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 100))
|
||||
controller.abort(new Error('didOpen-abort'))
|
||||
await expect(pending).rejects.toThrow(/didOpen-abort/)
|
||||
expect(instance.dead).toBe(true)
|
||||
})
|
||||
|
||||
it('terminates when stdin fails during the didOpen write', async () => {
|
||||
const instance = makeInstance({}, {
|
||||
shutdownTimeoutMs: 100,
|
||||
killGraceMs: 100,
|
||||
}, failingWriter('textDocument/didOpen'))
|
||||
await expect(run(instance, 'goToDefinition')).rejects.toThrow()
|
||||
expect(instance.dead).toBe(true)
|
||||
})
|
||||
|
||||
it('awaits process exit before rejecting a request write failure', async () => {
|
||||
const instance = makeInstance({}, {
|
||||
shutdownTimeoutMs: 100,
|
||||
killGraceMs: 100,
|
||||
}, failingWriter('textDocument/definition'))
|
||||
// The pid is observed only to prove the owned subprocess reached quiescence before rejection.
|
||||
const pid = (instance as unknown as { connection: { pid: number } }).connection.pid
|
||||
await expect(run(instance, 'goToDefinition')).rejects.toThrow(/fixture textDocument\/definition failure/)
|
||||
expect(processAlive(pid)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects when the server lacks the operation capability', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_CAPS: JSON.stringify({ definitionProvider: false }), LSP_FAKE_DEF: 'null' })
|
||||
await expect(run(instance, 'goToDefinition')).rejects.toThrow(/does not support goToDefinition/)
|
||||
})
|
||||
|
||||
it('propagates a server error response even when a signal is supplied (not an abort)', async () => {
|
||||
// A live signal is passed, but the request fails for a server reason; the catch must rethrow
|
||||
// without treating it as an abort.
|
||||
const instance = makeInstance({ LSP_FAKE_ERROR: '1' })
|
||||
const controller = new AbortController()
|
||||
await expect(run(instance, 'goToDefinition', controller.signal)).rejects.toThrow(/server refused/)
|
||||
})
|
||||
|
||||
it('keeps a settled result but awaits teardown when didClose cannot be written', async () => {
|
||||
const instance = makeInstance({
|
||||
LSP_FAKE_DEF: 'null',
|
||||
}, { shutdownTimeoutMs: 100, killGraceMs: 100 }, failingWriter('textDocument/didClose'))
|
||||
await expect(run(instance, 'goToDefinition')).resolves.toEqual({
|
||||
kind: 'locations',
|
||||
locations: [],
|
||||
resolvedWorkspaceUri: pathToFileURL(ws).href,
|
||||
})
|
||||
expect(instance.dead).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LspInstance disposal', () => {
|
||||
it('lets a server finish protocol exit before signal escalation', async () => {
|
||||
const marker = join(root, 'graceful-exit.log')
|
||||
const instance = makeInstance({
|
||||
LSP_FAKE_DEF: 'null',
|
||||
LSP_FAKE_EXIT_DELAY_MS: '75',
|
||||
LSP_FAKE_EXIT_MARKER: marker,
|
||||
}, { shutdownTimeoutMs: 500 })
|
||||
await run(instance, 'goToDefinition')
|
||||
await instance.dispose()
|
||||
expect(await readFile(marker, 'utf8')).toBe('EXIT\nCLEAN\n')
|
||||
})
|
||||
|
||||
it('is idempotent — a second dispose awaits close without error', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
|
||||
await run(instance, 'goToDefinition')
|
||||
await instance.dispose()
|
||||
await expect(instance.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a query after disposal', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
|
||||
await run(instance, 'goToDefinition')
|
||||
await instance.dispose()
|
||||
await expect(run(instance, 'goToDefinition')).rejects.toThrow(expect.objectContaining({ code: 'LSP_DISPOSED' }))
|
||||
})
|
||||
|
||||
it('reports dead after the process closes', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
|
||||
await run(instance, 'goToDefinition')
|
||||
await instance.dispose()
|
||||
expect(instance.dead).toBe(true)
|
||||
})
|
||||
|
||||
it('escalates to SIGKILL when the server ignores shutdown and SIGTERM', async () => {
|
||||
// Server answers initialize, ignores shutdown, and traps SIGTERM so only SIGKILL stops it.
|
||||
const script = RESPONDING_SERVER + 'process.on("SIGTERM",()=>{});'
|
||||
const instance = scriptInstance(script, { shutdownTimeoutMs: 100, killGraceMs: 100 })
|
||||
await run(instance, 'goToDefinition')
|
||||
await expect(instance.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('awaits a surviving process-tree helper on every concurrent dispose', async () => {
|
||||
const marker = join(root, 'helper.pid')
|
||||
const helper = 'process.on("SIGTERM",()=>{});setInterval(()=>{},1000);'
|
||||
const script = 'const{spawn}=require("node:child_process");const{writeFileSync}=require("node:fs");'
|
||||
+ `const helper=spawn(process.execPath,["-e",${JSON.stringify(helper)}],{stdio:"ignore"});`
|
||||
+ `writeFileSync(${JSON.stringify(marker)},String(helper.pid));`
|
||||
+ RESPONDING_SERVER
|
||||
const instance = scriptInstance(script, { shutdownTimeoutMs: 100, killGraceMs: 100 })
|
||||
await run(instance, 'goToDefinition')
|
||||
const helperPid = Number(await readFile(marker, 'utf8'))
|
||||
try {
|
||||
const first = instance.dispose()
|
||||
await instance.dispose()
|
||||
expect(processAlive(helperPid)).toBe(false)
|
||||
await first
|
||||
} finally {
|
||||
if (processAlive(helperPid)) process.kill(helperPid, 'SIGKILL')
|
||||
await waitForProcessExit(helperPid)
|
||||
}
|
||||
})
|
||||
|
||||
it('carries a non-Error abort reason as a generic aborted error', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_HANG: '1' })
|
||||
const controller = new AbortController()
|
||||
const pending = run(instance, 'goToDefinition', controller.signal)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 200))
|
||||
controller.abort('a string reason, not an Error')
|
||||
await expect(pending).rejects.toThrow(/aborted/)
|
||||
})
|
||||
})
|
||||
|
||||
/** Probe a pid without changing its state. */
|
||||
function processAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false
|
||||
throw error
|
||||
}
|
||||
if (process.platform !== 'linux') return true
|
||||
try {
|
||||
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
|
||||
const state = stat.slice(stat.lastIndexOf(')') + 2).split(/\s+/, 1)[0]
|
||||
return !/^[ZXx]$/.test(state ?? '')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait until a process can no longer execute so temporary-workspace cleanup cannot race handle release. */
|
||||
async function waitForProcessExit(pid: number, timeoutMs = 3_000): Promise<void> {
|
||||
const started = Date.now()
|
||||
while (processAlive(pid)) {
|
||||
if (Date.now() - started > timeoutMs) throw new Error(`process ${pid} did not exit`)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
/** Write normally except for one method whose callback receives a deterministic transport error. */
|
||||
function failingWriter(method: string): ConnectionWriter {
|
||||
return (stdin, message, done) => {
|
||||
if ((message as { method?: unknown }).method === method) {
|
||||
queueMicrotask(() => { done(new Error(`fixture ${method} failure`)) })
|
||||
return
|
||||
}
|
||||
stdin.write(encodeMessage(message), done)
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait until a fixture marker exists, bounded so a broken handshake cannot hang the test. */
|
||||
async function waitForFile(path: string, timeoutMs = 3000): Promise<void> {
|
||||
const started = Date.now()
|
||||
for (;;) {
|
||||
try {
|
||||
await readFile(path)
|
||||
return
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
if (Date.now() - started > timeoutMs) throw new Error('waitForFile timed out')
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
495
packages/lsp/lsp-stdio/tests/lifecycle.spec.ts
Normal file
495
packages/lsp/lsp-stdio/tests/lifecycle.spec.ts
Normal file
@@ -0,0 +1,495 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Lsp, { type LspProvider, type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
|
||||
import { deadline } from '@deepseek-ai/dsh-timeout'
|
||||
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import * as LspLocal from '@deepseek-ai/dsh-lsp-stdio'
|
||||
import type { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-stdio'
|
||||
|
||||
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
|
||||
|
||||
let root: string
|
||||
let ws: string
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-local-')))
|
||||
ws = join(root, 'ws')
|
||||
await mkdir(ws)
|
||||
await writeFile(join(ws, 'a.ts'), 'const x = 1\nconst y = x\n')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** One fake stdio server entry with optional behavior and host-bound overrides. */
|
||||
function fakeServer(fakeEnv: Record<string, string> = {}, overrides: Partial<LspLocalServerConfig> = {}): LspLocalServerConfig {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [fixtureServer],
|
||||
env: { ...fakeEnv },
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Mount the real seam + lsp-stdio plugin driving one fake server. */
|
||||
async function mount(
|
||||
fakeEnv: Record<string, string> = {},
|
||||
overrides: Partial<LspLocalServerConfig> = {},
|
||||
captureProvider?: (provider: LspProvider) => void,
|
||||
): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
const register = ctx.lsp.registerProvider.bind(ctx.lsp)
|
||||
const registrationSpy = captureProvider === undefined
|
||||
? undefined
|
||||
: vi.spyOn(ctx.lsp, 'registerProvider').mockImplementation((provider) => {
|
||||
captureProvider(provider)
|
||||
return register(provider)
|
||||
})
|
||||
try {
|
||||
await ctx.plugin(LspLocal, {
|
||||
servers: { fake: fakeServer(fakeEnv, overrides) },
|
||||
})
|
||||
} finally {
|
||||
registrationSpy?.mockRestore()
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
function query(operation: LspQueryRequest['operation'], filePath = 'a.ts'): LspQueryRequest {
|
||||
return { operation, filePath, position: { line: 0, character: 6 }, workspaceRoot: ws }
|
||||
}
|
||||
|
||||
/** A single Location JSON pointing into the workspace. */
|
||||
function locationJson(line: number): unknown {
|
||||
return { uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line, character: 0 }, end: { line, character: 3 } } }
|
||||
}
|
||||
|
||||
describe('lsp-stdio end to end over a fake server', () => {
|
||||
it('routes different extensions to independent configured servers', async () => {
|
||||
await writeFile(join(ws, 'a.py'), 'x = 1\n')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
await ctx.plugin(LspLocal, {
|
||||
servers: {
|
||||
typescript: fakeServer({ LSP_FAKE_HOVER: JSON.stringify({ contents: 'ts' }) }),
|
||||
python: fakeServer(
|
||||
{ LSP_FAKE_HOVER: JSON.stringify({ contents: 'py' }) },
|
||||
{ extensionToLanguage: { '.py': 'python' } },
|
||||
),
|
||||
},
|
||||
})
|
||||
expect(await ctx.lsp.query(query('hover', 'a.ts'))).toEqual({ kind: 'hover', hover: { contents: 'ts' } })
|
||||
expect(await ctx.lsp.query(query('hover', 'a.py'))).toEqual({ kind: 'hover', hover: { contents: 'py' } })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resolves definition to normalized locations', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
|
||||
const result = await ctx.lsp.query(query('goToDefinition'))
|
||||
expect(result).toEqual<LspQueryResult>({
|
||||
kind: 'locations',
|
||||
locations: [{ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } }],
|
||||
resolvedWorkspaceUri: pathToFileURL(ws).href,
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('maps a LocationLink for implementation', async () => {
|
||||
const link = { targetUri: pathToFileURL(join(ws, 'a.ts')).href, targetSelectionRange: { start: { line: 1, character: 0 }, end: { line: 1, character: 2 } } }
|
||||
const ctx = await mount({ LSP_FAKE_IMPL: JSON.stringify([link]) })
|
||||
const result = await ctx.lsp.query(query('goToImplementation'))
|
||||
expect(result).toMatchObject({ kind: 'locations', locations: [{ range: { start: { line: 1, character: 0 } } }] })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('returns references (server includes the declaration)', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_REFS: JSON.stringify([locationJson(0), locationJson(1)]) })
|
||||
const result = await ctx.lsp.query(query('findReferences'))
|
||||
expect(result).toMatchObject({ kind: 'locations' })
|
||||
if (result.kind !== 'locations') throw new Error('expected locations')
|
||||
expect(result.locations).toHaveLength(2)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('normalizes a hover MarkupContent', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_HOVER: JSON.stringify({ contents: { kind: 'markdown', value: 'docs' } }) })
|
||||
const result = await ctx.lsp.query(query('hover'))
|
||||
expect(result).toEqual({ kind: 'hover', hover: { contents: 'docs' } })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('returns an empty locations result for a null definition', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
|
||||
expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: pathToFileURL(ws).href })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('returns a null hover for a null result', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_HOVER: 'null' })
|
||||
expect(await ctx.lsp.query(query('hover'))).toEqual({ kind: 'hover', hover: null })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a non-utf-16 position encoding at initialize without retrying', async () => {
|
||||
const marker = join(root, 'initialize-rejection-exit.log')
|
||||
const ctx = await mount({
|
||||
LSP_FAKE_ENCODING: 'utf-8',
|
||||
LSP_FAKE_DEF: 'null',
|
||||
LSP_FAKE_EXIT_MARKER: marker,
|
||||
})
|
||||
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/)
|
||||
expect(await readFile(marker, 'utf8')).toBe('EXIT\nCLEAN\n')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('does not pool a poisoned instance when initialize rejects', async () => {
|
||||
// A utf-8 server makes `initialize` reject; the instance must be torn down (not left with a
|
||||
// permanently-rejecting `ready`) so a later query starts a fresh process rather than reusing it.
|
||||
const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' })
|
||||
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/)
|
||||
// A second query must also fail the same way (fresh instance), and must NOT hang on a poisoned one.
|
||||
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a server without transient-open sync (None)', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_SYNC: '0', LSP_FAKE_DEF: 'null' })
|
||||
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/transient textDocument\/didOpen/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('accepts openClose options sync', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_SYNC: JSON.stringify({ openClose: true, change: 2 }), LSP_FAKE_DEF: 'null' })
|
||||
expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: pathToFileURL(ws).href })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('fails a query for an unsupported operation', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_CAPS: JSON.stringify({ hoverProvider: false }), LSP_FAKE_DEF: 'null' })
|
||||
await expect(ctx.lsp.query(query('hover'))).rejects.toThrow(/does not support hover/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a source outside the workspace before startup', async () => {
|
||||
const outside = join(root, 'out.ts')
|
||||
await writeFile(outside, 'x')
|
||||
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
|
||||
await expect(ctx.lsp.query({ ...query('goToDefinition'), filePath: outside })).rejects.toThrow(/outside the workspace/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('serializes queries through one instance and runs them in order', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
|
||||
const results = await Promise.all([
|
||||
ctx.lsp.query(query('goToDefinition')),
|
||||
ctx.lsp.query(query('goToDefinition')),
|
||||
ctx.lsp.query(query('goToDefinition')),
|
||||
])
|
||||
for (const result of results) expect(result).toMatchObject({ kind: 'locations' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('reads a queued query source only when its lifecycle starts', async () => {
|
||||
const marker = join(root, 'opened.jsonl')
|
||||
const ctx = await mount({
|
||||
LSP_FAKE_DEF: 'null',
|
||||
LSP_FAKE_REPLY_DELAY_MS: '300',
|
||||
LSP_FAKE_OPEN_MARKER: marker,
|
||||
})
|
||||
const first = ctx.lsp.query(query('goToDefinition'))
|
||||
await waitFor(async () => (await markerLines(marker)).length === 1)
|
||||
const second = ctx.lsp.query(query('goToDefinition'))
|
||||
await writeFile(join(ws, 'a.ts'), 'const changed = 2\n')
|
||||
await Promise.all([first, second])
|
||||
expect(await markerLines(marker)).toEqual([
|
||||
'const x = 1\nconst y = x\n',
|
||||
'const changed = 2\n',
|
||||
])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('aborts an in-flight query when the signal fires', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_HANG: '1' })
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.lsp.query(query('goToDefinition'), controller.signal)
|
||||
controller.abort(new Error('caller cancelled'))
|
||||
await expect(pending).rejects.toThrow(/cancelled/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('honors an already-aborted signal before any host I/O or startup', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('pre-aborted'))
|
||||
await expect(ctx.lsp.query(query('goToDefinition'), controller.signal)).rejects.toThrow(/pre-aborted/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('surfaces the server stderr tail in the exit error', async () => {
|
||||
// A server that writes to stderr then exits without answering: the query rejection carries the
|
||||
// retained stderr tail so the failure is diagnosable.
|
||||
const ctx = await mount({}, {
|
||||
command: process.execPath,
|
||||
args: ['-e', 'process.stderr.write("FATAL: boom\\n"); setTimeout(()=>process.exit(1), 50)'],
|
||||
})
|
||||
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/FATAL: boom/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('classifies a timeout deadline as the abort reason', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_HANG: '1' })
|
||||
using d = deadline(undefined, 50, 'TEST_TIMEOUT')
|
||||
await expect(ctx.lsp.query(query('goToDefinition'), d.signal)).rejects.toThrow(/TEST_TIMEOUT/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('fails the active query when the server crashes on open, and replaces it next query', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_CRASH_ON_OPEN: '1', LSP_FAKE_DEF: 'null' }, { shutdownTimeoutMs: 100, killGraceMs: 100 })
|
||||
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow()
|
||||
// A later query starts a fresh process; still crashes, but proves the slot was replaced (no hang).
|
||||
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('evicts a pooled server that died while idle and serves the next query from a fresh one', async () => {
|
||||
// The first query succeeds, then the server exits before the second arrives, leaving a dead
|
||||
// instance in the pool. The next query must evict-and-replace it and still succeed, rather than
|
||||
// failing once on the closed connection first.
|
||||
let provider: LspProvider | undefined
|
||||
const ctx = await mount(
|
||||
{ LSP_FAKE_EXIT_AFTER_REPLY: '1', LSP_FAKE_DEF: JSON.stringify(locationJson(0)) },
|
||||
{},
|
||||
(registered) => { provider = registered },
|
||||
)
|
||||
expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' })
|
||||
if (provider === undefined) throw new Error('expected lsp-stdio to register a provider')
|
||||
// This implementation-local test reaches the private pool only to synchronize with its actual
|
||||
// close state. A fixed wall-clock sleep can expire before a CPU-starved child runs its exit timer.
|
||||
const instances = (provider as unknown as {
|
||||
readonly instances: ReadonlyMap<string, { readonly dead: boolean }>
|
||||
}).instances
|
||||
const instance = [...instances.values()][0]
|
||||
// The query's finally may already have observed the exit and evicted the dead slot. When the
|
||||
// slot remains, synchronize with its close before proving the next query replaces it.
|
||||
if (instance !== undefined) await waitFor(async () => instance.dead)
|
||||
expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('does not spawn a server when the signal aborts during source read', async () => {
|
||||
// Abort right after issuing the query: the abort lands while canonicalizeWorkspace/readHostSource
|
||||
// are awaited, so the pre-spawn recheck must reject without ever creating a pooled instance.
|
||||
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.lsp.query(query('goToDefinition'), controller.signal)
|
||||
controller.abort(new Error('mid-read cancel'))
|
||||
await expect(pending).rejects.toThrow(/mid-read cancel/)
|
||||
// A subsequent live query still works, proving no half-created instance poisoned the pool.
|
||||
expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: pathToFileURL(ws).href })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('aborts and awaits a workspace lookup when the provider is disposed', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
|
||||
const fs = ctx.fs
|
||||
const resolve = fs.resolve.bind(fs)
|
||||
const started = Promise.withResolvers<AbortSignal>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
vi.spyOn(fs, 'resolve').mockImplementation(async (path, options) => {
|
||||
if (path !== ws) return await resolve(path, options)
|
||||
const signal = options?.signal
|
||||
if (signal === undefined) throw new Error('workspace lookup missing provider lifetime signal')
|
||||
started.resolve(signal)
|
||||
return await rejectWhenAborted(signal, release.promise)
|
||||
})
|
||||
|
||||
const pending = ctx.lsp.query(query('goToDefinition'))
|
||||
const signal = await started.promise
|
||||
let disposed = false
|
||||
const disposing = ctx.fiber.dispose().then(() => { disposed = true })
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
|
||||
expect(signal.aborted).toBe(true)
|
||||
expect(disposed).toBe(false)
|
||||
release.resolve(undefined)
|
||||
await expect(pending).rejects.toThrow('provider is disposed')
|
||||
await expect(disposing).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('aborts a queued source stream when the provider is disposed', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
|
||||
const fs = ctx.fs
|
||||
const started = Promise.withResolvers<AbortSignal>()
|
||||
vi.spyOn(fs, 'streamText').mockImplementation(async (_target, signal) => {
|
||||
if (signal === undefined) throw new Error('source read missing provider lifetime signal')
|
||||
started.resolve(signal)
|
||||
return (async function* () {
|
||||
await rejectWhenAborted(signal)
|
||||
yield ''
|
||||
})()
|
||||
})
|
||||
|
||||
const pending = ctx.lsp.query(query('goToDefinition'))
|
||||
const signal = await started.promise
|
||||
const disposing = ctx.fiber.dispose()
|
||||
|
||||
await expect(pending).rejects.toThrow('provider is disposed')
|
||||
await expect(disposing).resolves.toBeUndefined()
|
||||
expect(signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('waits for every owned teardown before aggregating instance failures', async () => {
|
||||
let provider: LspProvider | undefined
|
||||
const ctx = await mount({ LSP_FAKE_DEF: 'null' }, {}, (registered) => { provider = registered })
|
||||
if (provider === undefined) throw new Error('expected lsp-stdio to register a provider')
|
||||
const internals = provider as unknown as {
|
||||
readonly instances: Map<string, { dispose(): Promise<void> }>
|
||||
readonly queues: Map<string, Promise<void>>
|
||||
readonly workspaceLookups: Set<Promise<void>>
|
||||
disposeAll(): Promise<void>
|
||||
}
|
||||
const firstFailure = new Error('first instance cleanup failed')
|
||||
const secondFailure = new Error('second instance cleanup failed')
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
internals.instances.set('first', { dispose: async () => { throw firstFailure } })
|
||||
internals.instances.set('second', { dispose: async () => { throw secondFailure } })
|
||||
internals.queues.set('pending', release.promise)
|
||||
internals.workspaceLookups.add(Promise.resolve())
|
||||
|
||||
let settled = false
|
||||
const disposing = internals.disposeAll().finally(() => { settled = true })
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
expect(settled).toBe(false)
|
||||
release.resolve(undefined)
|
||||
await expect(disposing).rejects.toMatchObject({
|
||||
errors: [firstFailure, secondFailure],
|
||||
message: 'lsp-stdio instance teardown failed',
|
||||
})
|
||||
expect(internals.instances.size).toBe(0)
|
||||
expect(internals.queues.size).toBe(0)
|
||||
expect(internals.workspaceLookups.size).toBe(0)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('waits for every provider before reporting plugin teardown failure', async () => {
|
||||
const ctx = new Context()
|
||||
const disposalErrors: unknown[] = []
|
||||
ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
const providers: LspProvider[] = []
|
||||
const register = ctx.lsp.registerProvider.bind(ctx.lsp)
|
||||
const registrationSpy = vi.spyOn(ctx.lsp, 'registerProvider').mockImplementation((provider) => {
|
||||
providers.push(provider)
|
||||
return register(provider)
|
||||
})
|
||||
const fiber = await ctx.plugin(LspLocal, {
|
||||
servers: {
|
||||
first: fakeServer(),
|
||||
second: fakeServer({}, { extensionToLanguage: { '.js': 'javascript' } }),
|
||||
},
|
||||
})
|
||||
registrationSpy.mockRestore()
|
||||
expect(providers).toHaveLength(2)
|
||||
const failure = new Error('provider cleanup failed')
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const first = providers[0] as LspProvider & { disposeAll(): Promise<void> }
|
||||
const second = providers[1] as LspProvider & { disposeAll(): Promise<void> }
|
||||
first.disposeAll = async () => { throw failure }
|
||||
second.disposeAll = async () => { await release.promise }
|
||||
|
||||
let disposed = false
|
||||
const disposing = fiber.dispose().then(() => { disposed = true })
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
expect(disposed).toBe(false)
|
||||
expect(disposalErrors).toEqual([])
|
||||
release.resolve(undefined)
|
||||
await disposing
|
||||
expect(disposalErrors).toEqual([failure])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('runs distinct workspaces in parallel instances', async () => {
|
||||
const ws2 = join(root, 'ws2')
|
||||
await mkdir(ws2)
|
||||
await writeFile(join(ws2, 'a.ts'), 'const z = 2\n')
|
||||
const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
|
||||
const [r1, r2] = await Promise.all([
|
||||
ctx.lsp.query({ ...query('goToDefinition'), workspaceRoot: ws }),
|
||||
ctx.lsp.query({ ...query('goToDefinition'), workspaceRoot: ws2 }),
|
||||
])
|
||||
expect(r1).toMatchObject({ kind: 'locations' })
|
||||
expect(r2).toMatchObject({ kind: 'locations' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('disposes cleanly, terminating a server that ignores shutdown', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_NO_SHUTDOWN: '1', LSP_FAKE_DEF: 'null' }, { killGraceMs: 100, shutdownTimeoutMs: 100 })
|
||||
await ctx.lsp.query(query('goToDefinition'))
|
||||
await expect(ctx.fiber.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects at load when the command is not found', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
await expect(ctx.plugin(LspLocal, {
|
||||
servers: {
|
||||
missing: {
|
||||
command: 'definitely-not-a-real-lsp-binary-xyz',
|
||||
args: [],
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
},
|
||||
},
|
||||
})).rejects.toThrow(/was not found on PATH/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
/** Read the fixture's JSON-lines didOpen marker, returning no entries before it exists. */
|
||||
async function markerLines(path: string): Promise<string[]> {
|
||||
try {
|
||||
const text = await readFile(path, 'utf8')
|
||||
return text.trim().split('\n').filter(Boolean).map(line => JSON.parse(line) as string)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Poll an asynchronous condition until it succeeds or the test-local deadline expires. */
|
||||
async function waitFor(condition: () => Promise<boolean>, timeoutMs = 3000): Promise<void> {
|
||||
const started = Date.now()
|
||||
while (!await condition()) {
|
||||
if (Date.now() - started > timeoutMs) throw new Error('waitFor timed out')
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
/** Hold one fake provider operation until cancellation, optionally behind a cleanup gate. */
|
||||
function rejectWhenAborted<T>(signal: AbortSignal, release: Promise<unknown> = Promise.resolve()): Promise<T> {
|
||||
return new Promise((_resolve, reject) => {
|
||||
const onAbort = (): void => {
|
||||
void release.then(() => {
|
||||
reject(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)))
|
||||
})
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
if (signal.aborted) onAbort()
|
||||
})
|
||||
}
|
||||
291
packages/lsp/lsp-stdio/tests/provider.spec.ts
Normal file
291
packages/lsp/lsp-stdio/tests/provider.spec.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { chmod, mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp'
|
||||
import * as LspLocal from '@deepseek-ai/dsh-lsp-stdio'
|
||||
import type { Config, LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-stdio'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
|
||||
let root: string
|
||||
let ws: string
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-prov-')))
|
||||
ws = join(root, 'ws')
|
||||
await mkdir(ws)
|
||||
await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function query(): LspQueryRequest {
|
||||
return { operation: 'goToDefinition', filePath: 'a.ts', position: { line: 0, character: 0 }, workspaceRoot: ws }
|
||||
}
|
||||
|
||||
/** Wrap one server entry in the plugin's named server table. */
|
||||
function config(providerId: string, server: LspLocalServerConfig): Config {
|
||||
return { servers: { [providerId]: server } }
|
||||
}
|
||||
|
||||
describe('lsp-stdio provider resolution', () => {
|
||||
it('resolves a bare command on the child PATH and registers the provider', async () => {
|
||||
// A tiny executable script placed on a custom PATH dir: the load-time resolver must find it.
|
||||
const bin = join(root, 'bin')
|
||||
await mkdir(bin)
|
||||
const exe = join(bin, process.platform === 'win32' ? 'fake-lsp.cmd' : 'fake-lsp')
|
||||
await writeFile(exe, process.platform === 'win32' ? '@exit /b 0\r\n' : '#!/bin/sh\nexit 0\n')
|
||||
if (process.platform !== 'win32') await chmod(exe, 0o755)
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
await expect(ctx.plugin(LspLocal, config('onpath', {
|
||||
command: 'fake-lsp',
|
||||
args: [],
|
||||
env: { PATH: bin, ...process.platform === 'win32' ? { PATHEXT: '.CMD' } : {} },
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
}))).resolves.toBeDefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('skips empty PATH segments and fails when the command is absent', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
await expect(ctx.plugin(LspLocal, config('nope', {
|
||||
command: 'fake-lsp',
|
||||
args: [],
|
||||
env: { PATH: `${delimiter}${delimiter}${join(root, 'empty')}` },
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
}))).rejects.toThrow(/was not found on PATH/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a query after the provider is disposed', async () => {
|
||||
// Use a server that never emits results and dispose the plugin, then confirm queries are refused.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
// Grab the provider instance by registering, then dispose the whole plugin fiber.
|
||||
const lsp = ctx.lsp
|
||||
const fiber = await ctx.plugin(LspLocal, config('disp', {
|
||||
command: process.execPath,
|
||||
args: ['-e', 'setInterval(()=>{},1000)'],
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
}))
|
||||
await fiber.dispose()
|
||||
// After disposal the provider unregistered from the seam, so selection fails as unavailable.
|
||||
await expect(lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a nonpositive teardown budget at load', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
await expect(ctx.plugin(LspLocal, config('bad-budget', {
|
||||
command: process.execPath,
|
||||
args: ['-e', ''],
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
killGraceMs: 0,
|
||||
}))).rejects.toThrow(/servers\.bad-budget\.killGraceMs must be a positive integer/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a nonpositive byte cap at load', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
await expect(ctx.plugin(LspLocal, config('bad-cap', {
|
||||
command: process.execPath,
|
||||
args: ['-e', ''],
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
maxDocumentBytes: 0,
|
||||
}))).rejects.toThrow(/servers\.bad-cap\.maxDocumentBytes must be a positive integer/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it.each(['shutdownTimeoutMs', 'killGraceMs'] as const)('rejects %s above Node timer range at load', async (name) => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
await expect(ctx.plugin(LspLocal, config('bad-timer', {
|
||||
command: process.execPath,
|
||||
args: ['-e', ''],
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
[name]: MAX_TIMER_DELAY_MS + 1,
|
||||
}))).rejects.toThrow(new RegExp(`servers\\.bad-timer\\.${name}`))
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
// Node's X_OK probe is an existence check on Windows, which has no executable mode bit.
|
||||
it.skipIf(process.platform === 'win32')('rejects an absolute command that is not executable at load', async () => {
|
||||
const notExe = join(root, 'not-exe.txt')
|
||||
await writeFile(notExe, 'plain text, not executable')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
await expect(ctx.plugin(LspLocal, config('abs-bad', {
|
||||
command: notExe,
|
||||
args: [],
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
}))).rejects.toThrow(/is not an executable file/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects an executable directory as a command at load', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
await expect(ctx.plugin(LspLocal, config('abs-directory', {
|
||||
command: ws,
|
||||
args: [],
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
}))).rejects.toThrow(/is not an executable file/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects an empty server table at load', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
await expect(ctx.plugin(LspLocal, { servers: {} })).rejects.toThrow(/servers must contain at least one server/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects an empty server id at load', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
await expect(ctx.plugin(LspLocal, config('', {
|
||||
command: process.execPath,
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
}))).rejects.toThrow(/server ids must be non-empty strings/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resolves every executable before publishing any provider', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
await expect(ctx.plugin(LspLocal, {
|
||||
servers: {
|
||||
valid: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } },
|
||||
missing: { command: 'definitely-not-a-real-lsp-binary-xyz', extensionToLanguage: { '.py': 'python' } },
|
||||
},
|
||||
})).rejects.toThrow(/was not found on PATH/)
|
||||
await expect(ctx.lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('waits for aborted sibling executable lookups before setup rejects', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
const slowStarted = Promise.withResolvers<undefined>()
|
||||
const slowAborted = Promise.withResolvers<undefined>()
|
||||
const releaseCleanup = Promise.withResolvers<undefined>()
|
||||
vi.spyOn(ctx.subprocess, 'resolveExecutable').mockImplementation(async (command, _env, signal) => {
|
||||
if (signal === undefined) throw new Error('missing setup signal')
|
||||
if (command === 'slow-lsp') {
|
||||
return await new Promise<string>((_resolve, reject) => {
|
||||
const onAbort = (): void => {
|
||||
slowAborted.resolve(undefined)
|
||||
void releaseCleanup.promise.then(() => {
|
||||
reject(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)))
|
||||
})
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
slowStarted.resolve(undefined)
|
||||
if (signal.aborted) onAbort()
|
||||
})
|
||||
}
|
||||
await slowStarted.promise
|
||||
throw new Error('lookup failed')
|
||||
})
|
||||
|
||||
const loading = ctx.plugin(LspLocal, {
|
||||
servers: {
|
||||
slow: { command: 'slow-lsp', extensionToLanguage: { '.ts': 'typescript' } },
|
||||
failing: { command: 'failing-lsp', extensionToLanguage: { '.js': 'javascript' } },
|
||||
},
|
||||
})
|
||||
await slowAborted.promise
|
||||
let settled = false
|
||||
void loading.then(() => { settled = true }, () => { settled = true })
|
||||
await new Promise<void>((resolve) => { setImmediate(resolve) })
|
||||
expect(settled).toBe(false)
|
||||
|
||||
releaseCleanup.resolve(undefined)
|
||||
await expect(loading).rejects.toThrow('lookup failed')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('aborts executable resolution when disposed during setup', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
const subprocess = ctx.subprocess
|
||||
const lookupStarted = Promise.withResolvers<AbortSignal>()
|
||||
vi.spyOn(subprocess, 'resolveExecutable').mockImplementation(async (_command, _env, signal) => {
|
||||
if (signal === undefined) throw new Error('missing setup signal')
|
||||
lookupStarted.resolve(signal)
|
||||
return await new Promise<string>((_resolve, reject) => {
|
||||
const onAbort = (): void => {
|
||||
reject(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)))
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
if (signal.aborted) onAbort()
|
||||
})
|
||||
})
|
||||
|
||||
const loading = ctx.plugin(LspLocal, config('pending', {
|
||||
command: 'pending-lsp',
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
}))
|
||||
const signal = await lookupStarted.promise
|
||||
const unrelated = await ctx.plugin(() => {})
|
||||
await unrelated.dispose()
|
||||
expect(signal.aborted).toBe(false)
|
||||
const disposing = loading.dispose()
|
||||
|
||||
await expect(loading).rejects.toThrow('lsp-stdio setup disposed')
|
||||
await expect(disposing).resolves.toBeUndefined()
|
||||
expect(signal.aborted).toBe(true)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rolls back earlier registrations when a later server conflicts', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
await expect(ctx.plugin(LspLocal, {
|
||||
servers: {
|
||||
first: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } },
|
||||
second: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } },
|
||||
},
|
||||
})).rejects.toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' }))
|
||||
await expect(ctx.lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
173
packages/lsp/lsp-stdio/tests/translate.spec.ts
Normal file
173
packages/lsp/lsp-stdio/tests/translate.spec.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
negotiatePositionEncoding,
|
||||
normalizeHover,
|
||||
normalizeLocations,
|
||||
requestMethod,
|
||||
supportsOperation,
|
||||
supportsTransientOpen,
|
||||
} from '@deepseek-ai/dsh-lsp-stdio'
|
||||
import type { WireServerCapabilities } from '@deepseek-ai/dsh-lsp-stdio/src/protocol.ts'
|
||||
|
||||
const RANGE = { start: { line: 1, character: 2 }, end: { line: 1, character: 5 } }
|
||||
|
||||
describe('requestMethod', () => {
|
||||
it('maps each operation to its textDocument request', () => {
|
||||
expect(requestMethod('goToDefinition')).toBe('textDocument/definition')
|
||||
expect(requestMethod('findReferences')).toBe('textDocument/references')
|
||||
expect(requestMethod('goToImplementation')).toBe('textDocument/implementation')
|
||||
expect(requestMethod('hover')).toBe('textDocument/hover')
|
||||
})
|
||||
})
|
||||
|
||||
describe('supportsOperation', () => {
|
||||
it('reads the provider slot for each operation (boolean and options forms)', () => {
|
||||
const caps: WireServerCapabilities = {
|
||||
definitionProvider: true,
|
||||
referencesProvider: { workDoneProgress: true },
|
||||
implementationProvider: false,
|
||||
}
|
||||
expect(supportsOperation(caps, 'goToDefinition')).toBe(true)
|
||||
expect(supportsOperation(caps, 'findReferences')).toBe(true)
|
||||
expect(supportsOperation(caps, 'goToImplementation')).toBe(false)
|
||||
expect(supportsOperation(caps, 'hover')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('supportsTransientOpen', () => {
|
||||
it('accepts legacy Full and Incremental enums, rejects None and absent', () => {
|
||||
expect(supportsTransientOpen(1)).toBe(true)
|
||||
expect(supportsTransientOpen(2)).toBe(true)
|
||||
expect(supportsTransientOpen(0)).toBe(false)
|
||||
expect(supportsTransientOpen(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts options with openClose:true and rejects openClose:false', () => {
|
||||
expect(supportsTransientOpen({ openClose: true })).toBe(true)
|
||||
expect(supportsTransientOpen({ openClose: false, change: 2 })).toBe(false)
|
||||
})
|
||||
|
||||
it('requires an explicit openClose for the options form (no change-enum fallback)', () => {
|
||||
expect(supportsTransientOpen({ change: 1 })).toBe(false)
|
||||
expect(supportsTransientOpen({ change: 2 })).toBe(false)
|
||||
expect(supportsTransientOpen({})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('negotiatePositionEncoding', () => {
|
||||
it('defaults an omitted encoding to utf-16', () => {
|
||||
expect(negotiatePositionEncoding(undefined)).toBe('utf-16')
|
||||
expect(negotiatePositionEncoding('utf-16')).toBe('utf-16')
|
||||
})
|
||||
|
||||
it('rejects any other encoding', () => {
|
||||
expect(() => negotiatePositionEncoding('utf-8')).toThrow(/unsupported position encoding/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeLocations', () => {
|
||||
it('returns empty only for the protocol no-result value null', () => {
|
||||
expect(normalizeLocations(null)).toEqual([])
|
||||
expect(() => normalizeLocations(undefined)).toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
|
||||
})
|
||||
|
||||
it('maps a single Location', () => {
|
||||
expect(normalizeLocations({ uri: 'file:///a', range: RANGE })).toEqual([{ uri: 'file:///a', range: RANGE }])
|
||||
})
|
||||
|
||||
it('maps an array of Locations', () => {
|
||||
const result = normalizeLocations([{ uri: 'file:///a', range: RANGE }, { uri: 'file:///b', range: RANGE }])
|
||||
expect(result.map(l => l.uri)).toEqual(['file:///a', 'file:///b'])
|
||||
})
|
||||
|
||||
it('maps a LocationLink from targetUri + targetSelectionRange', () => {
|
||||
const link = { targetUri: 'file:///c', targetSelectionRange: RANGE, targetRange: RANGE }
|
||||
expect(normalizeLocations([link])).toEqual([{ uri: 'file:///c', range: RANGE }])
|
||||
})
|
||||
|
||||
it('rejects a non-object entry', () => {
|
||||
expect(() => normalizeLocations([42])).toThrow(/non-object/)
|
||||
})
|
||||
|
||||
it('rejects an entry that is neither a Location nor a LocationLink', () => {
|
||||
expect(() => normalizeLocations([{ nope: true }])).toThrow(/neither a Location nor a LocationLink/)
|
||||
})
|
||||
|
||||
it('rejects a Location whose range is not an object', () => {
|
||||
expect(() => normalizeLocations([{ uri: 'file:///a', range: 'nope' }])).toThrow(/neither a Location/)
|
||||
})
|
||||
|
||||
it('rejects a Location whose range positions are malformed', () => {
|
||||
expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: null, end: null } }])).toThrow(/neither a Location/)
|
||||
})
|
||||
|
||||
it('rejects negative and fractional position coordinates', () => {
|
||||
expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: { line: -1, character: 0 }, end: RANGE.end } }]))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
|
||||
expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: RANGE.start, end: { line: 1.5, character: 5 } } }]))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeHover', () => {
|
||||
it('returns null for null', () => {
|
||||
expect(normalizeHover(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a missing hover result', () => {
|
||||
expect(() => normalizeHover(undefined)).toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
|
||||
})
|
||||
|
||||
it('reads MarkupContent value and keeps a range', () => {
|
||||
expect(normalizeHover({ contents: { kind: 'markdown', value: '# H' }, range: RANGE }))
|
||||
.toEqual({ contents: '# H', range: RANGE })
|
||||
})
|
||||
|
||||
it('keeps a bare string MarkedString verbatim', () => {
|
||||
expect(normalizeHover({ contents: 'plain text' })).toEqual({ contents: 'plain text' })
|
||||
})
|
||||
|
||||
it('renders a language-tagged MarkedString object as a fenced code block', () => {
|
||||
expect(normalizeHover({ contents: { language: 'ts', value: 'const x = 1' } }))
|
||||
.toEqual({ contents: '```ts\nconst x = 1\n```' })
|
||||
})
|
||||
|
||||
it('joins a MarkedString array with one blank line', () => {
|
||||
expect(normalizeHover({ contents: ['a', { language: 'ts', value: 'b' }] }))
|
||||
.toEqual({ contents: 'a\n\n```ts\nb\n```' })
|
||||
})
|
||||
|
||||
it('drops an empty-contents hover to null', () => {
|
||||
expect(normalizeHover({ contents: { kind: 'plaintext', value: '' } })).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a MarkupContent with a non-string value', () => {
|
||||
expect(() => normalizeHover({ contents: { kind: 'markdown', value: 42 } }))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
|
||||
})
|
||||
|
||||
it('rejects a non-object payload', () => {
|
||||
expect(() => normalizeHover(42)).toThrow(/was not an object/)
|
||||
})
|
||||
|
||||
it('rejects malformed contents', () => {
|
||||
expect(() => normalizeHover({ contents: { weird: true } })).toThrow(/were not MarkupContent/)
|
||||
expect(() => normalizeHover({ contents: 42 })).toThrow(/were not MarkupContent/)
|
||||
})
|
||||
|
||||
it('rejects a malformed MarkedString array member', () => {
|
||||
expect(() => normalizeHover({ contents: ['ok', { language: 'ts', value: 42 }] }))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
|
||||
expect(() => normalizeHover({ contents: [null] }))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
|
||||
})
|
||||
|
||||
it('rejects a hover with no contents field', () => {
|
||||
expect(() => normalizeHover({ range: RANGE })).toThrow(/no contents/)
|
||||
})
|
||||
|
||||
it('rejects a malformed range instead of silently dropping it', () => {
|
||||
expect(() => normalizeHover({ contents: 'x', range: { start: { line: 1 } } }))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
|
||||
})
|
||||
})
|
||||
118
packages/lsp/lsp-stdio/tests/typescript-server.e2e.ts
Normal file
118
packages/lsp/lsp-stdio/tests/typescript-server.e2e.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Keyless real-server e2e: drives the real `typescript-language-server` through the full
|
||||
* `ctx.lsp` → `dsh-lsp-stdio` stack over the base protocol, exercising all four operations. No API
|
||||
* key needed — the server is a local dev dependency. This establishes one compatibility floor
|
||||
* (TypeScript), not a cross-language claim.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
|
||||
import * as LspLocal from '@deepseek-ai/dsh-lsp-stdio'
|
||||
|
||||
// The server binary is a dev dependency of this package; resolve its pnpm-hoisted .bin path.
|
||||
const serverBin = join(
|
||||
new URL('..', import.meta.url).pathname,
|
||||
'node_modules',
|
||||
'.bin',
|
||||
'typescript-language-server',
|
||||
)
|
||||
|
||||
let root: string
|
||||
let ws: string
|
||||
let ctx: Context
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-ts-e2e-')))
|
||||
ws = join(root, 'proj')
|
||||
await mkdir(ws)
|
||||
await writeFile(join(ws, 'tsconfig.json'), JSON.stringify({ compilerOptions: { strict: true, module: 'nodenext' } }))
|
||||
// A small program with a definition, a reference, an interface + implementation, and a typed value.
|
||||
await writeFile(join(ws, 'shapes.ts'), [
|
||||
'export interface Shape {',
|
||||
' area(): number',
|
||||
'}',
|
||||
'',
|
||||
'export class Circle implements Shape {',
|
||||
' constructor(private r: number) {}',
|
||||
' area(): number { return Math.PI * this.r * this.r }',
|
||||
'}',
|
||||
'',
|
||||
'export function describe(s: Shape): string {',
|
||||
' return `area=${s.area()}`',
|
||||
'}',
|
||||
'',
|
||||
'const c = new Circle(2)',
|
||||
'export const text = describe(c)',
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
await ctx.plugin(LspLocal, {
|
||||
servers: {
|
||||
typescript: {
|
||||
command: serverBin,
|
||||
args: ['--stdio'],
|
||||
extensionToLanguage: { '.ts': 'typescript', '.tsx': 'typescriptreact' },
|
||||
},
|
||||
},
|
||||
})
|
||||
}, 60_000)
|
||||
|
||||
afterAll(async () => {
|
||||
if (ctx) await ctx.fiber.dispose()
|
||||
if (root) await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** One-based helper mirroring the model contract, converted to the seam's zero-based position. */
|
||||
function at(operation: LspQueryRequest['operation'], line1: number, char1: number, filePath = 'shapes.ts'): LspQueryRequest {
|
||||
return { operation, filePath, position: { line: line1 - 1, character: char1 - 1 }, workspaceRoot: ws }
|
||||
}
|
||||
|
||||
function locations(result: LspQueryResult): readonly { uri: string }[] {
|
||||
if (result.kind !== 'locations') throw new Error(`expected locations, got ${result.kind}`)
|
||||
return result.locations
|
||||
}
|
||||
|
||||
describe('real typescript-language-server', () => {
|
||||
it('resolves the definition of a call site to its declaration', async () => {
|
||||
// `export const text = describe(c)` (line 15): `describe` begins at column 21.
|
||||
const result = await ctx.lsp.query(at('goToDefinition', 15, 22))
|
||||
const locs = locations(result)
|
||||
expect(locs.length).toBeGreaterThanOrEqual(1)
|
||||
expect(locs.some(l => l.uri.endsWith('shapes.ts'))).toBe(true)
|
||||
}, 60_000)
|
||||
|
||||
it('finds references to a symbol including its declaration', async () => {
|
||||
// References to `describe` from its declaration (line 10, col 17).
|
||||
const result = await ctx.lsp.query(at('findReferences', 10, 17))
|
||||
const locs = locations(result)
|
||||
// At least the declaration plus the call site.
|
||||
expect(locs.length).toBeGreaterThanOrEqual(2)
|
||||
}, 60_000)
|
||||
|
||||
it('resolves implementations of an interface', async () => {
|
||||
// Implementations of `Shape` (line 1, col 18) → Circle.
|
||||
const result = await ctx.lsp.query(at('goToImplementation', 1, 18))
|
||||
const locs = locations(result)
|
||||
expect(locs.length).toBeGreaterThanOrEqual(1)
|
||||
}, 60_000)
|
||||
|
||||
it('returns hover information for a typed symbol', async () => {
|
||||
// Hover on `Circle` in `new Circle(2)` (line 14, col 15).
|
||||
const result = await ctx.lsp.query(at('hover', 14, 15))
|
||||
expect(result.kind).toBe('hover')
|
||||
if (result.kind === 'hover') {
|
||||
expect(result.hover).not.toBeNull()
|
||||
expect(result.hover?.contents).toContain('Circle')
|
||||
}
|
||||
}, 60_000)
|
||||
})
|
||||
Reference in New Issue
Block a user