import { describe, expect, it } from "vitest"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { loadConfig } from "./config.js"; import { LspManager } from "./manager.js"; const FAKE = ["node", new URL("../test/fixtures/fake-server.mjs", import.meta.url).pathname]; function makeProject(): { dir: string; cleanup: () => void } { const dir = mkdtempSync(path.join(tmpdir(), "pi-lsp-mgr-")); return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; } describe("LspManager", () => { it("routes a file to the server for its extension and starts one client", async () => { const p = makeProject(); try { writeFileSync(path.join(p.dir, "package.json"), "{}"); const file = path.join(p.dir, "src", "a.ts"); mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(file, "export const x: number = 1;", "utf8"); const config = loadConfig({ cwd: p.dir, trusted: true }); config.servers.typescript = { command: FAKE, extensions: [".ts"], rootMarkers: ["package.json"], }; const manager = new LspManager({ config, cwd: p.dir, trusted: true }); try { const clients = await manager.getClients(file); expect(clients).toHaveLength(1); expect(clients[0].serverID).toBe("typescript"); // Second call reuses the same client (no duplicate spawn). const again = await manager.getClients(file); expect(again[0]).toBe(clients[0]); } finally { await manager.shutdown(); } } finally { p.cleanup(); } }); it("marks a server broken when its binary is missing and does not retry", async () => { const p = makeProject(); try { const file = path.join(p.dir, "a.go"); writeFileSync(file, "package main", "utf8"); const config = loadConfig({ cwd: p.dir, trusted: true }); config.servers.gopls = { command: ["definitely-not-installed-gopls-xyz"], extensions: [".go"], rootMarkers: [], }; const manager = new LspManager({ config, cwd: p.dir, trusted: true }); try { const clients = await manager.getClients(file); expect(clients).toEqual([]); const again = await manager.getClients(file); expect(again).toEqual([]); const status = manager.status(); expect(status.some((s) => s.status === "error")).toBe(true); } finally { await manager.shutdown(); } } finally { p.cleanup(); } }); it("collects diagnostics across all clients", async () => { const p = makeProject(); try { writeFileSync(path.join(p.dir, "package.json"), "{}"); const file = path.join(p.dir, "a.ts"); writeFileSync(file, "export const x = 1;", "utf8"); const config = loadConfig({ cwd: p.dir, trusted: true }); config.servers.typescript = { command: FAKE, extensions: [".ts"], rootMarkers: ["package.json"], env: { FAKE_ERRORS: "1", FAKE_PUSH_DELAY_MS: "10" }, }; const manager = new LspManager({ config, cwd: p.dir, trusted: true }); try { await manager.touchFile(file, "document"); const all = await manager.diagnostics(); expect(all[file]).toBeDefined(); expect(all[file].some((d) => d.message === "fake error")).toBe(true); } finally { await manager.shutdown(); } } finally { p.cleanup(); } }); it("shutdown stops all clients and is idempotent", async () => { const p = makeProject(); try { writeFileSync(path.join(p.dir, "package.json"), "{}"); const file = path.join(p.dir, "a.ts"); writeFileSync(file, "let x;", "utf8"); const config = loadConfig({ cwd: p.dir, trusted: true }); config.servers.typescript = { command: FAKE, extensions: [".ts"], rootMarkers: ["package.json"], }; const manager = new LspManager({ config, cwd: p.dir, trusted: true }); await manager.touchFile(file, "document"); await manager.shutdown(); await manager.shutdown(); // idempotent } finally { p.cleanup(); } }); describe("OpenCode API parity", () => { function manager(): { m: LspManager; file: string; cleanup: () => void } { const p = makeProject(); writeFileSync(path.join(p.dir, "package.json"), "{}"); const file = path.join(p.dir, "a.ts"); writeFileSync(file, "export const x = 1;", "utf8"); const config = loadConfig({ cwd: p.dir, trusted: true }); config.servers.typescript = { command: FAKE, extensions: [".ts"], rootMarkers: ["package.json"], }; const m = new LspManager({ config, cwd: p.dir, trusted: true }); return { m, file, cleanup: () => rmSync(p.dir, { recursive: true, force: true }) }; } it("hasClients is true for a served file and false otherwise", async () => { const { m, file, cleanup } = manager(); try { expect(await m.hasClients(file)).toBe(true); // .go routes to the gopls server (binary may be missing — hasClients // only checks extension+root+not-broken, matching OpenCode). expect(await m.hasClients(path.join(path.dirname(file), "other.go"))).toBe(true); expect(await m.hasClients(path.join(path.dirname(file), "other.txt"))).toBe(false); } finally { await m.shutdown(); cleanup(); } }); it("diagnostics returns a Record keyed by absolute path", async () => { const p = makeProject(); try { writeFileSync(path.join(p.dir, "package.json"), "{}"); const file = path.join(p.dir, "a.ts"); writeFileSync(file, "export const x = 1;", "utf8"); const config = loadConfig({ cwd: p.dir, trusted: true }); config.servers.typescript = { command: FAKE, extensions: [".ts"], rootMarkers: ["package.json"], env: { FAKE_ERRORS: "1", FAKE_PUSH_DELAY_MS: "10" }, }; const m = new LspManager({ config, cwd: p.dir, trusted: true }); try { await m.touchFile(file, "document"); const record = await m.diagnostics(); expect(record[file]).toBeDefined(); expect(record[file].some((d) => d.message === "fake error")).toBe(true); } finally { await m.shutdown(); } } finally { p.cleanup(); } }); it("definition and implementation return locations", async () => { const { m, file, cleanup } = manager(); try { const defs = (await m.definition({ file, line: 0, character: 8 })) as Array<{ range: { start: { line: number } }; }>; expect(defs.length).toBe(1); expect(defs[0].range.start.line).toBe(1); const impls = await m.implementation({ file, line: 0, character: 8 }); expect(impls.length).toBe(1); } finally { await m.shutdown(); cleanup(); } }); it("hover returns the hover contents", async () => { const { m, file, cleanup } = manager(); try { const hover = (await m.hover({ file, line: 0, character: 8 })) as { contents?: { value?: string }; } | null; expect(hover?.contents?.value).toBe("hover docs"); } finally { await m.shutdown(); cleanup(); } }); it("workspaceSymbol filters to known kinds and slices 10", async () => { const { m, file, cleanup } = manager(); try { await m.touchFile(file, "document"); const symbols = await m.workspaceSymbol("canned"); expect(symbols.some((s) => s.name === "keepMe")).toBe(true); expect(symbols.some((s) => s.name === "dropMe")).toBe(false); expect(symbols.length).toBeLessThanOrEqual(10); } finally { await m.shutdown(); cleanup(); } }); it("incomingCalls prepares then asks for incoming calls", async () => { const { m, file, cleanup } = manager(); try { const calls = (await m.incomingCalls({ file, line: 0, character: 0 })) as Array<{ from?: { name?: string }; to?: { name?: string }; }>; expect(calls.length).toBeGreaterThan(0); expect(calls[0].from?.name ?? calls[0].to?.name).toBeDefined(); } finally { await m.shutdown(); cleanup(); } }); it("status includes the name field", async () => { const { m, file, cleanup } = manager(); try { await m.getClients(file); const sessions = m.status(); expect(sessions.length).toBeGreaterThan(0); expect(sessions[0]).toHaveProperty("name"); expect(sessions[0]).toHaveProperty("id"); expect(sessions[0]).toHaveProperty("root"); expect(sessions[0]).toHaveProperty("status"); } finally { await m.shutdown(); cleanup(); } }); }); });