From 120ae367adcf9ccff14dd26c912382ac68c864de Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Fri, 13 Mar 2026 11:37:49 -0600 Subject: [PATCH] test: add LSP integration test against typescript-language-server Tests initialize, hover, go-to-definition, references, document symbols, diagnostics (type error detection), and clean shutdown against a real typescript-language-server instance with a temp TypeScript project. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/core/lsp/lsp-integration.test.ts | 407 ++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 packages/pi-coding-agent/src/core/lsp/lsp-integration.test.ts diff --git a/packages/pi-coding-agent/src/core/lsp/lsp-integration.test.ts b/packages/pi-coding-agent/src/core/lsp/lsp-integration.test.ts new file mode 100644 index 000000000..1db637356 --- /dev/null +++ b/packages/pi-coding-agent/src/core/lsp/lsp-integration.test.ts @@ -0,0 +1,407 @@ +/** + * Integration test for the LSP tool port. + * + * Spins up typescript-language-server against a temp TypeScript project + * and exercises: initialize, didOpen, hover, definition, references, + * documentSymbol, diagnostics, and shutdown. + * + * Run: node --experimental-strip-types --test src/core/lsp/lsp-integration.test.ts + * (from packages/pi-coding-agent/) + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; + +// --------------------------------------------------------------------------- +// Helpers — lightweight JSON-RPC over stdio (no dependency on our LSP code) +// --------------------------------------------------------------------------- + +interface JsonRpcRequest { + jsonrpc: "2.0"; + id: number; + method: string; + params: unknown; +} + +interface JsonRpcNotification { + jsonrpc: "2.0"; + method: string; + params?: unknown; +} + +interface JsonRpcResponse { + jsonrpc: "2.0"; + id?: number; + result?: unknown; + error?: { code: number; message: string }; +} + +function encodeMessage(msg: JsonRpcRequest | JsonRpcNotification | JsonRpcResponse): string { + const body = JSON.stringify(msg); + return `Content-Length: ${Buffer.byteLength(body, "utf-8")}\r\n\r\n${body}`; +} + +/** + * Minimal LSP harness: spawns a language server, sends requests, collects responses. + */ +class LspHarness { + private proc; + private nextId = 1; + private buffer = Buffer.alloc(0); + private pending = new Map void; reject: (e: Error) => void }>(); + private notifications: Array<{ method: string; params: unknown }> = []; + + constructor(command: string, args: string[], cwd: string) { + this.proc = spawn(command, args, { + cwd, + stdio: ["pipe", "pipe", "pipe"], + }); + + this.proc.stdout!.on("data", (chunk: Buffer) => { + this.buffer = Buffer.concat([this.buffer, chunk]); + this.drain(); + }); + + this.proc.stderr!.on("data", (chunk: Buffer) => { + // Swallow stderr (server logs) + }); + } + + private drain(): void { + while (true) { + const headerEnd = this.findHeaderEnd(); + if (headerEnd === -1) return; + + const headerText = this.buffer.subarray(0, headerEnd).toString("utf-8"); + const match = headerText.match(/Content-Length:\s*(\d+)/i); + if (!match) return; + + const contentLength = parseInt(match[1], 10); + const messageStart = headerEnd + 4; // past \r\n\r\n + const messageEnd = messageStart + contentLength; + if (this.buffer.length < messageEnd) return; + + const body = this.buffer.subarray(messageStart, messageEnd).toString("utf-8"); + this.buffer = Buffer.from(this.buffer.subarray(messageEnd)); + + const msg = JSON.parse(body) as JsonRpcResponse & { method?: string; params?: unknown }; + + if (msg.id !== undefined && this.pending.has(msg.id)) { + const p = this.pending.get(msg.id)!; + this.pending.delete(msg.id); + if (msg.error) { + p.reject(new Error(`LSP error ${msg.error.code}: ${msg.error.message}`)); + } else { + p.resolve(msg.result); + } + } else if (msg.method) { + // Server request or notification + this.notifications.push({ method: msg.method, params: msg.params }); + // Auto-respond to server requests that have an id + if (msg.id !== undefined) { + this.respond(msg.id, null); + } + } + } + } + + private findHeaderEnd(): number { + for (let i = 0; i < this.buffer.length - 3; i++) { + if ( + this.buffer[i] === 13 && + this.buffer[i + 1] === 10 && + this.buffer[i + 2] === 13 && + this.buffer[i + 3] === 10 + ) { + return i; + } + } + return -1; + } + + private respond(id: number, result: unknown): void { + const msg: JsonRpcResponse = { jsonrpc: "2.0", id, result }; + this.proc.stdin!.write(encodeMessage(msg)); + } + + async request(method: string, params: unknown, timeoutMs = 15000): Promise { + const id = this.nextId++; + const msg: JsonRpcRequest = { jsonrpc: "2.0", id, method, params }; + this.proc.stdin!.write(encodeMessage(msg)); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Request ${method} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + this.pending.set(id, { + resolve: (v) => { + clearTimeout(timer); + resolve(v); + }, + reject: (e) => { + clearTimeout(timer); + reject(e); + }, + }); + }); + } + + notify(method: string, params: unknown): void { + const msg: JsonRpcNotification = { jsonrpc: "2.0", method, params }; + this.proc.stdin!.write(encodeMessage(msg)); + } + + getNotifications(method?: string): Array<{ method: string; params: unknown }> { + if (!method) return this.notifications; + return this.notifications.filter((n) => n.method === method); + } + + async shutdown(): Promise { + try { + await this.request("shutdown", null, 5000); + this.notify("exit", null); + } catch { + // Best effort + } + this.proc.kill(); + } +} + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +function createTempProject(): { dir: string; cleanup: () => void } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lsp-test-")); + + // tsconfig.json + fs.writeFileSync( + path.join(dir, "tsconfig.json"), + JSON.stringify( + { + compilerOptions: { + target: "ES2022", + module: "commonjs", + strict: true, + outDir: "./dist", + rootDir: "./src", + }, + include: ["src/**/*.ts"], + }, + null, + 2, + ), + ); + + // package.json + fs.writeFileSync( + path.join(dir, "package.json"), + JSON.stringify({ name: "lsp-test-project", version: "1.0.0" }, null, 2), + ); + + fs.mkdirSync(path.join(dir, "src")); + + // src/math.ts — module with exported functions + fs.writeFileSync( + path.join(dir, "src", "math.ts"), + `export function add(a: number, b: number): number { + return a + b; +} + +export function subtract(a: number, b: number): number { + return a - b; +} + +export interface Calculator { + add(a: number, b: number): number; + subtract(a: number, b: number): number; +} +`, + ); + + // src/main.ts — imports from math, has a type error + fs.writeFileSync( + path.join(dir, "src", "main.ts"), + `import { add, subtract, Calculator } from "./math"; + +const result: number = add(1, 2); +const diff: number = subtract(5, 3); + +// Intentional type error: string assigned to number +const bad: number = "not a number"; + +export function compute(calc: Calculator): number { + return calc.add(1, 2) + calc.subtract(5, 3); +} +`, + ); + + return { + dir, + cleanup: () => fs.rmSync(dir, { recursive: true, force: true }), + }; +} + +function fileToUri(filePath: string): string { + return `file://${path.resolve(filePath)}`; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test("LSP integration: typescript-language-server", async (t) => { + const { dir, cleanup } = createTempProject(); + const mainPath = path.join(dir, "src", "main.ts"); + const mathPath = path.join(dir, "src", "math.ts"); + const mainUri = fileToUri(mainPath); + const mathUri = fileToUri(mathPath); + + const lsp = new LspHarness("typescript-language-server", ["--stdio"], dir); + + try { + // ---- Initialize ---- + await t.test("initialize handshake", async () => { + const result = (await lsp.request("initialize", { + processId: process.pid, + rootUri: fileToUri(dir), + rootPath: dir, + capabilities: { + textDocument: { + hover: { contentFormat: ["markdown", "plaintext"] }, + definition: { linkSupport: true }, + references: {}, + documentSymbol: { hierarchicalDocumentSymbolSupport: true }, + publishDiagnostics: { relatedInformation: true }, + }, + }, + workspaceFolders: [{ uri: fileToUri(dir), name: "test" }], + })) as { capabilities?: Record }; + + assert.ok(result, "initialize should return a result"); + assert.ok(result.capabilities, "result should have capabilities"); + assert.ok(result.capabilities.hoverProvider !== undefined, "should support hover"); + assert.ok(result.capabilities.definitionProvider !== undefined, "should support definition"); + }); + + lsp.notify("initialized", {}); + + // Open both files + const mainContent = fs.readFileSync(mainPath, "utf-8"); + const mathContent = fs.readFileSync(mathPath, "utf-8"); + + lsp.notify("textDocument/didOpen", { + textDocument: { uri: mainUri, languageId: "typescript", version: 1, text: mainContent }, + }); + lsp.notify("textDocument/didOpen", { + textDocument: { uri: mathUri, languageId: "typescript", version: 1, text: mathContent }, + }); + + // Give the server time to index + await new Promise((r) => setTimeout(r, 3000)); + + // ---- Hover ---- + await t.test("hover on 'add' call", async () => { + const result = (await lsp.request("textDocument/hover", { + textDocument: { uri: mainUri }, + position: { line: 2, character: 24 }, // on 'add' in "add(1, 2)" + })) as { contents?: unknown } | null; + + assert.ok(result, "hover should return a result"); + assert.ok(result.contents, "hover should have contents"); + const text = JSON.stringify(result.contents); + assert.ok( + text.includes("add") || text.includes("number"), + `hover text should mention 'add' or 'number', got: ${text.slice(0, 200)}`, + ); + }); + + // ---- Go to Definition ---- + await t.test("go to definition of 'add'", async () => { + const result = (await lsp.request("textDocument/definition", { + textDocument: { uri: mainUri }, + position: { line: 2, character: 24 }, // on 'add' + })) as unknown; + + assert.ok(result, "definition should return a result"); + const locations = Array.isArray(result) ? result : [result]; + assert.ok(locations.length > 0, "should find at least one definition"); + // Response can be Location (uri) or LocationLink (targetUri) + const loc = locations[0] as Record; + const uri = (loc.uri ?? loc.targetUri) as string; + assert.ok(uri, `definition should have uri or targetUri, got keys: ${Object.keys(loc).join(", ")}`); + assert.ok( + uri.includes("math.ts"), + `definition should point to math.ts, got: ${uri}`, + ); + }); + + // ---- References ---- + await t.test("find references of 'add'", async () => { + const result = (await lsp.request("textDocument/references", { + textDocument: { uri: mathUri }, + position: { line: 0, character: 16 }, // on 'add' definition + context: { includeDeclaration: true }, + })) as Array<{ uri: string; range: unknown }> | null; + + assert.ok(result, "references should return a result"); + assert.ok(result.length >= 2, `should find at least 2 references (decl + usage), got ${result.length}`); + }); + + // ---- Document Symbols ---- + await t.test("document symbols in math.ts", async () => { + const result = (await lsp.request("textDocument/documentSymbol", { + textDocument: { uri: mathUri }, + })) as Array<{ name: string; kind: number }> | null; + + assert.ok(result, "documentSymbol should return a result"); + assert.ok(result.length >= 2, `should find at least 2 symbols, got ${result.length}`); + const names = result.map((s) => s.name); + assert.ok(names.includes("add"), `symbols should include 'add', got: ${names.join(", ")}`); + assert.ok(names.includes("subtract"), `symbols should include 'subtract', got: ${names.join(", ")}`); + }); + + // ---- Diagnostics (published via notification) ---- + await t.test("diagnostics for type error", async () => { + // Wait a bit more for diagnostics to arrive + await new Promise((r) => setTimeout(r, 2000)); + + const diagNotifications = lsp.getNotifications("textDocument/publishDiagnostics"); + const mainDiags = diagNotifications.filter( + (n) => (n.params as { uri: string }).uri === mainUri, + ); + + assert.ok(mainDiags.length > 0, "should receive diagnostics for main.ts"); + + const lastDiag = mainDiags[mainDiags.length - 1]; + const diagnostics = (lastDiag.params as { diagnostics: Array<{ message: string; range: unknown }> }) + .diagnostics; + + // Should catch the type error: string assigned to number + const typeError = diagnostics.find( + (d) => d.message.includes("not assignable") || d.message.includes("Type"), + ); + assert.ok( + typeError, + `should find type error diagnostic, got: ${diagnostics.map((d) => d.message).join("; ")}`, + ); + }); + + // ---- Shutdown ---- + await t.test("clean shutdown", async () => { + // Should not throw + await lsp.shutdown(); + }); + } catch (err) { + await lsp.shutdown().catch(() => {}); + cleanup(); + throw err; + } + + cleanup(); +});