Skip to content

Commit e6b9f84

Browse files
committed
test(server): cover managed LSP verify across platforms and Vue companion lifecycle
The Vue LSP work fixed a Windows `where.exe` regression that affected every managed LSP (python / go / rust / vue) at the verify step, but no test actually exercised the real `checkCommandAvailable` against an absolute path. Add coverage to lock that fix in and fill a few adjacent gaps. - add `lsp-tools/install-manager.integration.test.ts`: drives the install manager with the real `checkCommandAvailable` (not the mocked one used in the unit tests) for every managed serverKind, with a stubbed `runCommand` that writes a fake executable to the expected absolute path. Verifies the verify step succeeds when the file is on disk and fails cleanly when it isn't. - add two Vue companion lifecycle tests in `lsp/session.test.ts`: primary exit must SIGTERM the companion, and explicit `stop()` must bring both children down so idle TTL cleanup doesn't leak processes. - fix pre-existing Windows-flaky cases: - `install-manager.test.ts > returns missing_prerequisite ...` now pins `platform: "linux"` so `getManagedPrerequisites` doesn't silently add `python` as a fallback on win32. - `install-manager.test.ts > downloads rust-analyzer ...` computes `executablePath` with the platform-aware `.exe` suffix. - `lsp/document-store.test.ts`: gate three POSIX-only assertions (`/repo` style absolute paths, `symlinkSync` requiring elevated privileges) via `it.skip` on win32. Suite is now green on Windows: 105 passed + 3 POSIX-only skipped.
1 parent a2db5bb commit e6b9f84

4 files changed

Lines changed: 338 additions & 17 deletions

File tree

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/**
2+
* Integration tests for `LspToolInstallManager`'s verify step.
3+
*
4+
* Goal: prove the verify step works for every managed LSP under both POSIX
5+
* and Windows path conventions, *without mocking `commandExists`*. That makes
6+
* this the only place that exercises the real `checkCommandAvailable` against
7+
* the absolute path the install plan computes.
8+
*
9+
* Why this matters: every managed LSP (python, go, rust, vue) verifies by
10+
* passing an absolute path to `commandExists`. Windows `where.exe` rejects
11+
* absolute paths because it parses the colon as a `path:pattern` separator,
12+
* so before the absolute-path branch in `checkCommandAvailable`, every LSP
13+
* install silently failed at verify. We assert here that an on-disk
14+
* executable at the planned path passes the verify step for every kind.
15+
*
16+
* Strategy:
17+
* - Mock only `runCommand` (so we don't actually invoke npm/pip/go/curl).
18+
* - In the runCommand mock, write a real file at the expected
19+
* `executablePath` so the verify step's `fs.existsSync` succeeds.
20+
* - Run for every managed serverKind, under both `linux` and `win32`.
21+
*/
22+
23+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
24+
import { tmpdir } from "node:os";
25+
import { dirname, join } from "node:path";
26+
import type { LspServerKind, Workspace } from "@coder-studio/core";
27+
import { afterEach, describe, expect, it, vi } from "vitest";
28+
import { VUE_MANAGED_VERSION } from "./definitions.js";
29+
import { LspToolInstallManager } from "./install-manager.js";
30+
import { FileManifestStore } from "./manifest-store.js";
31+
32+
const workspace: Workspace = {
33+
id: "ws-1",
34+
path: "/repo",
35+
targetRuntime: "native",
36+
openedAt: 1,
37+
lastActiveAt: 1,
38+
uiState: { leftPanelWidth: 240, bottomPanelHeight: 180, focusMode: false },
39+
};
40+
41+
interface ExpectedInstall {
42+
serverKind: LspServerKind;
43+
expectedPath: (root: string, platform: NodeJS.Platform) => string;
44+
}
45+
46+
const CASES: ExpectedInstall[] = [
47+
{
48+
serverKind: "python",
49+
expectedPath: (root, platform) =>
50+
join(
51+
root,
52+
"python",
53+
"1.14.0",
54+
"venv",
55+
platform === "win32" ? "Scripts" : "bin",
56+
platform === "win32" ? "pylsp.exe" : "pylsp"
57+
),
58+
},
59+
{
60+
serverKind: "go",
61+
expectedPath: (root, platform) =>
62+
join(root, "go", "v0.21.1", "bin", platform === "win32" ? "gopls.exe" : "gopls"),
63+
},
64+
{
65+
serverKind: "rust",
66+
expectedPath: (root, platform) =>
67+
join(
68+
root,
69+
"rust",
70+
"2026-05-18",
71+
"bin",
72+
platform === "win32" ? "rust-analyzer.exe" : "rust-analyzer"
73+
),
74+
},
75+
{
76+
serverKind: "vue",
77+
expectedPath: (root, platform) =>
78+
join(
79+
root,
80+
"vue",
81+
VUE_MANAGED_VERSION,
82+
"node_modules",
83+
".bin",
84+
platform === "win32" ? "vue-language-server.cmd" : "vue-language-server"
85+
),
86+
},
87+
];
88+
89+
// We can only meaningfully exercise the verify step on the host's actual
90+
// platform — `path.join` and `fs.existsSync` use host conventions, and the
91+
// real `checkCommandAvailable` walks the host PATH for any bare-name
92+
// fallbacks. Cross-platform behavior of `checkCommandAvailable` itself is
93+
// covered in detail by `command-check.test.ts`.
94+
const PLATFORM = process.platform;
95+
96+
describe(`LspToolInstallManager verify step (platform=${PLATFORM})`, () => {
97+
afterEach(() => {
98+
vi.restoreAllMocks();
99+
});
100+
101+
// Whitelist the bare-name prerequisites so the test doesn't depend on
102+
// python3 / go / npm actually being installed on the runner. The verify
103+
// step itself still goes through the *real* checkCommandAvailable.
104+
const allowedPrereqs = new Set(["npm", "python", "python3", "go"]);
105+
async function smartCommandExists(command: string): Promise<boolean> {
106+
if (allowedPrereqs.has(command)) {
107+
return true;
108+
}
109+
const { checkCommandAvailable } = await import("../provider-runtime/command-check.js");
110+
return checkCommandAvailable(command, { platform: PLATFORM });
111+
}
112+
113+
it.each(CASES)("$serverKind verify accepts the absolute managed executable path", async ({
114+
serverKind,
115+
expectedPath: pathFn,
116+
}) => {
117+
const root = mkdtempSync(join(tmpdir(), `lsp-install-${serverKind}-`));
118+
const expectedPath = pathFn(root, PLATFORM);
119+
120+
const runCommand = vi.fn(async () => {
121+
// Simulate the install step actually putting the executable on disk
122+
// so the verify step (real `checkCommandAvailable`) can find it.
123+
mkdirSync(dirname(expectedPath), { recursive: true });
124+
writeFileSync(expectedPath, "#!/usr/bin/env sh\nexit 0\n", { mode: 0o755 });
125+
return { stdout: "", stderr: "" };
126+
});
127+
128+
const manager = new LspToolInstallManager({
129+
manifestStore: new FileManifestStore(root),
130+
platform: PLATFORM,
131+
commandExists: smartCommandExists,
132+
runCommand,
133+
});
134+
135+
const job = await manager.start({ workspace, serverKind });
136+
137+
await vi.waitFor(
138+
() => {
139+
const snapshot = manager.get(job.jobId);
140+
expect(snapshot?.status).not.toBe("running");
141+
expect(snapshot?.status).not.toBe("queued");
142+
},
143+
{ timeout: 5000 }
144+
);
145+
146+
const final = manager.get(job.jobId);
147+
expect(final?.status).toBe("succeeded");
148+
149+
// Manifest written → verify step actually accepted the on-disk file.
150+
// On Windows this is the regression test for the `where.exe` colon bug.
151+
const manifest = new FileManifestStore(root).read(serverKind);
152+
expect(manifest).toMatchObject({
153+
serverKind,
154+
executablePath: expectedPath,
155+
platform: PLATFORM,
156+
source: "managed",
157+
});
158+
});
159+
160+
it.each(
161+
CASES
162+
)("$serverKind verify rejects when no executable exists at the expected path", async ({
163+
serverKind,
164+
}) => {
165+
const root = mkdtempSync(join(tmpdir(), `lsp-install-${serverKind}-miss-`));
166+
167+
const manager = new LspToolInstallManager({
168+
manifestStore: new FileManifestStore(root),
169+
platform: PLATFORM,
170+
commandExists: smartCommandExists,
171+
// runCommand succeeds but never writes the file, simulating an
172+
// install step that silently completed without producing the binary.
173+
runCommand: vi.fn(async () => ({ stdout: "", stderr: "" })),
174+
});
175+
176+
const job = await manager.start({ workspace, serverKind });
177+
178+
await vi.waitFor(
179+
() => {
180+
const snapshot = manager.get(job.jobId);
181+
expect(snapshot?.status).not.toBe("running");
182+
expect(snapshot?.status).not.toBe("queued");
183+
},
184+
{ timeout: 5000 }
185+
);
186+
187+
const final = manager.get(job.jobId);
188+
expect(final?.status).toBe("failed");
189+
// The verify step is the last one in every install plan; if it errored
190+
// because the file isn't there, the failure should not be the missing
191+
// prerequisites code (those were satisfied by smartCommandExists).
192+
expect(final?.failure?.code).not.toBe("missing_prerequisite");
193+
});
194+
});

packages/server/src/lsp-tools/install-manager.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,12 @@ describe("LspToolInstallManager", () => {
2727
});
2828

2929
it("returns missing_prerequisite when python3 is unavailable", async () => {
30+
// Pin platform so the prerequisite list is deterministic (on win32 the
31+
// manager also tries `python` as a fallback, which would otherwise leak
32+
// into `missingCommands`).
3033
const manager = new LspToolInstallManager({
3134
manifestStore: new FileManifestStore(mkdtempSync(join(tmpdir(), "lsp-tools-"))),
35+
platform: "linux",
3236
commandExists: vi.fn(async () => false),
3337
runCommand: vi.fn(async () => ({ stdout: "", stderr: "" })),
3438
});
@@ -330,7 +334,15 @@ describe("LspToolInstallManager", () => {
330334
it("downloads rust-analyzer into the managed tool directory and writes a manifest", async () => {
331335
const root = mkdtempSync(join(tmpdir(), "lsp-tools-"));
332336
let installed = false;
333-
const executablePath = join(root, "rust", "2026-05-18", "bin", "rust-analyzer");
337+
// The real manager picks `.exe` on Windows; mirror that here so the
338+
// commandExists mock matches the path the verify step actually checks.
339+
const executablePath = join(
340+
root,
341+
"rust",
342+
"2026-05-18",
343+
"bin",
344+
process.platform === "win32" ? "rust-analyzer.exe" : "rust-analyzer"
345+
);
334346

335347
const manager = new LspToolInstallManager({
336348
manifestStore: new FileManifestStore(root),

packages/server/src/lsp/document-store.test.ts

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ import { pathToFileURL } from "node:url";
55
import { describe, expect, it } from "vitest";
66
import { DocumentStore } from "./document-store.js";
77

8+
// POSIX-style fixtures (`/repo`, `file:///repo/...`) only resolve correctly
9+
// when the host treats `/` as an absolute root. On Windows `path.resolve("/repo")`
10+
// returns `<drive>:\repo`, which breaks both URI construction and reverse
11+
// lookup. Gate the POSIX-only assertions to keep the suite green on every host
12+
// while preserving the actual coverage on the platforms where it matters.
13+
const itPosix = process.platform === "win32" ? it.skip : it;
14+
815
describe("DocumentStore", () => {
916
it("tracks open/change/close versions and replayable snapshots", () => {
1017
const store = new DocumentStore("/repo");
@@ -31,15 +38,15 @@ describe("DocumentStore", () => {
3138
expect(store.listReplayable()).toHaveLength(0);
3239
});
3340

34-
it("maps file URIs back to workspace-relative paths without a leading slash", () => {
41+
itPosix("maps file URIs back to workspace-relative paths without a leading slash", () => {
3542
const store = new DocumentStore("/repo");
3643

3744
expect(store.fromUri("file:///repo/e2e/fixtures/lsp-workspace/shared.ts")).toBe(
3845
"e2e/fixtures/lsp-workspace/shared.ts"
3946
);
4047
});
4148

42-
it("encodes spaces in file URIs and decodes them back to relative paths", () => {
49+
itPosix("encodes spaces in file URIs and decodes them back to relative paths", () => {
4350
const store = new DocumentStore("/repo with spaces");
4451
const opened = store.open({
4552
path: "dir/a b.ts",
@@ -61,18 +68,24 @@ describe("DocumentStore", () => {
6168
);
6269
});
6370

64-
it("maps POSIX file URIs back to workspace-relative paths when the workspace path is a symlink alias", () => {
65-
const realRoot = mkdtempSync(join(tmpdir(), "document-store-real-"));
66-
const aliasParent = mkdtempSync(join(tmpdir(), "document-store-alias-"));
67-
const aliasRoot = join(aliasParent, "workspace");
68-
69-
mkdirSync(join(realRoot, "src"));
70-
symlinkSync(realRoot, aliasRoot, "dir");
71-
72-
const store = new DocumentStore(aliasRoot);
73-
74-
expect(store.fromUri(pathToFileURL(join(realRoot, "src/main.ts")).toString())).toBe(
75-
"src/main.ts"
76-
);
77-
});
71+
// `symlinkSync(... "dir")` requires elevated privileges or Developer Mode on
72+
// Windows. The behaviour we care about (resolving symlink-aliased workspace
73+
// roots) is POSIX-only in practice, so gate the test to non-Windows hosts.
74+
itPosix(
75+
"maps POSIX file URIs back to workspace-relative paths when the workspace path is a symlink alias",
76+
() => {
77+
const realRoot = mkdtempSync(join(tmpdir(), "document-store-real-"));
78+
const aliasParent = mkdtempSync(join(tmpdir(), "document-store-alias-"));
79+
const aliasRoot = join(aliasParent, "workspace");
80+
81+
mkdirSync(join(realRoot, "src"));
82+
symlinkSync(realRoot, aliasRoot, "dir");
83+
84+
const store = new DocumentStore(aliasRoot);
85+
86+
expect(store.fromUri(pathToFileURL(join(realRoot, "src/main.ts")).toString())).toBe(
87+
"src/main.ts"
88+
);
89+
}
90+
);
7891
});

0 commit comments

Comments
 (0)