release: WRNexusJS 0.8.0
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import { afterEach, beforeEach, expect, test } from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { ACTION_RUNTIME } from "../src/action-runtime.ts";
|
||||
import { createActionClient } from "../src/actions.ts";
|
||||
|
||||
const originalGlobals = new Map(
|
||||
["window", "document", "location", "CustomEvent", "FormData", "fetch"].map((key) => [
|
||||
key,
|
||||
(globalThis as Record<string, unknown>)[key],
|
||||
]),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of ["window", "document", "location", "CustomEvent", "FormData", "fetch"]) {
|
||||
delete (globalThis as Record<string, unknown>)[key];
|
||||
}
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const [key, value] of originalGlobals) {
|
||||
if (value === undefined) delete (globalThis as Record<string, unknown>)[key];
|
||||
else (globalThis as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
test("action runtime exposes pending, optimistic, success, and invalidation state", async () => {
|
||||
const win = new Window({ url: "https://example.test/users" });
|
||||
win.document.cookie = "wire-csrf=token";
|
||||
win.document.body.innerHTML = `<form data-wrn-action="createUser"><input name="name" value="Ada"></form>`;
|
||||
const phases: string[] = [];
|
||||
["optimistic", "pending", "success"].forEach((phase) =>
|
||||
win.document.addEventListener(`wrnexus:action:${phase}`, () => phases.push(phase)),
|
||||
);
|
||||
let invalidated: unknown;
|
||||
win.addEventListener("wrnexus:cache:invalidate", (event) => {
|
||||
invalidated = (event as unknown as CustomEvent).detail.tags;
|
||||
});
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
Object.assign(g, {
|
||||
window: win,
|
||||
document: win.document,
|
||||
location: win.location,
|
||||
CustomEvent: win.CustomEvent,
|
||||
FormData: win.FormData,
|
||||
fetch: async () => Response.json({ data: { id: 1 }, invalidated: ["users"] }),
|
||||
});
|
||||
(0, eval)(ACTION_RUNTIME);
|
||||
win.document
|
||||
.querySelector("form")!
|
||||
.dispatchEvent(new win.Event("submit", { bubbles: true, cancelable: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(phases).toEqual(["optimistic", "pending", "success"]);
|
||||
expect(invalidated).toEqual(["users"]);
|
||||
expect(win.document.querySelector("form")?.getAttribute("data-wrn-action-state")).toBe("success");
|
||||
});
|
||||
|
||||
test("typed action client returns invalidations and structured validation errors", async () => {
|
||||
const original = globalThis.fetch;
|
||||
try {
|
||||
globalThis.fetch = (async () =>
|
||||
Response.json({ data: { id: 1 }, invalidated: ["users"] })) as unknown as typeof fetch;
|
||||
const call = createActionClient<{ name: string }, { id: number }>("/users", "createUser");
|
||||
expect(await call({ name: "Ada" }, { csrfToken: "token" })).toEqual({
|
||||
data: { id: 1 },
|
||||
invalidated: ["users"],
|
||||
});
|
||||
globalThis.fetch = (async () =>
|
||||
Response.json({ errors: { name: "Required" } }, { status: 422 })) as unknown as typeof fetch;
|
||||
await expect(call({ name: "" })).rejects.toMatchObject({
|
||||
status: 422,
|
||||
errors: { name: "Required" },
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
@@ -18,6 +18,7 @@ function install(bodyHtml: string): void {
|
||||
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({
|
||||
@@ -44,6 +45,7 @@ beforeEach(() => {
|
||||
"location",
|
||||
"DOMParser",
|
||||
"CustomEvent",
|
||||
"Event",
|
||||
"fetch",
|
||||
]) {
|
||||
delete g[k];
|
||||
@@ -64,6 +66,22 @@ test("intercepts an internal link click and swaps #app in place", async () => {
|
||||
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("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();
|
||||
@@ -151,3 +169,37 @@ test("synchronizes page styles during client navigation and preserves the curren
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -9,9 +9,13 @@ function mount(html: string): Window {
|
||||
win.document.body.innerHTML = `<div id="app">${html}</div>`;
|
||||
(globalThis as Record<string, unknown>).window = win;
|
||||
(globalThis as Record<string, unknown>).document = win.document;
|
||||
(globalThis as Record<string, unknown>).location = win.location;
|
||||
(globalThis as Record<string, unknown>).NodeFilter = (
|
||||
win as unknown as { NodeFilter: unknown }
|
||||
).NodeFilter;
|
||||
(globalThis as Record<string, unknown>).MutationObserver = (
|
||||
win as unknown as { MutationObserver: unknown }
|
||||
).MutationObserver;
|
||||
(0, eval)(REACTIVE_RUNTIME);
|
||||
// Hydrate deterministically (auto-init waits on DOMContentLoaded, which the
|
||||
// test window may not fire). setupScope is idempotent, so this is safe.
|
||||
@@ -23,6 +27,9 @@ function mount(html: string): Window {
|
||||
beforeEach(() => {
|
||||
delete (globalThis as Record<string, unknown>).window;
|
||||
delete (globalThis as Record<string, unknown>).document;
|
||||
delete (globalThis as Record<string, unknown>).location;
|
||||
delete (globalThis as Record<string, unknown>).fetch;
|
||||
delete (globalThis as Record<string, unknown>).MutationObserver;
|
||||
});
|
||||
|
||||
test("hydrates {expr} mustaches from data-scope", () => {
|
||||
@@ -30,6 +37,40 @@ test("hydrates {expr} mustaches from data-scope", () => {
|
||||
expect(win.document.querySelector("span")!.textContent).toBe("0, 0");
|
||||
});
|
||||
|
||||
test("mounts client-only templates before hydrating their scopes", () => {
|
||||
const win = mount(
|
||||
`<div data-wrn-client-root="client-page" aria-busy="true"></div>` +
|
||||
`<template data-wrn-client-template="client-page"><main data-scope="count: 2"><b>{count}</b></main></template>`,
|
||||
);
|
||||
const runtime = win as unknown as {
|
||||
__wrnexusMountClientRoots?: (root: unknown) => void;
|
||||
__wrnexusHydrateScopes?: (root: unknown) => void;
|
||||
};
|
||||
runtime.__wrnexusMountClientRoots?.(win.document);
|
||||
runtime.__wrnexusHydrateScopes?.(win.document);
|
||||
expect(win.document.querySelector("template")).toBeNull();
|
||||
expect(win.document.querySelector("main b")?.textContent).toBe("2");
|
||||
});
|
||||
|
||||
test("orchestrates named client loads and renders the success template", async () => {
|
||||
(globalThis as Record<string, unknown>).fetch = () =>
|
||||
Promise.resolve(Response.json({ data: { name: "Ada" } }));
|
||||
const win = mount(
|
||||
`<section data-wrn-async="users" data-wrn-async-retries="0" aria-busy="true">` +
|
||||
`<div data-wrn-async-content>Loading</div>` +
|
||||
`<template data-wrn-async-loading>Loading</template>` +
|
||||
`<template data-wrn-async-success><strong>{users.name}</strong></template>` +
|
||||
`<template data-wrn-async-error><b>{error.message}</b></template></section>`,
|
||||
);
|
||||
const runtime = win as unknown as { __wrnexusHydrateAsyncBoundaries?: (root: unknown) => void };
|
||||
runtime.__wrnexusHydrateAsyncBoundaries?.(win.document);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(win.document.querySelector("section")?.getAttribute("data-wrn-async-state")).toBe(
|
||||
"success",
|
||||
);
|
||||
expect(win.document.querySelector("strong")?.textContent).toBe("Ada");
|
||||
});
|
||||
|
||||
test("@event (data-on-click) mutates a signal and re-renders", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="count: 0"><button data-on-click="count++">{count}</button></div>`,
|
||||
@@ -83,6 +124,26 @@ test("component functions support formatted multiline assignments and ternaries"
|
||||
expect(increment.textContent).toBe("0");
|
||||
});
|
||||
|
||||
test("component functions evaluate template literals without unsafe eval", () => {
|
||||
const behavior = Buffer.from(
|
||||
JSON.stringify({
|
||||
functions: `function increment() {
|
||||
count++
|
||||
message = \`Count is now \${count}.\`
|
||||
}`,
|
||||
watches: [],
|
||||
lifecycle: {},
|
||||
}),
|
||||
).toString("base64");
|
||||
const win = mount(
|
||||
`<div data-scope="count: 0, message: 'ready'" data-wrn-behavior="${behavior}">` +
|
||||
`<button data-on-click="increment()">{message}</button></div>`,
|
||||
);
|
||||
const button = win.document.querySelector("button")!;
|
||||
button.click();
|
||||
expect(button.textContent).toBe("Count is now 1.");
|
||||
});
|
||||
|
||||
test("declared component events emit through the generic $emit function", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="" data-wrn-events="complete">
|
||||
@@ -220,7 +281,7 @@ test("expression evaluator supports flatMap callbacks", () => {
|
||||
expect(win.document.querySelector("span")!.textContent).toBe("1,2,3");
|
||||
});
|
||||
|
||||
test("data-show toggles visibility on a reactive expression (tabs pattern)", () => {
|
||||
test("data-show toggles visibility without destroying interactive state", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="tab: 0">
|
||||
<button data-on-click="tab = 1">go</button>
|
||||
@@ -228,17 +289,37 @@ test("data-show toggles visibility on a reactive expression (tabs pattern)", ()
|
||||
<section id="b" data-show="tab === 1">B</section>
|
||||
</div>`,
|
||||
);
|
||||
const disp = (id: string) =>
|
||||
(win.document.getElementById(id) as unknown as HTMLElement).style.display;
|
||||
expect(disp("a")).toBe("");
|
||||
expect(disp("b")).toBe("none");
|
||||
expect(win.document.getElementById("a")!.getAttribute("data-show")).toBe("true");
|
||||
expect(win.document.getElementById("b")!.getAttribute("data-show")).toBe("false");
|
||||
const a = win.document.getElementById("a") as unknown as HTMLElement;
|
||||
const b = win.document.getElementById("b") as unknown as HTMLElement;
|
||||
expect(a.style.display).toBe("");
|
||||
expect(b.style.display).toBe("none");
|
||||
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
||||
expect(disp("a")).toBe("none");
|
||||
expect(disp("b")).toBe("");
|
||||
expect(win.document.getElementById("a")!.getAttribute("data-show")).toBe("false");
|
||||
expect(win.document.getElementById("b")!.getAttribute("data-show")).toBe("true");
|
||||
expect(a.style.display).toBe("none");
|
||||
expect(b.style.display).toBe("");
|
||||
});
|
||||
|
||||
test("dynamic components remove inactive cases from the live DOM", async () => {
|
||||
const win = mount(
|
||||
`<div data-scope="active: 'Admin'">
|
||||
<button data-on-click="active = active === 'Admin' ? 'Guest' : 'Admin'">switch</button>
|
||||
<div data-wrn-dynamic-component="Admin" data-wrn-bind-0='["data-wrn-dynamic-component","{active}"]'>
|
||||
<section data-component-case="Admin">Administrator secret</section>
|
||||
<section data-component-case="Guest">Guest dashboard</section>
|
||||
</div>
|
||||
</div>`,
|
||||
);
|
||||
win.document.dispatchEvent(new win.Event("DOMContentLoaded"));
|
||||
expect(win.document.querySelector('[data-component-case="Admin"]')?.textContent).toContain(
|
||||
"Administrator",
|
||||
);
|
||||
expect(win.document.querySelector('[data-component-case="Guest"]')).toBeNull();
|
||||
|
||||
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(win.document.querySelector('[data-component-case="Admin"]')).toBeNull();
|
||||
expect(win.document.querySelector('[data-component-case="Guest"]')?.textContent).toContain(
|
||||
"Guest",
|
||||
);
|
||||
});
|
||||
|
||||
test("reactive data attributes preserve explicit boolean strings", () => {
|
||||
|
||||
Reference in New Issue
Block a user