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; win.document.body.innerHTML = `
${html}
`; (globalThis as Record).window = win; (globalThis as Record).document = win.document; (globalThis as Record).location = win.location; (globalThis as Record).NodeFilter = ( win as unknown as { NodeFilter: unknown } ).NodeFilter; (globalThis as Record).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).window; delete (globalThis as Record).document; delete (globalThis as Record).location; delete (globalThis as Record).fetch; delete (globalThis as Record).MutationObserver; }); test("hydrates {expr} mustaches from data-scope", () => { const win = mount(`
{count}, {count * 2}
`); expect(win.document.querySelector("span")!.textContent).toBe("0, 0"); }); test("mounts client-only templates before hydrating their scopes", () => { const win = mount( `
` + ``, ); 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).fetch = () => Promise.resolve(Response.json({ data: { name: "Ada" } })); const win = mount( `
` + `
Loading
` + `` + `` + `
`, ); 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( `
`, ); 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( `
`, ); 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( `
` + `
`, ); 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( `
`, ); const root = win.document.querySelector("[data-wrn-events]")!; let detail: Record | 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( `
`, ); 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( `
?
`, ); 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(`
?
`); expect(win.document.querySelector("strong")!.textContent).toBe("hi"); }); test("data-for renders a list of objects and reacts to array changes", () => { const win = mount( `
`, ); 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( `
  • {i}:{r.name}
`, ); 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( `
  • {row.name}
`, ); 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( `
`, ); 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( `
`, ); expect(win.document.querySelector("span")!.textContent).toBe("12"); }); test("logical expressions consume their right-hand side when the result short-circuits", () => { const win = mount( `
`, ); 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( `
`, ); expect(win.document.querySelector("span")!.textContent).toBe("1,2,3"); }); test("data-show toggles visibility without destroying interactive state", () => { const win = mount( `
A
B
`, ); 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( `
Administrator secret
Guest dashboard
`, ); 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( `
`, ); expect(win.document.getElementById("carousel-layout")!.getAttribute("data-show")).toBe("true"); }); test("reactive attribute bindings update input and accessibility attributes", () => { const win = mount( `
`, ); 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( `
`, ); 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(`
{index + 1} {item.question}
`); 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).event; const win = mount( `
{visible}
`, ); const root = win.document.querySelector("[data-wrn-events]")!; let detail: Record | 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" }); }); test("template literals work inside browser API call arguments", () => { const behavior = Buffer.from( JSON.stringify({ functions: `function save() { window.localStorage.setItem(\`prefs:\${count}\`, JSON.stringify(count)) message = \`Saved \${count}\` }`, watches: [], lifecycle: {}, }), ).toString("base64"); const win = mount( `
` + `
`, ); win.document.querySelector("button")!.click(); expect(win.localStorage.getItem("prefs:3")).toBe("3"); expect(win.document.querySelector("button")!.textContent).toBe("Saved 3"); }); test("compiled client functions are not overwritten by fallback behavior parsing", () => { const behavior = Buffer.from( JSON.stringify({ functions: `function save() { message = \`fallback\` }`, watches: [], lifecycle: {}, }), ).toString("base64"); const win = new Window() as unknown as Window & Record; win.document.body.innerHTML = `
` + `
`; const scope = win.document.getElementById("scope") as unknown as HTMLElement & { __wrnexusClientModule?: unknown; }; scope.__wrnexusClientModule = { bindClientScope(context: { state: Record }) { return { save() { context.state.message = "native"; }, }; }, }; (globalThis as Record).window = win; (globalThis as Record).document = win.document; (globalThis as Record).location = win.location; (globalThis as Record).NodeFilter = ( win as unknown as { NodeFilter: unknown } ).NodeFilter; (globalThis as Record).MutationObserver = ( win as unknown as { MutationObserver: unknown } ).MutationObserver; (0, eval)(REACTIVE_RUNTIME); (win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void }).__wrnexusHydrateScopes?.( win.document, ); win.document.querySelector("button")!.click(); expect(win.document.querySelector("button")!.textContent).toBe("native"); }); test("$route updates after client navigation", () => { const win = new Window({ url: "http://localhost/" }) as unknown as Window & Record; win.document.body.innerHTML = `` + `
{$route.pathname}{$route.params.id}
`; (globalThis as Record).window = win; (globalThis as Record).document = win.document; (globalThis as Record).location = win.location; (globalThis as Record).NodeFilter = ( win as unknown as { NodeFilter: unknown } ).NodeFilter; (globalThis as Record).MutationObserver = ( win as unknown as { MutationObserver: unknown } ).MutationObserver; (0, eval)(REACTIVE_RUNTIME); (win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void }).__wrnexusHydrateScopes?.( win.document, ); expect(win.document.querySelector("strong")!.textContent).toBe("/"); expect(win.document.querySelector("em")!.textContent).toBe("42"); expect(win.document.querySelector("[data-wrn-route-params]")).toBeNull(); win.history.pushState({}, "", "/settings?tab=chat"); win.dispatchEvent( new (win as unknown as { CustomEvent: typeof CustomEvent }).CustomEvent( "wrnexus:navigated", ) as unknown as Parameters[0], ); expect(win.document.querySelector("strong")!.textContent).toBe("/settings"); }); test("cache invalidation refetches matching client Async boundaries", async () => { let requests = 0; (globalThis as Record).fetch = () => { requests++; return Promise.resolve(Response.json({ data: { value: requests } })); }; const win = mount( `
` + `
` + `` + `` + `` + `
`, ); 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("b")?.textContent).toBe("1"); win.dispatchEvent( new (win as unknown as { CustomEvent: typeof CustomEvent }).CustomEvent( "wrnexus:cache:invalidate", { detail: { tags: ["uploads"] } }, ) as unknown as Parameters[0], ); await new Promise((resolve) => setTimeout(resolve, 0)); expect(requests).toBe(2); expect(win.document.querySelector("b")?.textContent).toBe("2"); });