Last active
August 20, 2025 00:44
-
-
Save drevantonder/29f70c9629e9234866285c2dab98e021 to your computer and use it in GitHub Desktop.
A minimal test for vue LSP with typescript-language-server
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { describe, test, expect, beforeAll, afterAll } from "bun:test" | |
| import { createMessageConnection, StreamMessageReader, StreamMessageWriter, type MessageConnection } from "vscode-jsonrpc/node" | |
| import type { Diagnostic } from "vscode-languageserver-types" | |
| import { spawn, type ChildProcessWithoutNullStreams } from "child_process" | |
| import fs from "fs/promises" | |
| import os from "os" | |
| import path from "path" | |
| import { pathToFileURL } from "url" | |
| import { createRequire } from "module" | |
| // Ultra-minimal test for Vue LSP v3 with typescript-language-server | |
| let tempDir: string | |
| let vueProc: ChildProcessWithoutNullStreams | null = null | |
| let vueConn: MessageConnection | null = null | |
| let tsProc: ChildProcessWithoutNullStreams | null = null | |
| let tsConn: MessageConnection | null = null | |
| const tsDiagnostics = new Map<string, Diagnostic[]>() | |
| const diagnosticResultIds = new Map<string, string>() | |
| async function makeTempProject(): Promise<string> { | |
| const dir = await fs.mkdtemp(path.join(os.tmpdir(), "vue-lsp-test-")) | |
| await fs.writeFile( | |
| path.join(dir, "package.json"), | |
| JSON.stringify({ | |
| name: "vue-lsp-test", | |
| dependencies: { vue: "3.5.12" } | |
| }), | |
| "utf8" | |
| ) | |
| await fs.writeFile( | |
| path.join(dir, "tsconfig.json"), | |
| JSON.stringify({ | |
| compilerOptions: { | |
| noUnusedLocals: true, | |
| noUnusedParameters: true | |
| } | |
| }), | |
| "utf8" | |
| ) | |
| await fs.mkdir(path.join(dir, "src"), { recursive: true }) | |
| // Stub node_modules/vue to satisfy Vue LS | |
| await fs.mkdir(path.join(dir, "node_modules", "vue"), { recursive: true }) | |
| await fs.writeFile( | |
| path.join(dir, "node_modules", "vue", "package.json"), | |
| JSON.stringify({ name: "vue", version: "3.5.12" }), | |
| "utf8" | |
| ) | |
| return dir | |
| } | |
| async function startServers(root: string) { | |
| const require = createRequire(import.meta.url) | |
| const rootUri = pathToFileURL(root).href | |
| // Start Vue Language Server | |
| const vueBin = require.resolve("@vue/language-server/bin/vue-language-server.js") | |
| vueProc = spawn(vueBin, ["--stdio"], { | |
| stdio: ["pipe", "pipe", "pipe"], | |
| cwd: root | |
| }) | |
| vueConn = createMessageConnection( | |
| new StreamMessageReader(vueProc.stdout!), | |
| new StreamMessageWriter(vueProc.stdin!) | |
| ) | |
| // Start TypeScript Language Server | |
| const tsBin = require.resolve("typescript-language-server/lib/cli.mjs") | |
| const vueLsDir = path.dirname(require.resolve("@vue/language-server/package.json")) | |
| tsProc = spawn("node", [tsBin, "--stdio"], { | |
| stdio: ["pipe", "pipe", "pipe"], | |
| cwd: root | |
| }) | |
| tsConn = createMessageConnection( | |
| new StreamMessageReader(tsProc.stdout!), | |
| new StreamMessageWriter(tsProc.stdin!) | |
| ) | |
| // Track TypeScript diagnostics | |
| tsConn.onNotification("textDocument/publishDiagnostics", (params: any) => { | |
| tsDiagnostics.set(params.uri, params.diagnostics || []) | |
| }) | |
| // Required handlers | |
| vueConn.onRequest("workspace/configuration", () => []) | |
| tsConn.onRequest("workspace/configuration", () => []) | |
| // Bridge tsserver requests from Vue LS to TypeScript LS | |
| vueConn.onNotification( | |
| "tsserver/request", | |
| async ([id, command, payload]: [number, string, unknown]) => { | |
| const result = await tsConn!.sendRequest("workspace/executeCommand", { | |
| command: "typescript.tsserverRequest", | |
| arguments: [command, payload] | |
| } as never) | |
| const body = (result as any)?.body ?? result | |
| vueConn!.sendNotification("tsserver/response", [id, body]) | |
| } | |
| ) | |
| vueConn.listen() | |
| tsConn.listen() | |
| // Initialize TypeScript Language Server | |
| await tsConn.sendRequest("initialize", { | |
| processId: process.pid, | |
| rootUri, | |
| capabilities: { | |
| workspace: { configuration: true }, | |
| textDocument: { publishDiagnostics: { relatedInformation: true } } | |
| }, | |
| workspaceFolders: [{ uri: rootUri, name: "workspace" }], | |
| initializationOptions: { | |
| plugins: [{ | |
| name: "@vue/typescript-plugin", | |
| location: vueLsDir, | |
| languages: ["vue"] | |
| }] | |
| } | |
| }) | |
| tsConn.sendNotification("initialized", {}) | |
| // Initialize Vue Language Server | |
| await vueConn.sendRequest("initialize", { | |
| processId: process.pid, | |
| rootUri, | |
| capabilities: { | |
| workspace: { configuration: true } | |
| }, | |
| workspaceFolders: [{ uri: rootUri, name: "workspace" }] | |
| }) | |
| vueConn.sendNotification("initialized", {}) | |
| } | |
| async function stopServers() { | |
| if (vueConn) { | |
| await vueConn.sendRequest("shutdown").catch(() => {}) | |
| vueConn.sendNotification("exit") | |
| vueConn.dispose() | |
| } | |
| if (tsConn) { | |
| await tsConn.sendRequest("shutdown").catch(() => {}) | |
| tsConn.sendNotification("exit") | |
| tsConn.dispose() | |
| } | |
| vueProc?.kill() | |
| tsProc?.kill() | |
| } | |
| async function pullDiagnostics(uri: string, timeoutMs = 10000): Promise<Diagnostic[] | null> { | |
| if (!vueConn) return null | |
| const start = Date.now() | |
| while (Date.now() - start < timeoutMs) { | |
| const previousResultId = diagnosticResultIds.get(uri) | |
| try { | |
| const res: any = await vueConn.sendRequest("textDocument/diagnostic", { | |
| textDocument: { uri }, | |
| previousResultId | |
| }) | |
| if (res?.resultId) diagnosticResultIds.set(uri, res.resultId) | |
| if (res?.kind === "full" && Array.isArray(res.items) && res.items.length > 0) { | |
| return res.items | |
| } | |
| } catch (e) {} | |
| await new Promise(r => setTimeout(r, 200)) | |
| } | |
| return null | |
| } | |
| async function waitForTsDiagnostics(uri: string, timeoutMs = 5000): Promise<Diagnostic[]> { | |
| const start = Date.now() | |
| while (Date.now() - start < timeoutMs) { | |
| const diags = tsDiagnostics.get(uri) | |
| if (diags && diags.length > 0) return diags | |
| await new Promise(r => setTimeout(r, 100)) | |
| } | |
| return [] | |
| } | |
| describe("Vue LSP v3 ultra-minimal", () => { | |
| beforeAll(async () => { | |
| tempDir = await makeTempProject() | |
| await startServers(tempDir) | |
| }) | |
| afterAll(async () => { | |
| await stopServers() | |
| await fs.rm(tempDir, { recursive: true, force: true }) | |
| }) | |
| test("gets Vue template errors", async () => { | |
| const filePath = path.join(tempDir, "src", "Template.vue") | |
| const fileUri = pathToFileURL(filePath).href | |
| const vueContent = `<template>\n <div>\n</template>\n` | |
| await fs.writeFile(filePath, vueContent, "utf8") | |
| // Open in both servers | |
| const textDocument = { uri: fileUri, languageId: "vue", version: 1, text: vueContent } | |
| vueConn!.sendNotification("textDocument/didOpen", { textDocument }) | |
| tsConn!.sendNotification("textDocument/didOpen", { textDocument }) | |
| // Trigger validation | |
| vueConn!.sendNotification("textDocument/didChange", { | |
| textDocument: { uri: fileUri, version: 2 }, | |
| contentChanges: [{ text: vueContent }] | |
| }) | |
| // Get diagnostics | |
| const diagnostics = await pullDiagnostics(fileUri) | |
| expect(diagnostics).not.toBeNull() | |
| expect(diagnostics!.length).toBeGreaterThan(0) | |
| expect(diagnostics![0].message).toContain("Element is missing end tag") | |
| }, 15000) | |
| test("gets TypeScript errors in .vue files", async () => { | |
| const filePath = path.join(tempDir, "src", "TypeScript.vue") | |
| const fileUri = pathToFileURL(filePath).href | |
| const vueContent = `<template> | |
| <div>{{ message }}</div> | |
| </template> | |
| <script setup lang="ts"> | |
| const message: string = 123 | |
| const unused: number = 456 | |
| function unusedFunction(): void { | |
| const localUnused = "test" | |
| } | |
| interface UnusedInterface { | |
| prop: string | |
| } | |
| </script> | |
| ` | |
| await fs.writeFile(filePath, vueContent, "utf8") | |
| // Open in both servers | |
| const textDocument = { uri: fileUri, languageId: "vue", version: 1, text: vueContent } | |
| vueConn!.sendNotification("textDocument/didOpen", { textDocument }) | |
| tsConn!.sendNotification("textDocument/didOpen", { textDocument }) | |
| // Trigger validation | |
| vueConn!.sendNotification("textDocument/didChange", { | |
| textDocument: { uri: fileUri, version: 2 }, | |
| contentChanges: [{ text: vueContent }] | |
| }) | |
| tsConn!.sendNotification("textDocument/didChange", { | |
| textDocument: { uri: fileUri, version: 2 }, | |
| contentChanges: [{ text: vueContent }] | |
| }) | |
| // Trigger diagnostic refresh | |
| await tsConn!.sendRequest("workspace/diagnostic/refresh").catch(() => {}) | |
| await vueConn!.sendRequest("workspace/diagnostic/refresh").catch(() => {}) | |
| // Wait for TypeScript diagnostics | |
| const diagnostics = await waitForTsDiagnostics(fileUri) | |
| expect(diagnostics.length).toBeGreaterThan(0) | |
| // Check we have type assignment errors | |
| const typeErrors = diagnostics.filter(d => | |
| d.message.includes("Type") && d.message.includes("assignable") | |
| ) | |
| expect(typeErrors.length).toBeGreaterThan(0) | |
| // Check we have unused variable/function warnings | |
| const unusedErrors = diagnostics.filter(d => | |
| d.message.includes("unused") || d.message.includes("never read") || | |
| (typeof d.code === 'number' && (d.code === 6133 || d.code === 6196)) | |
| ) | |
| expect(unusedErrors.length).toBeGreaterThan(0) | |
| }, 15000) | |
| }) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment