270 lines
10 KiB
TypeScript
270 lines
10 KiB
TypeScript
import { test, expect, beforeEach } from "bun:test";
|
|
import { Window } from "happy-dom";
|
|
import { NAV_RUNTIME } from "../src/nav-runtime.ts";
|
|
|
|
let win: any;
|
|
let fetchCalls: { url: string; opts: any }[];
|
|
let nextHtml: string;
|
|
|
|
function install(bodyHtml: string): void {
|
|
win = new Window({ url: "https://example.test/" });
|
|
win.document.body.innerHTML = bodyHtml;
|
|
fetchCalls = [];
|
|
nextHtml = "";
|
|
const g = globalThis as any;
|
|
g.window = win;
|
|
g.document = win.document;
|
|
g.history = win.history;
|
|
g.location = win.location;
|
|
g.DOMParser = win.DOMParser;
|
|
g.CustomEvent = win.CustomEvent;
|
|
g.Event = win.Event;
|
|
g.fetch = win.fetch = (url: string, opts: any) => {
|
|
fetchCalls.push({ url, opts });
|
|
return Promise.resolve({
|
|
redirected: false,
|
|
url,
|
|
headers: {
|
|
get: (k: string) =>
|
|
k.toLowerCase() === "content-type" ? "text/html; charset=utf-8" : null,
|
|
},
|
|
text: () => Promise.resolve(nextHtml),
|
|
});
|
|
};
|
|
(0, eval)(NAV_RUNTIME);
|
|
}
|
|
|
|
const flush = () => new Promise((r) => setTimeout(r, 0));
|
|
|
|
beforeEach(() => {
|
|
const g = globalThis as any;
|
|
for (const k of [
|
|
"window",
|
|
"document",
|
|
"history",
|
|
"location",
|
|
"DOMParser",
|
|
"CustomEvent",
|
|
"Event",
|
|
"fetch",
|
|
]) {
|
|
delete g[k];
|
|
}
|
|
});
|
|
|
|
test("intercepts an internal link click and swaps #app in place", async () => {
|
|
install(`<div id="app"><h1>Home</h1><a href="/about" id="lnk">About</a></div>`);
|
|
nextHtml =
|
|
`<!doctype html><html><head><title>About</title></head>` +
|
|
`<body><div id="app"><h1>About page</h1></div></body></html>`;
|
|
win.document.getElementById("lnk").click();
|
|
await flush();
|
|
expect(fetchCalls.length).toBe(1);
|
|
expect(fetchCalls[0]!.url).toContain("/about");
|
|
expect(fetchCalls[0]!.opts.headers["x-wrnexus-nav"]).toBe("1");
|
|
expect(win.document.getElementById("app").innerHTML).toContain("About page");
|
|
expect(win.document.title).toBe("About");
|
|
});
|
|
|
|
test("prefetches focused routes once and reuses the document during navigation", async () => {
|
|
install(`<div id="app"><a href="/about" id="prefetch">About</a></div>`);
|
|
nextHtml =
|
|
`<!doctype html><html><head><title>About</title></head>` +
|
|
`<body><div id="app"><h1>Prefetched</h1></div></body></html>`;
|
|
const link = win.document.getElementById("prefetch");
|
|
link.dispatchEvent(new win.FocusEvent("focusin", { bubbles: true }));
|
|
await flush();
|
|
expect(fetchCalls).toHaveLength(1);
|
|
expect(fetchCalls[0]?.opts.headers["x-wrnexus-prefetch"]).toBe("1");
|
|
link.click();
|
|
await flush();
|
|
expect(fetchCalls).toHaveLength(1);
|
|
expect(win.document.querySelector("h1")?.textContent).toBe("Prefetched");
|
|
});
|
|
|
|
test("does not prefetch links merely passing under the pointer while scrolling", async () => {
|
|
install(`<div id="app"><a href="/about" id="prefetch">About</a></div>`);
|
|
const link = win.document.getElementById("prefetch");
|
|
link.dispatchEvent(new win.PointerEvent("pointerover", { bubbles: true }));
|
|
await flush();
|
|
expect(fetchCalls).toHaveLength(0);
|
|
link.dispatchEvent(new win.PointerEvent("pointerdown", { bubbles: true }));
|
|
await flush();
|
|
expect(fetchCalls).toHaveLength(1);
|
|
expect(fetchCalls[0]?.opts.headers["x-wrnexus-prefetch"]).toBe("1");
|
|
});
|
|
|
|
test("ignores cross-origin links (full navigation)", async () => {
|
|
install(`<div id="app"><a href="https://other.test/x" id="lnk">x</a></div>`);
|
|
win.document.getElementById("lnk").click();
|
|
await flush();
|
|
expect(fetchCalls.length).toBe(0);
|
|
});
|
|
|
|
test("ignores modified clicks so new-tab still works", async () => {
|
|
install(`<div id="app"><a href="/about" id="lnk">x</a></div>`);
|
|
const MouseEventConstructor = (win as unknown as { MouseEvent: typeof MouseEvent }).MouseEvent;
|
|
win.document.getElementById("lnk").dispatchEvent(
|
|
new MouseEventConstructor("click", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
button: 0,
|
|
metaKey: true,
|
|
}),
|
|
);
|
|
await flush();
|
|
expect(fetchCalls.length).toBe(0);
|
|
});
|
|
|
|
test("exposes programmatic navigation", async () => {
|
|
install(`<div id="app"><h1>Home</h1></div>`);
|
|
nextHtml = `<html><head><title>Dash</title></head><body><div id="app"><h1>Dashboard</h1></div></body></html>`;
|
|
expect(typeof win.__wrnexusNavigate).toBe("function");
|
|
win.__wrnexusNavigate("/dashboard");
|
|
await flush();
|
|
expect(fetchCalls.length).toBe(1);
|
|
expect(win.document.getElementById("app").innerHTML).toContain("Dashboard");
|
|
});
|
|
|
|
test("rebinds theme controls after swapping the page", async () => {
|
|
install(`<div id="app"><a href="/about" id="lnk">About</a></div>`);
|
|
let boundRoot: unknown;
|
|
win.wrnTheme = { bind: (root: unknown) => (boundRoot = root) };
|
|
nextHtml = `<html><body><div id="app"><button data-wrn-theme-toggle>Theme</button></div></body></html>`;
|
|
|
|
win.document.getElementById("lnk").click();
|
|
await flush();
|
|
|
|
expect(boundRoot).toBe(win.document);
|
|
expect(win.document.querySelector("[data-wrn-theme-toggle]")).not.toBeNull();
|
|
});
|
|
|
|
test("synchronizes and rebinds i18n data during client navigation", async () => {
|
|
install(`<div id="app"><a href="/about" id="lnk">About</a></div>`);
|
|
let boundRoot: unknown;
|
|
const translate = () => "translated";
|
|
const setLanguage = () => true;
|
|
win.__wrnI18n = { lang: "en", messages: { old: "Old" }, t: translate, set: setLanguage };
|
|
win.__wrnLang = { bind: (root: unknown) => (boundRoot = root) };
|
|
nextHtml =
|
|
`<html lang="mr"><body><div id="app"><p data-t="home.title">नवीन</p></div>` +
|
|
`<script>window.__wrnI18n={"lang":"mr","messages":{"home":{"title":"नवीन"}},"fallbackMessages":{}};</script>` +
|
|
`</body></html>`;
|
|
|
|
win.document.getElementById("lnk").click();
|
|
await flush();
|
|
|
|
expect(win.__wrnI18n.lang).toBe("mr");
|
|
expect(win.__wrnI18n.messages.home.title).toBe("नवीन");
|
|
expect(win.__wrnI18n.t).toBe(translate);
|
|
expect(win.__wrnI18n.set).toBe(setLanguage);
|
|
expect(boundRoot).toBe(win.document.getElementById("app"));
|
|
});
|
|
|
|
test("preserves same-language translations when an incoming navigation catalog is partial", async () => {
|
|
install(`<div id="app"><a href="/about" id="lnk">About</a></div>`);
|
|
win.__wrnI18n = {
|
|
lang: "en",
|
|
messages: { navigation: { home: "Home" }, footer: { contact: "Contact" } },
|
|
fallbackMessages: {},
|
|
};
|
|
win.__wrnLang = {
|
|
bind: (root: ParentNode) => {
|
|
root.querySelectorAll("[data-t]").forEach((node) => {
|
|
const parts = String(node.getAttribute("data-t") || "").split(".");
|
|
let value: any = win.__wrnI18n.messages;
|
|
for (const part of parts) value = value?.[part];
|
|
node.textContent = typeof value === "string" ? value : node.getAttribute("data-t");
|
|
});
|
|
},
|
|
};
|
|
nextHtml =
|
|
`<html lang="en"><body><div id="app"><p data-t="navigation.home">navigation.home</p></div>` +
|
|
`<script>window.__wrnI18n={"lang":"en","messages":{},"fallbackMessages":{}};</script>` +
|
|
`</body></html>`;
|
|
|
|
win.document.getElementById("lnk").click();
|
|
await flush();
|
|
|
|
expect(win.__wrnI18n.messages.navigation.home).toBe("Home");
|
|
expect(win.__wrnI18n.messages.footer.contact).toBe("Contact");
|
|
expect(win.document.querySelector("[data-t]")?.textContent).toBe("Home");
|
|
});
|
|
|
|
test("unmounts and remounts package runtimes during client navigation", async () => {
|
|
install(
|
|
`<div id="app"><div data-wrnexus-runtime="captcha">Old</div><a href="/next" id="lnk">Next</a></div>`,
|
|
);
|
|
let mounts = 0;
|
|
let unmounts = 0;
|
|
win.__wrnexusRuntimes = {
|
|
captcha: {
|
|
mount: () => mounts++,
|
|
unmount: () => unmounts++,
|
|
},
|
|
};
|
|
nextHtml = `<html><body><div id="app"><div data-wrnexus-runtime="captcha">New</div></div></body></html>`;
|
|
|
|
win.document.getElementById("lnk").click();
|
|
await flush();
|
|
|
|
expect(unmounts).toBe(1);
|
|
expect(mounts).toBe(1);
|
|
expect(win.document.getElementById("app").textContent).toContain("New");
|
|
});
|
|
|
|
test("synchronizes page styles during client navigation and preserves the current nonce", async () => {
|
|
install(
|
|
`<script nonce="current-nonce"></script>` +
|
|
`<style data-wrnexus-style-id="home" data-wrnexus-style-kind="page" nonce="current-nonce">.page{color:red}</style>` +
|
|
`<div id="app"><a href="/about" id="lnk">About</a></div>`,
|
|
);
|
|
nextHtml =
|
|
`<html><head>` +
|
|
`<style data-wrnexus-style-id="about" data-wrnexus-style-owner="About" data-wrnexus-style-kind="page" nonce="response-nonce">.page{color:blue}</style>` +
|
|
`</head><body><div id="app"><h1 class="page">About</h1></div></body></html>`;
|
|
|
|
win.document.getElementById("lnk").click();
|
|
await flush();
|
|
|
|
expect(win.document.querySelector('style[data-wrnexus-style-id="home"]')).toBeNull();
|
|
const style = win.document.querySelector('style[data-wrnexus-style-id="about"]');
|
|
expect(style).not.toBeNull();
|
|
expect(style.textContent).toContain("color:blue");
|
|
expect(style.getAttribute("nonce")).toBe("current-nonce");
|
|
});
|
|
|
|
test("restores declared form state but never preserves sensitive fields", async () => {
|
|
install(
|
|
`<meta name="wrnexus-preserve" content="forms">` +
|
|
`<div id="app"><input name="query" value="draft"><input name="password" type="password" value="secret"></div>`,
|
|
);
|
|
nextHtml = `<html><head><meta name="wrnexus-preserve" content="forms"></head><body><div id="app">Next</div></body></html>`;
|
|
win.__wrnexusNavigate("/next");
|
|
await flush();
|
|
|
|
nextHtml =
|
|
`<html><head><meta name="wrnexus-preserve" content="forms"></head><body><div id="app">` +
|
|
`<input name="query" value=""><input name="password" type="password" value=""></div></body></html>`;
|
|
win.__wrnexusNavigate("/");
|
|
await flush();
|
|
|
|
expect(win.document.querySelector('[name="query"]').value).toBe("draft");
|
|
expect(win.document.querySelector('[name="password"]').value).toBe("");
|
|
});
|
|
|
|
test("KeepAlive preserves the same live DOM instance across routes", async () => {
|
|
install(
|
|
`<div id="app"><section data-wrn-keepalive="dashboard"><input value="live"></section></div>`,
|
|
);
|
|
const original = win.document.querySelector("[data-wrn-keepalive]");
|
|
original.runtimeState = { count: 7 };
|
|
nextHtml = `<html><body><div id="app"><section data-wrn-keepalive="dashboard"><input value="new"></section></div></body></html>`;
|
|
win.__wrnexusNavigate("/next");
|
|
await flush();
|
|
const restored = win.document.querySelector("[data-wrn-keepalive]");
|
|
expect(restored).toBe(original);
|
|
expect(restored.runtimeState).toEqual({ count: 7 });
|
|
expect(restored.querySelector("input").value).toBe("live");
|
|
});
|