Skip to content

Instantly share code, notes, and snippets.

@drevantonder
Last active August 20, 2025 00:44
Show Gist options
  • Select an option

  • Save drevantonder/29f70c9629e9234866285c2dab98e021 to your computer and use it in GitHub Desktop.

Select an option

Save drevantonder/29f70c9629e9234866285c2dab98e021 to your computer and use it in GitHub Desktop.
A minimal test for vue LSP with typescript-language-server
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