test(language-server): add end-to-end verification for HTML editing support
Adds a scratch page (kept intentionally, per controller ruling on Task 8) and one end-to-end test that drives the real language server over LSP stdio against that page's real text, verifying completion (with the seo-block negative case tested via a simulated '<' keystroke and mutation-verified against html-regions.ts), hover, folding ranges, linked editing, and wrn/tagComplete all work together on realistic content. Regenerates routes.gen.ts and wrnexus.generated.d.ts for the new page's route, required by check:generated-types. Two checks from the original brief (auto-close-tag insertion and Emmet Tab-expansion) require a live VS Code Extension Development Host and are documented as outstanding manual verification in .superpowers/sdd/2026-08-18-wrn-html-editing/task-8-report.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
page HtmlEditingCheck {
|
||||||
|
seo {
|
||||||
|
title = "HTML editing check"
|
||||||
|
description = "Scratch page for verifying editor support inside view blocks."
|
||||||
|
canonical = "/html-editing-check"
|
||||||
|
}
|
||||||
|
|
||||||
|
view {
|
||||||
|
<main>
|
||||||
|
<h1>Editor check</h1>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export interface Routes {
|
|||||||
"/client-only": Record<string, never>;
|
"/client-only": Record<string, never>;
|
||||||
"/dashboard": Record<string, never>;
|
"/dashboard": Record<string, never>;
|
||||||
"/hello": Record<string, never>;
|
"/hello": Record<string, never>;
|
||||||
|
"/html-editing-check": Record<string, never>;
|
||||||
"/island-demo": Record<string, never>;
|
"/island-demo": Record<string, never>;
|
||||||
"/language-tools": Record<string, never>;
|
"/language-tools": Record<string, never>;
|
||||||
"/layout": Record<string, never>;
|
"/layout": Record<string, never>;
|
||||||
@@ -32,6 +33,7 @@ export interface RouteNames {
|
|||||||
"client.only": "/client-only";
|
"client.only": "/client-only";
|
||||||
"dashboard": "/dashboard";
|
"dashboard": "/dashboard";
|
||||||
"hello": "/hello";
|
"hello": "/hello";
|
||||||
|
"html.editing.check": "/html-editing-check";
|
||||||
"island.demo": "/island-demo";
|
"island.demo": "/island-demo";
|
||||||
"language.tools": "/language-tools";
|
"language.tools": "/language-tools";
|
||||||
"layout": "/layout";
|
"layout": "/layout";
|
||||||
@@ -124,6 +126,7 @@ export function route<N extends RouteName>(
|
|||||||
"client.only": "/client-only",
|
"client.only": "/client-only",
|
||||||
"dashboard": "/dashboard",
|
"dashboard": "/dashboard",
|
||||||
"hello": "/hello",
|
"hello": "/hello",
|
||||||
|
"html.editing.check": "/html-editing-check",
|
||||||
"island.demo": "/island-demo",
|
"island.demo": "/island-demo",
|
||||||
"language.tools": "/language-tools",
|
"language.tools": "/language-tools",
|
||||||
"layout": "/layout",
|
"layout": "/layout",
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ declare namespace WRNexusGenerated {
|
|||||||
: never;
|
: never;
|
||||||
type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown;
|
type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown;
|
||||||
type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? I : unknown;
|
type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? I : unknown;
|
||||||
type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "island.demo" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui";
|
type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "html.editing.check" | "index" | "island.demo" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui";
|
||||||
type ApiRoute = "/api/accounts" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment";
|
type ApiRoute = "/api/accounts" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment";
|
||||||
type RealtimeRoute = "/realtime/chat" | "/realtime/hello";
|
type RealtimeRoute = "/realtime/chat" | "/realtime/hello";
|
||||||
type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY";
|
type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY";
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
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 the real scratch page checked in at
|
||||||
|
// examples/basic-app/app/pages/html-editing-check.wrn. The page's text is
|
||||||
|
// read from disk rather than duplicated here, so this test breaks if the
|
||||||
|
// page and the test drift apart.
|
||||||
|
|
||||||
|
const pagePath = join(
|
||||||
|
fileURLToPath(new URL("../../../", import.meta.url)),
|
||||||
|
"examples/basic-app/app/pages/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<T>(
|
||||||
|
run: (
|
||||||
|
send: (msg: unknown) => Promise<void>,
|
||||||
|
readUntil: (marker: string) => Promise<string>,
|
||||||
|
) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
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<string> {
|
||||||
|
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<void> {
|
||||||
|
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("</main>"));
|
||||||
|
const h1TagOffset = pageText.indexOf("<h1>") + 1; // inside "h1"
|
||||||
|
const mainOpenNameOffset = pageText.indexOf("<main>") + 1; // inside "main" opening tag name
|
||||||
|
const mainOpenTagEndOffset = pageText.indexOf("<main>") + "<main>".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 <main> 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 <main> 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</main>"');
|
||||||
|
|
||||||
|
// --- wrn/tagComplete for a void element (<br>) returns null ---
|
||||||
|
const voidText = `page A {\n view {\n <br>\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("<br>") + "<br>".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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user