import { afterEach, beforeEach, 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 { pathToFileURL, fileURLToPath } from 'node:url' import { LspInstance } from '@deepseek-ai/dsh-lsp-local' import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' import type { LspProviderQuery } from '@deepseek-ai/dsh-lsp' const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) let root: string let ws: string 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') }) afterEach(async () => { for (const instance of live) await instance.dispose() live = [] await rm(root, { recursive: true, force: true }) }) function makeInstance(env: Record = {}, overrides: Partial = {}): LspInstance { const instance = new LspInstance({ command: process.execPath, args: ['--import', tsxLoader, fixtureServer], cwd: ws, env: { ...process.env as Record, TSX_TSCONFIG_PATH: repoTsconfig, ...env }, configuration: { setting: 42 }, initializationOptions: { init: true }, maxMessageBytes: 16_000_000, maxStderrBytes: 100_000, maxDocumentBytes: 4_000_000, shutdownTimeoutMs: 200, killGraceMs: 200, ...overrides, }) live.push(instance) return instance } function query(operation: LspProviderQuery['operation'] = 'definition'): LspProviderQuery { return { operation, filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ws, languageId: 'typescript' } } /** Build an instance whose "server" is an inline node script (for teardown-escalation control). */ function scriptInstance(script: string, overrides: Partial = {}): LspInstance { const instance = new LspInstance({ command: process.execPath, args: ['-e', script], cwd: ws, env: { ...process.env as Record }, configuration: null, initializationOptions: null, maxMessageBytes: 16_000_000, maxStderrBytes: 100_000, maxDocumentBytes: 4_000_000, shutdownTimeoutMs: 150, killGraceMs: 150, ...overrides, }) 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 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(instance.query(query('definition'))).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(instance.query(query('definition'))).resolves.toEqual({ kind: 'locations', locations: [] }) }) it('rejects a workspace/applyEdit request but keeps serving', async () => { const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'applyEdit', LSP_FAKE_DEF: 'null' }) await expect(instance.query(query('definition'))).resolves.toEqual({ kind: 'locations', locations: [] }) }) it('rejects an unknown server request but keeps serving', async () => { const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'unknown', LSP_FAKE_DEF: 'null' }) await expect(instance.query(query('definition'))).resolves.toEqual({ kind: 'locations', locations: [] }) }) }) describe('LspInstance query and abort', () => { it('sends includeDeclaration for references', async () => { const instance = makeInstance({ LSP_FAKE_REFS: JSON.stringify([JSON.parse(locJson())]) }) await expect(instance.query(query('references'))).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(instance.query(query('definition'), 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 = instance.query(query('definition'), controller.signal) await new Promise(resolve => setTimeout(resolve, 300)) controller.abort(new Error('mid-flight')) await expect(pending).rejects.toThrow(/mid-flight/) }) 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(instance.query(query('definition'))).rejects.toThrow(/does not support definition/) }) 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(instance.query(query('definition'), controller.signal)).rejects.toThrow(/server refused/) }) }) describe('LspInstance disposal', () => { it('is idempotent — a second dispose awaits close without error', async () => { const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) await instance.query(query('definition')) await instance.dispose() await expect(instance.dispose()).resolves.toBeUndefined() }) it('rejects a query after disposal', async () => { const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) await instance.query(query('definition')) await instance.dispose() await expect(instance.query(query('definition'))).rejects.toThrow(/disposed/) }) it('reports dead after the process closes', async () => { const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) await instance.query(query('definition')) 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 instance.query(query('definition')) await expect(instance.dispose()).resolves.toBeUndefined() }) 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 = instance.query(query('definition'), controller.signal) await new Promise(resolve => setTimeout(resolve, 200)) controller.abort('a string reason, not an Error') await expect(pending).rejects.toThrow(/aborted/) }) })