import { expect, test } from "bun:test"; import { fileURLToPath } from "node:url"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { positionAt } from "../src/index.ts"; // End-to-end verification (Task 8): drives the real language server, over // real LSP stdio framing, against a realistic WRN page fixture. The page's // text is read from disk rather than duplicated here, so the fixture remains // reusable and independently inspectable. const pagePath = join( fileURLToPath(new URL(".", import.meta.url)), "fixtures/html-editing-check.wrn", ); const pageText = readFileSync(pagePath, "utf8"); function packet(value: unknown): Uint8Array { const body = JSON.stringify(value); return new TextEncoder().encode(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`); } async function withServer( run: ( send: (msg: unknown) => Promise, readUntil: (marker: string) => Promise, ) => Promise, ): Promise { const process = Bun.spawn( ["bun", "run", fileURLToPath(new URL("../src/server.ts", import.meta.url))], { stdin: "pipe", stdout: "pipe", stderr: "pipe" }, ); const reader = process.stdout.getReader(); let output = ""; async function readUntil(marker: string): Promise { while (!output.includes(marker)) { const chunk = await reader.read(); if (chunk.done) break; output += new TextDecoder().decode(chunk.value); } return output.slice(output.indexOf(marker)); } async function send(message: unknown): Promise { process.stdin.write(packet(message)); await process.stdin.flush(); } try { return await run(send, readUntil); } finally { process.kill(); await process.exited; } } test("HTML editor support works end-to-end against the real scratch page", async () => { const uri = "file:///html-editing-check.wrn"; // Positions derived from the real file content, not hardcoded line/column // literals, so the test tracks the page if it changes. const viewOpenBrace = pageText.indexOf("view {"); const viewCloseBrace = pageText.indexOf("}", pageText.indexOf("")); const h1TagOffset = pageText.indexOf("

") + 1; // inside "h1" const mainOpenNameOffset = pageText.indexOf("
") + 1; // inside "main" opening tag name const mainOpenTagEndOffset = pageText.indexOf("
") + "
".length; // just after '>' const seoTitleOffset = pageText.indexOf('title = "HTML editing check"'); // inside the seo block // Simulate typing "<" inside the seo {} block: insert the character into a // copy of the real page text (the checked-in file itself is never // mutated) and ask for completion right after it. This is the scenario // that actually exercises the view-region guard: if the guard were // defeated, this exact position is where the HTML language service would // offer tag completions, because it is positioned directly after an // unclosed "<". const seoInjectedText = pageText.slice(0, seoTitleOffset) + "<" + pageText.slice(seoTitleOffset); const seoInjectedPosition = positionAt(seoInjectedText, seoTitleOffset + 1); const viewPosition = positionAt(pageText, h1TagOffset); const linkedEditingPosition = positionAt(pageText, mainOpenNameOffset); const tagCompletePosition = positionAt(pageText, mainOpenTagEndOffset); const viewStartLine = positionAt(pageText, viewOpenBrace).line; const viewEndLine = positionAt(pageText, viewCloseBrace).line; await withServer(async (send, readUntil) => { await send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); await readUntil('"id":1'); await send({ jsonrpc: "2.0", method: "textDocument/didOpen", params: { textDocument: { uri, version: 1, text: pageText } }, }); // --- Completion inside view {} : HTML entries present, WRNexus sorts first --- await send({ jsonrpc: "2.0", id: 2, method: "textDocument/completion", params: { textDocument: { uri }, position: viewPosition }, }); const completionReply = await readUntil('"id":2'); // An HTML tag entry is present, tagged with the "1" (below-wrnexus) sortText prefix. expect(completionReply).toMatch(/"label":"div"[^}]*"sortText":"1/); // A WRNexus keyword entry is present, tagged with the "0" (above-html) sortText prefix. expect(completionReply).toMatch(/"label":"page"[^}]*"sortText":"0/); // No html-prefixed sortText is lexicographically smaller than any wrnexus one: // every "0..." sortText must precede every "1..." sortText, which is exactly // what makes WRNexus entries render above HTML ones in an editor. expect(completionReply).not.toMatch(/"sortText":"1[^"]*"[\s\S]*"sortText":"0/); // --- Negative case: completion right after "<" typed inside seo {} must NOT include HTML entries --- const seoUri = "file:///html-editing-check-seo-inject.wrn"; await send({ jsonrpc: "2.0", method: "textDocument/didOpen", params: { textDocument: { uri: seoUri, version: 1, text: seoInjectedText } }, }); await send({ jsonrpc: "2.0", id: 3, method: "textDocument/completion", params: { textDocument: { uri: seoUri }, position: seoInjectedPosition }, }); const seoReply = await readUntil('"id":3'); expect(seoReply).not.toContain('"label":"div"'); expect(seoReply).not.toContain('"label":"span"'); expect(seoReply).not.toContain('"label":"h1"'); expect(seoReply).not.toMatch(/"sortText":"1/); // --- Hover over a tag inside the view block returns documentation --- await send({ jsonrpc: "2.0", id: 4, method: "textDocument/hover", params: { textDocument: { uri }, position: viewPosition }, }); const hoverReply = await readUntil('"id":4'); expect(hoverReply).toContain('"contents"'); expect(hoverReply).not.toMatch(/"result":\s*null/); // --- Folding ranges are returned and all lie within the view block --- await send({ jsonrpc: "2.0", id: 5, method: "textDocument/foldingRange", params: { textDocument: { uri } }, }); const foldReply = await readUntil('"id":5'); expect(foldReply).toContain('"result":['); const foldBody = foldReply.slice(foldReply.indexOf('"result":[')); const foldRanges = [...foldBody.matchAll(/"startLine":(\d+),"endLine":(\d+)/g)]; expect(foldRanges.length).toBeGreaterThan(0); for (const [, start, end] of foldRanges) { expect(Number(start)).toBeGreaterThanOrEqual(viewStartLine); expect(Number(end)).toBeLessThanOrEqual(viewEndLine); } // --- Linked editing at the
opening tag name returns two ranges --- await send({ jsonrpc: "2.0", id: 6, method: "textDocument/linkedEditingRange", params: { textDocument: { uri }, position: linkedEditingPosition }, }); const linkedReply = await readUntil('"id":6'); expect(linkedReply).toContain('"ranges"'); const rangeCount = (linkedReply.match(/"start":\{/g) ?? []).length; expect(rangeCount).toBe(2); // --- wrn/tagComplete after
returns the closing snippet --- await send({ jsonrpc: "2.0", id: 7, method: "wrn/tagComplete", params: { textDocument: { uri }, position: tagCompletePosition }, }); const tagCompleteReply = await readUntil('"id":7'); expect(tagCompleteReply).toContain('"result":"$0
"'); // --- wrn/tagComplete for a void element (
) returns null --- const voidText = `page A {\n view {\n
\n }\n}\n`; const voidUri = "file:///html-editing-check-void.wrn"; await send({ jsonrpc: "2.0", method: "textDocument/didOpen", params: { textDocument: { uri: voidUri, version: 1, text: voidText } }, }); const brOffset = voidText.indexOf("
") + "
".length; const brPosition = positionAt(voidText, brOffset); await send({ jsonrpc: "2.0", id: 8, method: "wrn/tagComplete", params: { textDocument: { uri: voidUri }, position: brPosition }, }); const voidReply = await readUntil('"id":8'); expect(voidReply).toContain('"result":null'); }); });