diff --git a/examples/basic-app/app/pages/html-editing-check.wrn b/examples/basic-app/app/pages/html-editing-check.wrn new file mode 100644 index 00000000..6e0f4437 --- /dev/null +++ b/examples/basic-app/app/pages/html-editing-check.wrn @@ -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 { +
+

Editor check

+
+ } +} diff --git a/examples/basic-app/app/routes.gen.ts b/examples/basic-app/app/routes.gen.ts index 86898346..6ec53fbb 100644 --- a/examples/basic-app/app/routes.gen.ts +++ b/examples/basic-app/app/routes.gen.ts @@ -9,6 +9,7 @@ export interface Routes { "/client-only": Record; "/dashboard": Record; "/hello": Record; + "/html-editing-check": Record; "/island-demo": Record; "/language-tools": Record; "/layout": Record; @@ -32,6 +33,7 @@ export interface RouteNames { "client.only": "/client-only"; "dashboard": "/dashboard"; "hello": "/hello"; + "html.editing.check": "/html-editing-check"; "island.demo": "/island-demo"; "language.tools": "/language-tools"; "layout": "/layout"; @@ -124,6 +126,7 @@ export function route( "client.only": "/client-only", "dashboard": "/dashboard", "hello": "/hello", + "html.editing.check": "/html-editing-check", "island.demo": "/island-demo", "language.tools": "/language-tools", "layout": "/layout", diff --git a/examples/basic-app/app/types/wrnexus.generated.d.ts b/examples/basic-app/app/types/wrnexus.generated.d.ts index 537a1c99..afb9e21a 100644 --- a/examples/basic-app/app/types/wrnexus.generated.d.ts +++ b/examples/basic-app/app/types/wrnexus.generated.d.ts @@ -13,7 +13,7 @@ declare namespace WRNexusGenerated { : never; type RealtimeMessage = T extends import("@wrnexus/core").RoomDefinition ? M : unknown; type QueuePayload = T extends import("@wrnexus/queue").JobDefinition ? 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 RealtimeRoute = "/realtime/chat" | "/realtime/hello"; type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY"; diff --git a/packages/language-server/test/html-editing-e2e.test.ts b/packages/language-server/test/html-editing-e2e.test.ts new file mode 100644 index 00000000..70e8c424 --- /dev/null +++ b/packages/language-server/test/html-editing-e2e.test.ts @@ -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( + 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'); + }); +});