Files
WRNexusJS/packages/csr/test/reactive.test.ts
T
Clintchiz 586a6db8ff
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s
release: WRNexusJS 0.8.0
2026-08-02 23:18:51 +05:30

455 lines
17 KiB
TypeScript

import { test, expect, beforeEach } from "bun:test";
import { Window } from "happy-dom";
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
import { mountHtml } from "@wrnexus/test";
// Fresh DOM per test, with the runtime's globals bound.
function mount(html: string): Window {
const win = new Window() as unknown as Window & Record<string, unknown>;
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.
const w = win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void };
w.__wrnexusHydrateScopes?.(win.document);
return win as unknown as 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", () => {
const win = mount(`<div data-scope="count: 0"><span>{count}, {count * 2}</span></div>`);
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>`,
);
const btn = win.document.querySelector("button")!;
expect(btn.textContent).toBe("0");
btn.click();
btn.click();
expect(btn.textContent).toBe("2");
});
test("component functions support formatted multiline assignments and ternaries", () => {
const behavior = Buffer.from(
JSON.stringify({
functions: `
function increment(event, nextValue) {
nextValue =
Number(count) +
(event.shiftKey ? 10 : 1)
count = nextValue
}
function normalize(useMinimum) {
count = useMinimum
? 0
: Number(count)
}
`,
watches: [],
lifecycle: {},
}),
).toString("base64");
const win = mount(
`<div data-scope="count: 0" data-wrn-behavior="${behavior}">
<button id="increment" data-on-click="increment(event)">{count}</button>
<button id="normalize" data-on-click="normalize(true)">normalize</button>
</div>`,
);
const increment = win.document.getElementById("increment") as unknown as HTMLElement;
increment.click();
expect(increment.textContent).toBe("1");
const MouseEventConstructor = (win as unknown as { MouseEvent: typeof MouseEvent }).MouseEvent;
increment.dispatchEvent(
new MouseEventConstructor("click", { shiftKey: true }) as unknown as Event,
);
expect(increment.textContent).toBe("11");
(win.document.getElementById("normalize") as unknown as HTMLElement).click();
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">
<button data-on-click="$emit('complete', { value: '4829' })">Complete</button>
</div>`,
);
const root = win.document.querySelector("[data-wrn-events]")!;
let detail: Record<string, unknown> | undefined;
root.addEventListener("complete", (event) => {
detail = (event as unknown as CustomEvent).detail;
});
win.document.querySelector("button")!.click();
expect(detail).toEqual({ value: "4829" });
});
test("nested scopes don't clobber each other (regression)", () => {
// An empty outer scope must not touch inner scopes' values.
const win = mount(
`<div data-scope="">
<div data-scope="count: 0"><button data-on-click="count++">A{count}</button></div>
<div data-scope="count: 10"><button data-on-click="count++">B{count}</button></div>
</div>`,
);
const [a, b] = Array.from(win.document.querySelectorAll("button"));
expect(a!.textContent).toBe("A0");
expect(b!.textContent).toBe("B10");
a!.click();
expect(a!.textContent).toBe("A1");
expect(b!.textContent).toBe("B10"); // unchanged
});
test("data-text binds an element's textContent to an expression", () => {
const win = mount(
`<div data-scope="n: 3"><strong data-text="n * 3">?</strong><button data-on-click="n = 5">x</button></div>`,
);
expect(win.document.querySelector("strong")!.textContent).toBe("9");
win.document.querySelector("button")!.click();
expect(win.document.querySelector("strong")!.textContent).toBe("15");
});
test("string scope values bind via data-text", () => {
const win = mount(`<div data-scope="msg: 'hi'"><strong data-text="msg">?</strong></div>`);
expect(win.document.querySelector("strong")!.textContent).toBe("hi");
});
test("data-for renders a list of objects and reacts to array changes", () => {
const win = mount(
`<div data-scope="todos: [{text: 'a'}, {text: 'b'}]">
<ul><li data-for="t in todos" data-text="t.text"></li></ul>
<button id="add" data-on-click="todos = todos.concat([{text: 'c'}])">add</button>
<button id="clear" data-on-click="todos = []">clear</button>
</div>`,
);
const items = () => Array.from(win.document.querySelectorAll("li"), (li) => li.textContent);
expect(items()).toEqual(["a", "b"]);
(win.document.getElementById("add") as unknown as HTMLElement).click();
expect(items()).toEqual(["a", "b", "c"]);
(win.document.getElementById("clear") as unknown as HTMLElement).click();
expect(items()).toEqual([]);
});
test("data-for exposes item + index, mustaches and member access", () => {
const win = mount(
`<div data-scope="rows: [{name: 'x'}, {name: 'y'}]">
<ul><li data-for="r, i in rows">{i}:{r.name}</li></ul>
</div>`,
);
expect(Array.from(win.document.querySelectorAll("li"), (li) => li.textContent)).toEqual([
"0:x",
"1:y",
]);
});
test("keyed data-for preserves DOM identity when items reorder", () => {
const win = mount(
`<div data-scope="rows: [{id: 1, name: 'a'}, {id: 2, name: 'b'}]">
<ul><li data-for="row in rows key row.id">{row.name}</li></ul>
<button data-on-click="rows = rows.slice().reverse()">reverse</button>
</div>`,
);
const before = Array.from(win.document.querySelectorAll("li"));
expect(before.map((node) => node.textContent)).toEqual(["a", "b"]);
(win.document.querySelector("button") as unknown as HTMLElement).click();
const after = Array.from(win.document.querySelectorAll("li"));
expect(after.map((node) => node.textContent)).toEqual(["b", "a"]);
expect(after[0]).toBe(before[1]);
expect(after[1]).toBe(before[0]);
});
test("expression evaluator: member access, ternary, comparison, calls", () => {
const win = mount(
`<div data-scope="user: {name: 'Ada', age: 36}, items: [1, 2, 3]">
<span id="a" data-text="user.name"></span>
<span id="b" data-text="user.age > 30 ? 'senior' : 'junior'"></span>
<span id="c" data-text="items.length"></span>
</div>`,
);
expect(win.document.getElementById("a")!.textContent).toBe("Ada");
expect(win.document.getElementById("b")!.textContent).toBe("senior");
expect(win.document.getElementById("c")!.textContent).toBe("3");
});
test("expression evaluator exposes safe primitive conversion globals", () => {
const win = mount(
`<div data-scope="count: 12">
<span data-text="String(count)"></span>
</div>`,
);
expect(win.document.querySelector("span")!.textContent).toBe("12");
});
test("logical expressions consume their right-hand side when the result short-circuits", () => {
const win = mount(
`<div data-scope="leftFalse: false, leftTrue: true">
<span id="and" data-text="leftFalse && 123"></span>
<span id="or" data-text="leftTrue || 456"></span>
</div>`,
);
expect(win.document.getElementById("and")!.textContent).toBe("false");
expect(win.document.getElementById("or")!.textContent).toBe("true");
});
test("expression evaluator supports flatMap callbacks", () => {
const win = mount(
`<div data-scope="groups: [{items: [1, 2]}, {items: [3]}]">
<span data-text="groups.flatMap((group) => group.items)"></span>
</div>`,
);
expect(win.document.querySelector("span")!.textContent).toBe("1,2,3");
});
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>
<section id="a" data-show="tab === 0">A</section>
<section id="b" data-show="tab === 1">B</section>
</div>`,
);
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(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", () => {
const win = mount(
`<div data-scope="ready: true">
<section
id="carousel-layout"
data-show="true"
data-wrn-bind-0='["data-show","{ready}"]'
>Slides</section>
</div>`,
);
expect(win.document.getElementById("carousel-layout")!.getAttribute("data-show")).toBe("true");
});
test("reactive attribute bindings update input and accessibility attributes", () => {
const win = mount(
`<div data-scope="show: false">
<input id="password" type="password" data-wrn-bind-0='["type","{show ? &#39;text&#39; : &#39;password&#39;}"]'>
<button data-on-click="show = !show" aria-label="Show password"
data-wrn-bind-0='["aria-label","{show ? &#39;Hide password&#39; : &#39;Show password&#39;}"]'>Toggle</button>
</div>`,
);
const input = win.document.getElementById("password") as unknown as HTMLInputElement;
const button = win.document.querySelector("button") as unknown as HTMLElement;
expect(input.type).toBe("password");
expect(button.getAttribute("aria-label")).toBe("Show password");
button.click();
expect(input.type).toBe("text");
expect(button.getAttribute("aria-label")).toBe("Hide password");
});
test("independent signals in one scope update correctly (dependency tracking)", () => {
const win = mount(
`<div data-scope="a: 0, b: 100">
<span id="ta" data-text="a"></span>
<span id="tb" data-text="b"></span>
<button id="ba" data-on-click="a++">A</button>
<button id="bb" data-on-click="b++">B</button>
</div>`,
);
const ta = () => win.document.getElementById("ta")!.textContent;
const tb = () => win.document.getElementById("tb")!.textContent;
const click = (id: string) => (win.document.getElementById(id) as unknown as HTMLElement).click();
expect([ta(), tb()]).toEqual(["0", "100"]);
click("ba");
click("ba");
expect([ta(), tb()]).toEqual(["2", "100"]); // b untouched by a's changes
click("bb");
expect([ta(), tb()]).toEqual(["2", "101"]);
});
test("data-for preserves arrays of objects from encoded component scope", () => {
const items = [
{
question: "One",
answer: "First",
},
{
question: "Two",
answer: "Second",
},
{
question: "Three",
answer: "Third",
},
{
question: "Four",
answer: "Fourth",
},
];
const encoded = Buffer.from(
JSON.stringify({
items,
}),
"utf8",
).toString("base64");
const dom = mountHtml(`
<div data-wrn-scope="${encoded}">
<article data-for="item, index in items">
<span>{index + 1}</span>
<strong>{item.question}</strong>
</article>
</div>
`);
expect(dom.querySelectorAll("article")).toHaveLength(4);
expect(dom.querySelectorAll("strong").map((element) => element.textContent)).toEqual([
"One",
"Two",
"Three",
"Four",
]);
});
test("$emit inside component functions does not depend on a global browser event", () => {
const behavior = Buffer.from(
JSON.stringify({
functions: `
function showOverlay(sourceEvent) {
visible = true
$emit("open", { source: sourceEvent.type })
}
`,
watches: [],
lifecycle: {},
}),
).toString("base64");
delete (globalThis as Record<string, unknown>).event;
const win = mount(
`<div data-scope="visible: false" data-wrn-events="open" data-wrn-behavior="${behavior}">
<button data-on-click="showOverlay(event)">Open overlay</button>
<span>{visible}</span>
</div>`,
);
const root = win.document.querySelector("[data-wrn-events]")!;
let detail: Record<string, unknown> | undefined;
root.addEventListener("open", (event) => {
detail = (event as unknown as CustomEvent).detail;
});
win.document.querySelector("button")!.click();
expect(win.document.querySelector("span")!.textContent).toBe("true");
expect(detail).toEqual({ source: "click" });
});