312 lines
13 KiB
TypeScript
312 lines
13 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import { renderDocument, renderDocumentStream, renderStoreHydration } from "../src/index.ts";
|
|
import type { StoreContainer } from "@wrnexus/store";
|
|
import { runWithRequestContext, encodeControlTemplate } from "@wrnexus/core";
|
|
|
|
test("inactive hydration branches share nonce-tagged styles without changing registry source", () => {
|
|
runWithRequestContext({} as never, () => {
|
|
const css = ".review{color:blue}".repeat(500);
|
|
const style = `<style data-wrnexus-style-id="review" data-wrnexus-style-owner="Review" data-wrnexus-style-kind="component">${css}</style>`;
|
|
const reference = encodeControlTemplate([
|
|
{ cond: "open", body: `${style.repeat(30)}<button>Review</button>` },
|
|
]);
|
|
const options = {
|
|
meta: {},
|
|
body: `<template data-wrn-if="${reference}"></template>${style}`,
|
|
styleNonce: "review-nonce",
|
|
};
|
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
const html = renderDocument(options);
|
|
expect(html.match(/data-wrnexus-style-id="review"/g)).toHaveLength(1);
|
|
expect(html).toContain('nonce="review-nonce"');
|
|
expect(html.indexOf(css)).toBeLessThan(html.indexOf("</head>"));
|
|
const definitions = JSON.parse(/data-wrn-controls[^>]*>(.*?)<\/script>/s.exec(html)![1]!);
|
|
expect(definitions[0][0].body).toBe("<button>Review</button>");
|
|
expect(html.length).toBeLessThan(css.length + 2000);
|
|
}
|
|
const inactiveOnly = renderDocument({
|
|
meta: {},
|
|
body: `<template data-wrn-if="${reference}"></template>`,
|
|
});
|
|
expect(inactiveOnly).toContain(css);
|
|
});
|
|
});
|
|
|
|
test("streamed documents include templates registered by late body chunks", async () => {
|
|
await runWithRequestContext({} as never, async () => {
|
|
async function* body() {
|
|
yield "<p>Shell</p>";
|
|
await Promise.resolve();
|
|
yield `<template data-wrn-if="${encodeControlTemplate([{ cond: null, body: "Late" }])}"></template>`;
|
|
}
|
|
const html = await new Response(renderDocumentStream({ meta: {}, body: body() })).text();
|
|
expect(html).toContain("data-wrn-controls");
|
|
expect(html).toContain('"body":"Late"');
|
|
expect(html.match(/data-wrn-controls/g)).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
test("store hydration is HTML-safe, bounded, redacted, and JSON-compatible", () => {
|
|
const container = {
|
|
serialize: () => ({
|
|
ProfileStore: {
|
|
display: "</script><script>alert(1)</script>",
|
|
accessToken: "never-render-this",
|
|
},
|
|
}),
|
|
} as unknown as StoreContainer;
|
|
const html = renderStoreHydration(container, 'safe" onload="bad');
|
|
expect(html).not.toContain("</script><script>");
|
|
expect(html).not.toContain("never-render-this");
|
|
expect(html).toContain('nonce="safe" onload="bad"');
|
|
const payload = html.match(/>(.*)<\/script>$/)?.[1];
|
|
expect(JSON.parse(payload!)).toEqual({
|
|
ProfileStore: { display: "</script><script>alert(1)</script>", accessToken: "[REDACTED]" },
|
|
});
|
|
|
|
const oversized = {
|
|
serialize: () => ({ value: "x".repeat(300_000) }),
|
|
} as unknown as StoreContainer;
|
|
expect(() => renderStoreHydration(oversized)).toThrow("exceeds 262144 bytes");
|
|
});
|
|
|
|
test("escapes metadata and script URLs while preserving trusted rendered body", () => {
|
|
const html = renderDocument({
|
|
meta: { title: '</title><script>alert("x")</script>', description: '" onload="x' },
|
|
body: "<main>trusted</main>",
|
|
scripts: ['"><script>alert(1)</script>'],
|
|
});
|
|
expect(html).not.toContain('</title><script>alert("x")</script>');
|
|
expect(html).toContain("</title>");
|
|
expect(html).toContain("<main>trusted</main>");
|
|
expect(html).not.toContain("<script>alert(1)</script>");
|
|
});
|
|
|
|
test("deduplicates runtime scripts and module preloads", () => {
|
|
const html = renderDocument({
|
|
meta: { title: "Page" },
|
|
body: "",
|
|
scripts: ["/app.js", "/app.js"],
|
|
});
|
|
expect(html.match(/rel="modulepreload"/g)).toHaveLength(1);
|
|
expect(html.match(/src="\/app\.js"/g)).toHaveLength(1);
|
|
});
|
|
|
|
test("always emits a document language and preserves an explicit language", () => {
|
|
const fallback = renderDocument({ meta: {}, body: "" });
|
|
expect(fallback).toContain('<html lang="en">');
|
|
|
|
const explicit = renderDocument({
|
|
meta: {},
|
|
body: "",
|
|
htmlAttrs: ' data-theme="dark" lang="fr"',
|
|
});
|
|
expect(explicit).toContain('<html data-theme="dark" lang="fr">');
|
|
expect(explicit).not.toContain('lang="en"');
|
|
});
|
|
|
|
test("merges framework head and scripts into an application document template", () => {
|
|
const html = renderDocument({
|
|
meta: { title: "Document layout" },
|
|
body: "",
|
|
htmlAttrs: ' data-theme="dark"',
|
|
scripts: ["/page.js"],
|
|
documentTemplate:
|
|
'<html data-ui-font="system"><head><meta name="custom" content="yes" /></head><body><div id="app">Page</div></body></html>',
|
|
});
|
|
expect(html).toStartWith("<!doctype html>");
|
|
expect(html).toContain('<html data-ui-font="system" data-theme="dark" lang="en">');
|
|
expect(html).toContain("<title>Document layout</title>");
|
|
expect(html).toContain('<meta name="custom" content="yes" />');
|
|
expect(html).toContain('<script type="module" src="/page.js"></script>');
|
|
expect(html.match(/<html/g)).toHaveLength(1);
|
|
expect(html.match(/<body/g)).toHaveLength(1);
|
|
});
|
|
|
|
test("streams async body chunks inside the document shell", async () => {
|
|
async function* body() {
|
|
yield "<h1>Shell</h1>";
|
|
yield "<p>Later</p>";
|
|
}
|
|
const html = await new Response(
|
|
renderDocumentStream({ meta: { title: "Stream" }, body: body() }),
|
|
).text();
|
|
expect(html).toContain('<div id="app"><h1>Shell</h1><p>Later</p></div>');
|
|
});
|
|
|
|
test("renders rich script metadata and deduplicates by source", () => {
|
|
const html = renderDocument({
|
|
meta: { title: "Runtime" },
|
|
body: '<main data-wrnexus-runtime="captcha"></main>',
|
|
scripts: [
|
|
{
|
|
src: "/__wrnexus/assets/captcha.abc.js",
|
|
type: "classic",
|
|
defer: true,
|
|
integrity: "sha256-test",
|
|
crossOrigin: "anonymous",
|
|
attributes: { "data-wrnexus-runtime-src": "captcha" },
|
|
},
|
|
{ src: "/__wrnexus/assets/captcha.abc.js", type: "classic" },
|
|
],
|
|
});
|
|
expect(html.match(/captcha\.abc\.js/g)).toHaveLength(1);
|
|
expect(html).toContain("defer");
|
|
expect(html).toContain('integrity="sha256-test"');
|
|
expect(html).toContain('crossorigin="anonymous"');
|
|
expect(html).toContain('data-wrnexus-runtime-src="captcha"');
|
|
expect(html).not.toContain('rel="modulepreload" href="/__wrnexus/assets/captcha.abc.js"');
|
|
});
|
|
|
|
test("promotes WRN style blocks into head after global CSS with CSP nonce", () => {
|
|
const html = renderDocument({
|
|
meta: { title: "Styled" },
|
|
extraHead: '<link rel="stylesheet" href="/global.css" />',
|
|
styleNonce: "nonce-123",
|
|
body:
|
|
'<style data-wrnexus-style="Card" data-wrnexus-style-id="card" data-wrnexus-style-kind="component">.shared{color:blue}</style>' +
|
|
'<style data-wrnexus-style="Page" data-wrnexus-style-id="page" data-wrnexus-style-kind="page">.shared{color:red}</style>' +
|
|
'<style data-wrnexus-style="Layout" data-wrnexus-style-id="layout" data-wrnexus-style-kind="layout">.shared{color:green}</style>' +
|
|
'<main class="shared">Styled</main>',
|
|
});
|
|
|
|
expect(html).not.toContain('<div id="app"><style');
|
|
expect(html).toContain('<main class="shared">Styled</main>');
|
|
expect(html.match(/data-wrnexus-style-id=/g)).toHaveLength(3);
|
|
expect(html.match(/nonce="nonce-123"/g)).toHaveLength(3);
|
|
|
|
const globalIndex = html.indexOf("/global.css");
|
|
const layoutIndex = html.indexOf('data-wrnexus-style-id="layout"');
|
|
const pageIndex = html.indexOf('data-wrnexus-style-id="page"');
|
|
const componentIndex = html.indexOf('data-wrnexus-style-id="card"');
|
|
expect(globalIndex).toBeLessThan(layoutIndex);
|
|
expect(layoutIndex).toBeLessThan(pageIndex);
|
|
expect(pageIndex).toBeLessThan(componentIndex);
|
|
});
|
|
|
|
test("deduplicates repeated component style blocks", () => {
|
|
const style =
|
|
'<style data-wrnexus-style="Card" data-wrnexus-style-id="card" data-wrnexus-style-kind="component">.card{display:grid}</style>';
|
|
const html = renderDocument({
|
|
meta: { title: "Repeated" },
|
|
body: `${style}<article>One</article>${style}<article>Two</article>`,
|
|
});
|
|
|
|
expect(html.match(/data-wrnexus-style-id="card"/g)).toHaveLength(1);
|
|
expect(html).toContain("<article>One</article><article>Two</article>");
|
|
});
|
|
|
|
test("places document-layout WRN styles at the end of head", () => {
|
|
const html = renderDocument({
|
|
meta: { title: "Document styles" },
|
|
styleNonce: "document-nonce",
|
|
body: "",
|
|
documentTemplate:
|
|
'<html><head><link rel="stylesheet" href="/document-global.css" /></head><body>' +
|
|
'<style data-wrnexus-style="Document" data-wrnexus-style-id="document-layout" data-wrnexus-style-kind="layout">.shell{display:block}</style>' +
|
|
'<div id="app" class="shell">Page</div></body></html>',
|
|
});
|
|
|
|
const globalIndex = html.indexOf("/document-global.css");
|
|
const styleIndex = html.indexOf('data-wrnexus-style-id="document-layout"');
|
|
const headEnd = html.indexOf("</head>");
|
|
expect(globalIndex).toBeLessThan(styleIndex);
|
|
expect(styleIndex).toBeLessThan(headEnd);
|
|
expect(html).not.toContain("<body><style");
|
|
});
|
|
|
|
test("repeated behavior descriptors are shared across body and inactive templates", () => {
|
|
runWithRequestContext({} as never, () => {
|
|
const behavior = {
|
|
functions: "function increment(){count++}",
|
|
outputs: [],
|
|
computed: [],
|
|
effects: [],
|
|
lifecycle: {},
|
|
watches: [],
|
|
};
|
|
const encoded = Buffer.from(JSON.stringify(behavior)).toString("base64");
|
|
const instance = `<div data-scope="count: 0" data-wrn-behavior="${encoded}"><button>Increment</button></div>`;
|
|
const reference = encodeControlTemplate([{ cond: "open", body: instance.repeat(100) }]);
|
|
const document = renderDocument({
|
|
meta: {},
|
|
body: instance.repeat(100) + `<template data-wrn-if="${reference}"></template>`,
|
|
});
|
|
const definitions = JSON.parse(/data-wrn-controls[^>]*>(.*?)<\/script>/s.exec(document)![1]!);
|
|
expect(definitions.filter((entry: any) => entry.functions)).toHaveLength(1);
|
|
expect(document).not.toContain(`data-wrn-behavior="${encoded}"`);
|
|
expect(document.match(/data-wrn-behavior="@/g)?.length).toBe(100);
|
|
expect(definitions[0][0].body.match(/data-wrn-behavior="@/g)).toHaveLength(100);
|
|
expect(document.length).toBeLessThan(instance.length * 200);
|
|
});
|
|
});
|
|
|
|
test("old client navigation keeps inline behavior while capable clients use references", () => {
|
|
const encoded = Buffer.from(
|
|
JSON.stringify({ functions: "function increment(){count++}" }),
|
|
).toString("base64");
|
|
for (const capable of [false, true])
|
|
runWithRequestContext(
|
|
{
|
|
req: new Request("https://example.test/next", {
|
|
headers: {
|
|
"x-wrnexus-nav": "1",
|
|
...(capable ? { "x-wrnexus-behaviors": "registry-v1" } : {}),
|
|
},
|
|
}),
|
|
} as never,
|
|
() => {
|
|
const html = renderDocument({
|
|
meta: {},
|
|
body: `<div data-scope="count: 0" data-wrn-behavior="${encoded}"></div>`,
|
|
});
|
|
expect(html.includes(`data-wrn-behavior="${encoded}"`)).toBe(!capable);
|
|
expect(html.includes('data-wrn-behavior="@')).toBe(capable);
|
|
},
|
|
);
|
|
});
|
|
|
|
test("scope and loop metadata share a request value without duplicating fallback declarations", () => {
|
|
const encoded = Buffer.from(
|
|
JSON.stringify({ items: Array.from({ length: 30 }, (_, i) => ({ label: "Menu " + i })) }),
|
|
).toString("base64");
|
|
for (const mode of ["registry-v1", "registry-v2"])
|
|
runWithRequestContext(
|
|
{
|
|
req: new Request("https://example.test/", {
|
|
headers: { "x-wrnexus-nav": "1", "x-wrnexus-behaviors": mode },
|
|
}),
|
|
} as never,
|
|
() => {
|
|
const node = `<div data-scope="items: []" data-wrn-scope="${encoded}"><button data-wrn-loop-locals="${encoded}">Menu</button></div>`;
|
|
const html = renderDocument({ meta: {}, body: node.repeat(50) });
|
|
if (mode === "registry-v1") {
|
|
expect(html).toContain(`data-wrn-scope="${encoded}"`);
|
|
expect(html).toContain('data-scope="items: []"');
|
|
} else {
|
|
expect(html).not.toContain(encoded);
|
|
expect(html).toContain('data-scope=""');
|
|
expect(html).toContain('data-wrn-loop-locals="@');
|
|
expect(html.length).toBeLessThan(node.length * 10);
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
test("scope state named body remains data rather than a control template", () => {
|
|
runWithRequestContext({} as never, () => {
|
|
const value = {
|
|
body: '<style data-wrnexus-style-id="user">literal user text</style>',
|
|
label: "keep",
|
|
};
|
|
const encoded = Buffer.from(JSON.stringify(value)).toString("base64");
|
|
const html = renderDocument({
|
|
meta: {},
|
|
body: `<div data-scope="" data-wrn-scope="${encoded}"></div>`,
|
|
});
|
|
const entries = JSON.parse(/data-wrn-controls[^>]*>(.*?)<\/script>/s.exec(html)![1]!);
|
|
expect(entries.find((entry: any) => entry.__wrnMetadata)?.value).toEqual(value);
|
|
expect(html.split("</head>")[0]).not.toContain("literal user text");
|
|
});
|
|
});
|