first commit
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
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.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",
|
||||
"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("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>`);
|
||||
win.document
|
||||
.getElementById("lnk")
|
||||
.dispatchEvent(
|
||||
new win.MouseEvent("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");
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { test, expect, beforeEach } from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
|
||||
|
||||
// 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>).NodeFilter = (
|
||||
win as unknown as { NodeFilter: unknown }
|
||||
).NodeFilter;
|
||||
(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;
|
||||
});
|
||||
|
||||
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("@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("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("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("data-show toggles visibility on a reactive expression (tabs pattern)", () => {
|
||||
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 disp = (id: string) =>
|
||||
(win.document.getElementById(id) as unknown as HTMLElement).style.display;
|
||||
expect(disp("a")).toBe("");
|
||||
expect(disp("b")).toBe("none");
|
||||
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
||||
expect(disp("a")).toBe("none");
|
||||
expect(disp("b")).toBe("");
|
||||
});
|
||||
|
||||
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"]);
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { test, expect, beforeEach } from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { REALTIME_RUNTIME } from "../src/realtime-runtime.ts";
|
||||
|
||||
/* A fake WebSocket that records instances + sent frames and lets tests drive events. */
|
||||
let sockets: FakeWS[];
|
||||
class FakeWS {
|
||||
url: string;
|
||||
readyState = 0;
|
||||
sent: string[] = [];
|
||||
onopen: (() => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onmessage: ((e: { data: string }) => void) | null = null;
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
sockets.push(this);
|
||||
}
|
||||
send(data: string) {
|
||||
this.sent.push(data);
|
||||
}
|
||||
close() {
|
||||
this.readyState = 3;
|
||||
}
|
||||
fireOpen() {
|
||||
this.readyState = 1;
|
||||
this.onopen?.();
|
||||
}
|
||||
fireMessage(obj: unknown) {
|
||||
this.onmessage?.({ data: JSON.stringify(obj) });
|
||||
}
|
||||
}
|
||||
|
||||
function boot(bodyHtml: string) {
|
||||
sockets = [];
|
||||
const win = new Window({ url: "http://localhost/" }) as unknown as Window &
|
||||
Record<string, unknown>;
|
||||
win.document.body.innerHTML = bodyHtml;
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
g.window = win;
|
||||
g.document = win.document;
|
||||
g.location = win.location;
|
||||
g.WebSocket = FakeWS;
|
||||
(0, eval)(REALTIME_RUNTIME);
|
||||
return win as unknown as Window;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of ["window", "document", "location", "WebSocket"]) {
|
||||
delete (globalThis as Record<string, unknown>)[k];
|
||||
}
|
||||
});
|
||||
|
||||
const CHAT = `
|
||||
<div data-room="chat">
|
||||
<span data-room-status data-room-status-class="badge" class="badge">connecting…</span>
|
||||
<div data-room-log></div>
|
||||
<template data-room-item="message"><div class="msg"><strong>%user%</strong>: %text%</div></template>
|
||||
<template data-room-item="system"><div class="sys">%text%</div></template>
|
||||
<form data-room-send>
|
||||
<input name="user" value="Ada">
|
||||
<input name="text" value="hello" data-room-reset>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</div>`;
|
||||
|
||||
test("[data-room] connects to the right URL and reflects status", () => {
|
||||
const win = boot(CHAT);
|
||||
expect(sockets.length).toBe(1);
|
||||
expect(sockets[0]!.url).toBe("ws://localhost/realtime/chat");
|
||||
sockets[0]!.fireOpen();
|
||||
const status = win.document.querySelector("[data-room-status]")!;
|
||||
expect(status.textContent).toBe("connected");
|
||||
expect(status.className).toContain("is-connected");
|
||||
});
|
||||
|
||||
test("incoming messages render via the typed template, HTML-escaped", () => {
|
||||
const win = boot(CHAT);
|
||||
sockets[0]!.fireOpen();
|
||||
sockets[0]!.fireMessage({ type: "message", user: "<b>Ada</b>", text: "hi & bye" });
|
||||
sockets[0]!.fireMessage({ type: "system", text: "joined" });
|
||||
const log = win.document.querySelector("[data-room-log]")!;
|
||||
expect(log.querySelector(".msg strong")!.textContent).toBe("<b>Ada</b>"); // escaped, not parsed
|
||||
expect(log.querySelector(".msg")!.textContent).toBe("<b>Ada</b>: hi & bye");
|
||||
expect(log.querySelector(".sys")!.textContent).toBe("joined");
|
||||
});
|
||||
|
||||
test("submitting [data-room-send] sends JSON and clears reset fields", () => {
|
||||
const win = boot(CHAT);
|
||||
sockets[0]!.fireOpen();
|
||||
const form = win.document.querySelector("form[data-room-send]")! as unknown as HTMLFormElement;
|
||||
form.dispatchEvent(
|
||||
new (win as unknown as { Event: typeof Event }).Event("submit", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
expect(sockets[0]!.sent.length).toBe(1);
|
||||
expect(JSON.parse(sockets[0]!.sent[0]!)).toEqual({ user: "Ada", text: "hello" });
|
||||
// text field had data-room-reset → cleared; user field kept.
|
||||
const inputs = win.document.querySelectorAll("input");
|
||||
expect((inputs[0] as unknown as HTMLInputElement).value).toBe("Ada");
|
||||
expect((inputs[1] as unknown as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
|
||||
test("programmatic wire.room() sends and receives", () => {
|
||||
const win = boot(`<div></div>`) as unknown as Window & {
|
||||
wire: {
|
||||
room: (n: string) => {
|
||||
on: (t: string, cb: (m: unknown) => void) => unknown;
|
||||
send: (o: unknown) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
const got: unknown[] = [];
|
||||
const room = win.wire.room("lobby");
|
||||
room.on("ping", (m: unknown) => got.push(m));
|
||||
sockets[0]!.fireOpen();
|
||||
room.send({ type: "hello" });
|
||||
expect(JSON.parse(sockets[0]!.sent[0]!)).toEqual({ type: "hello" });
|
||||
sockets[0]!.fireMessage({ type: "ping", n: 1 });
|
||||
expect(got).toEqual([{ type: "ping", n: 1 }]);
|
||||
});
|
||||
Reference in New Issue
Block a user