374 lines
13 KiB
TypeScript
374 lines
13 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;
|
|
});
|