Files
WRNexusJS/packages/language-server/test/language-server.test.ts
T

424 lines
14 KiB
TypeScript

import { expect, test } from "bun:test";
import { fileURLToPath } from "node:url";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
completionItems,
definitionLocation,
documentDiagnostics,
documentSymbols,
formatDocument,
positionAt,
symbolLocations,
virtualTypeScriptDocument,
workspaceCompletionItems,
clearWorkspaceIndexCache,
extractComponentRefactor,
htmlToWrn,
} from "../src/index.ts";
const document = {
uri: "file:///Counter.wrn",
text: `component Counter {\nstate count = 0\nview { <button @click='count++'>{count}</button> }\n}`,
};
test("provides editor-neutral language features", () => {
expect(documentDiagnostics(document)).toEqual([]);
expect(completionItems().some((item) => item.label === "state")).toBe(true);
expect(documentSymbols(document).map((item) => item.name)).toContain("count");
const reference = positionAt(document.text, document.text.lastIndexOf("count"));
expect(definitionLocation(document, reference)).not.toBeNull();
expect(symbolLocations(document, reference)).toHaveLength(3);
expect(formatDocument(document)).toBeArray();
});
test("indexes workspace components, contracts, routes, translations, schemas and classes", () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-lsp-workspace-"));
try {
for (const directory of ["components", "pages", "locales", "db"])
mkdirSync(join(root, "app", directory), { recursive: true });
mkdirSync(join(root, "app", "pages", "users"), { recursive: true });
writeFileSync(
join(root, "app", "components", "Card.wrn"),
`component Card { props { title: string } outputs { close() } view { <slot name="body" /> } }`,
);
writeFileSync(
join(root, "app", "pages", "users", "[id].wrn"),
`page User { view { <p class="text-red-500">User</p> } }`,
{ flag: "w" },
);
writeFileSync(
join(root, "app", "locales", "en.json"),
JSON.stringify({ user: { title: "User" } }),
);
writeFileSync(join(root, "app", "db", "schema.ts"), `export const UserSchema = {};`);
const labels = workspaceCompletionItems(root).map((item) => item.label);
for (const label of [
"Card",
"title",
"@close",
"slot:body",
"/users/:id",
"user.title",
"UserSchema",
"text-red-500",
])
expect(labels).toContain(label);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("workspace indexing is cached and skips dependency and generated trees", () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-lsp-bounded-"));
try {
mkdirSync(join(root, "app", "pages"), { recursive: true });
mkdirSync(join(root, "app", "node_modules", "large-package"), { recursive: true });
mkdirSync(join(root, "app", ".wrnexus"), { recursive: true });
writeFileSync(join(root, "app", "pages", "index.wrn"), `page Home { view { <p>Home</p> } }`);
writeFileSync(
join(root, "app", "node_modules", "large-package", "secret.ts"),
`export const DependencySchema = {};`,
);
writeFileSync(join(root, "app", ".wrnexus", "generated.ts"), `export const CacheSchema = {};`);
const first = workspaceCompletionItems(root);
const second = workspaceCompletionItems(root);
expect(second).toBe(first);
expect(first.map((item) => item.label)).not.toContain("DependencySchema");
expect(first.map((item) => item.label)).not.toContain("CacheSchema");
writeFileSync(join(root, "app", "pages", "later.wrn"), `page Later { view { <p>Later</p> } }`);
expect(workspaceCompletionItems(root).map((item) => item.label)).not.toContain("/later");
clearWorkspaceIndexCache(root);
expect(workspaceCompletionItems(root).map((item) => item.label)).toContain("/later");
} finally {
clearWorkspaceIndexCache(root);
rmSync(root, { recursive: true, force: true });
}
});
test("provides safe component extraction and HTML conversion refactors", () => {
const doc = {
uri: "file:///project/app/pages/index.wrn",
version: 2,
text: "<section>Hello</section>",
};
const range = { start: { line: 0, character: 0 }, end: { line: 0, character: doc.text.length } };
const edit = extractComponentRefactor(doc, range, "Greeting");
expect(edit.documentChanges).toHaveLength(3);
expect(JSON.stringify(edit)).toContain("<Greeting />");
expect(htmlToWrn(`<button onclick="save()">Save</button>`, "Imported")).toContain(
`@click="save()"`,
);
});
test("provides a mapped TypeScript virtual document and expression type diagnostics", () => {
const typed = {
uri: "file:///Typed.wrn",
text: `component Typed {\nprops { count: number }\nstate label: string = count\nview { <p>{label}</p> }\n}`,
};
const virtual = virtualTypeScriptDocument(typed);
expect(virtual.languageId).toBe("typescript");
expect(virtual.uri).toEndWith(".wrn.ts");
expect(virtual.text).toContain("declare const count: Readonly<number>");
expect(virtual.mappings.length).toBeGreaterThan(0);
expect(documentDiagnostics(typed).some((item) => String(item.code).startsWith("WRN-TYPE-"))).toBe(
true,
);
});
test("reports compiler and accessibility diagnostics", () => {
const diagnostics = documentDiagnostics({
uri: "file:///bad.wrn",
text: `page Bad {\n view {\n <img src='/x.png' />\n }\n}`,
});
expect(diagnostics.some((item) => String(item.code).startsWith("WRN-A11Y"))).toBe(true);
});
test("skips compiler graph creation for oversized WRN documents", () => {
const diagnostics = documentDiagnostics({
uri: "file:///large.wrn",
text: `page Large { view { <p>${"x".repeat(1_048_576)}</p> } }`,
});
expect(diagnostics).toHaveLength(1);
expect(diagnostics[0]?.code).toBe("WRN-LSP-FILE-SIZE");
});
function packet(value: unknown): Uint8Array {
const body = JSON.stringify(value);
return new TextEncoder().encode(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
}
test("serves initialize over standard LSP stdio framing", async () => {
const process = Bun.spawn(
["bun", "run", fileURLToPath(new URL("../src/server.ts", import.meta.url))],
{
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
},
);
process.stdin.write(packet({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }));
await process.stdin.flush();
const reader = process.stdout.getReader();
let output = "";
while (!output.includes('"id":1')) {
const chunk = await reader.read();
if (chunk.done) break;
output += new TextDecoder().decode(chunk.value);
}
expect(output).toContain("WRNexus Language Server");
expect(output).toContain("documentFormattingProvider");
expect(output).toContain("wrnexusVirtualTypeScript");
process.kill();
await process.exited;
});
test("coalesces rapid document changes into one pending diagnostic analysis", async () => {
const process = Bun.spawn(
["bun", "run", fileURLToPath(new URL("../src/server.ts", import.meta.url))],
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
);
const uri = "file:///rapid.wrn";
process.stdin.write(
packet({
jsonrpc: "2.0",
method: "textDocument/didOpen",
params: { textDocument: { uri, version: 1, text: `page Rapid { view { <p>1</p> } }` } },
}),
);
for (let version = 2; version <= 50; version++) {
process.stdin.write(
packet({
jsonrpc: "2.0",
method: "textDocument/didChange",
params: {
textDocument: { uri, version },
contentChanges: [{ text: `page Rapid { view { <p>${version}</p> } }` }],
},
}),
);
}
process.stdin.write(
packet({ jsonrpc: "2.0", id: 99, method: "wrnexus/serverStatus", params: {} }),
);
await process.stdin.flush();
const reader = process.stdout.getReader();
let output = "";
while (!output.includes('"id":99')) {
const chunk = await reader.read();
if (chunk.done) break;
output += new TextDecoder().decode(chunk.value);
}
expect(output).toContain('"pendingDiagnostics":1');
expect(output).toContain('"openDocuments":1');
process.kill();
await process.exited;
});
test("didClose drops the region cache so a reopened document at the same version is rescanned", async () => {
// Regression coverage for the didClose wiring in server.ts: without the
// clearHtmlRegionCache(uri) call there, a document that closes and reopens
// at version 1 (a common restart point) matches the stale cache entry from
// the prior session. Open first WITHOUT a view block (caching "no HTML
// here" for this uri/version), close, then reopen the SAME uri at the SAME
// version WITH a view block covering the same offset: correct behaviour
// rescans and finds it, a stale cache still says "no HTML here" and
// suppresses the completions entirely.
const process = Bun.spawn(
["bun", "run", fileURLToPath(new URL("../src/server.ts", import.meta.url))],
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
);
const uri = "file:///reopen.wrn";
const withoutView = `page A {
functions {
x
}
}
`;
const withView = `page A {
view {
<
}
}
`;
const position = { line: 2, character: 5 };
const reader = process.stdout.getReader();
let output = "";
async function readUntil(marker: string): Promise<void> {
while (!output.includes(marker)) {
const chunk = await reader.read();
if (chunk.done) break;
output += new TextDecoder().decode(chunk.value);
}
}
process.stdin.write(packet({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }));
await process.stdin.flush();
await readUntil('"id":1');
process.stdin.write(
packet({
jsonrpc: "2.0",
method: "textDocument/didOpen",
params: { textDocument: { uri, version: 1, text: withoutView } },
}),
);
process.stdin.write(
packet({
jsonrpc: "2.0",
id: 2,
method: "textDocument/completion",
params: { textDocument: { uri }, position },
}),
);
await process.stdin.flush();
await readUntil('"id":2');
const firstReply = output.slice(output.indexOf('"id":2'));
expect(firstReply).not.toContain('"label":"div"');
process.stdin.write(
packet({ jsonrpc: "2.0", method: "textDocument/didClose", params: { textDocument: { uri } } }),
);
process.stdin.write(
packet({
jsonrpc: "2.0",
method: "textDocument/didOpen",
params: { textDocument: { uri, version: 1, text: withView } },
}),
);
process.stdin.write(
packet({
jsonrpc: "2.0",
id: 3,
method: "textDocument/completion",
params: { textDocument: { uri }, position },
}),
);
await process.stdin.flush();
await readUntil('"id":3');
const secondReply = output.slice(output.indexOf('"id":3'));
expect(secondReply).toContain('"label":"div"');
process.kill();
await process.exited;
});
test("advertises and serves folding ranges and linked editing ranges over the wire", async () => {
const process = Bun.spawn(
["bun", "run", fileURLToPath(new URL("../src/server.ts", import.meta.url))],
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
);
const uri = "file:///fold.wrn";
const text = `page A {
view {
<div>x</div>
}
}
`;
const reader = process.stdout.getReader();
let output = "";
async function readUntil(marker: string): Promise<void> {
while (!output.includes(marker)) {
const chunk = await reader.read();
if (chunk.done) break;
output += new TextDecoder().decode(chunk.value);
}
}
process.stdin.write(packet({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }));
await process.stdin.flush();
await readUntil('"id":1');
const initReply = output.slice(output.indexOf('"id":1'));
expect(initReply).toContain('"foldingRangeProvider":true');
expect(initReply).toContain('"linkedEditingRangeProvider":true');
process.stdin.write(
packet({
jsonrpc: "2.0",
method: "textDocument/didOpen",
params: { textDocument: { uri, version: 1, text } },
}),
);
process.stdin.write(
packet({
jsonrpc: "2.0",
id: 2,
method: "textDocument/foldingRange",
params: { textDocument: { uri } },
}),
);
await process.stdin.flush();
await readUntil('"id":2');
const foldReply = output.slice(output.indexOf('"id":2'));
expect(foldReply).toContain('"result":[');
process.stdin.write(
packet({
jsonrpc: "2.0",
id: 3,
method: "textDocument/linkedEditingRange",
params: { textDocument: { uri }, position: { line: 2, character: 6 } },
}),
);
await process.stdin.flush();
await readUntil('"id":3');
const linkedReply = output.slice(output.indexOf('"id":3'));
expect(linkedReply).toContain('"ranges"');
process.kill();
await process.exited;
});
test("serves wrn/tagComplete over the wire", async () => {
const process = Bun.spawn(
["bun", "run", fileURLToPath(new URL("../src/server.ts", import.meta.url))],
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
);
const uri = "file:///tag-complete.wrn";
const text = `page A {
view {
<div>
}
}
`;
const reader = process.stdout.getReader();
let output = "";
async function readUntil(marker: string): Promise<void> {
while (!output.includes(marker)) {
const chunk = await reader.read();
if (chunk.done) break;
output += new TextDecoder().decode(chunk.value);
}
}
process.stdin.write(packet({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }));
await process.stdin.flush();
await readUntil('"id":1');
process.stdin.write(
packet({
jsonrpc: "2.0",
method: "textDocument/didOpen",
params: { textDocument: { uri, version: 1, text } },
}),
);
process.stdin.write(
packet({
jsonrpc: "2.0",
id: 2,
method: "wrn/tagComplete",
params: { textDocument: { uri }, position: { line: 2, character: 9 } },
}),
);
await process.stdin.flush();
await readUntil('"id":2');
const reply = output.slice(output.indexOf('"id":2'));
expect(reply).toContain('"result":"$0</div>"');
process.kill();
await process.exited;
});