// The page modules that need no browser: the renderer's reconciliation (on a small stand-in // for the document), the prop values' plain form, the ZIP writer, and the model stream // readers. // // node --test wai/wai-app/web/web.test.mjs import test from "node:test"; import assert from "node:assert/strict"; import zlib from "node:zlib"; // A stand-in for the few DOM operations the renderer makes. class El { constructor(tag) { this.tag = tag; this.children = []; this.parent = null; this.dataset = {}; this.classList = new Set(); this.classList.remove = Set.prototype.delete; } get firstElementChild() { return this.children[0] || null; } remove() { if (this.parent) { this.parent.children.splice(this.parent.children.indexOf(this), 1); this.parent = null; } } insertBefore(el, ref) { el.remove(); const i = ref ? this.children.indexOf(ref) : this.children.length; this.children.splice(i < 0 ? this.children.length : i, 0, el); el.parent = this; this.moves = (this.moves || 0) + 1; } replaceChildren(...els) { for (const c of [...this.children]) c.remove(); for (const e of els) this.insertBefore(e, null); } } globalThis.setTimeout = ((orig) => (f, ms) => orig(f, ms))(globalThis.setTimeout); const { Renderer, plain, plainProps } = await import("./renderer.js"); const { completionChunks, replay } = await import("./model.js"); const T = (v) => ({ type: "text", value: v }); function comps(log) { const make = (name, withSlot) => (ctx, props, node) => { const el = new El(name); const c = { el, props, update(p) { log.push(`update ${node.key}`); c.props = p; }, destroy() { log.push(`destroy ${node.key}`); } }; if (withSlot) { c.slot = new El("slot"); el.children.push(c.slot); } log.push(`create ${node.key}`); return c; }; return { App: make("App", true), Room: make("Room", true), Chat: make("Chat"), Text: make("Text") }; } const snap = (tree, keys) => ({ tree, nodes: keys.map((key, i) => ({ key, line: i + 1, statement: key })) }); test("a widget keeps its instance across snapshots while its key and widget stay", () => { const log = []; const root = new El("root"); const r = new Renderer(root, comps(log), {}); const app = (kids, title) => ({ name: "App", props: { title: T(title) }, children: kids }); r.render(snap(app([], "A"), ["root"])); const room0 = { name: "Room", props: {}, children: [] }; r.render(snap(app([room0], "A"), ["root", "room"])); const roomEl = r.inst.get("room").comp.el; r.render(snap(app([{ name: "Room", props: {}, children: [{ name: "Chat", props: {} }] }], "A b"), ["root", "room", "chat"])); assert.equal(r.inst.get("room").comp.el, roomEl, "the room is the same instance"); assert.deepEqual(log, ["create root", "create room", "update root", "create chat"]); // The room's element was placed once and never moved again (a frame moved reloads). assert.equal(r.inst.get("root").comp.slot.moves, 1); // A widget gone is destroyed; one whose widget changed under its key is made again. r.render(snap(app([{ name: "Text", props: { text: T("x") } }], "A b"), ["root", "room"])); assert.deepEqual(log.slice(4), ["destroy room", "create room", "destroy chat"]); r.render({ tree: null, nodes: [] }); assert.equal(root.children.length, 0); }); test("children are placed in order, inserting without moving what is in place", () => { const log = []; const root = new El("root"); const r = new Renderer(root, comps(log), {}); const kid = (n) => ({ name: "Text", props: { text: T(n) } }); r.render(snap({ name: "App", props: {}, children: [kid("a"), kid("c")] }, ["root", "a", "c"])); const slot = r.inst.get("root").comp.slot; const c = r.inst.get("c").comp.el; slot.moves = 0; r.render(snap({ name: "App", props: {}, children: [kid("a"), kid("b"), kid("c")] }, ["root", "a", "b", "c"])); assert.deepEqual(slot.children.map((e) => e.tag), ["Text", "Text", "Text"]); assert.equal(slot.children[2], c); assert.equal(slot.moves, 1, "only the new widget was inserted"); }); test("prop values in their plain form", () => { assert.deepEqual(plainProps({ a: T("x"), n: { type: "number", value: 2 }, b: { type: "bool", value: false }, z: { type: "null" } }), { a: "x", n: 2, b: false, z: null }); assert.deepEqual(plain({ type: "list", value: [T("a"), { type: "list", value: [] }] }), ["a", []]); assert.deepEqual(plain({ type: "object", value: { k: T("v") } }), { k: "v" }); assert.deepEqual(plain({ type: "action", value: { id: "Go", payload: { type: "list", value: [T("a")] } } }), { action: "Go", payload: ["a"] }); }); test("a chat/completions stream gives its content chunks", async () => { const lines = (async function* () { yield ": keep-alive"; yield 'data: {"choices":[{"delta":{"role":"assistant"}}]}'; yield 'data: {"choices":[{"delta":{"content":"root = "}}]}'; yield ""; yield 'data: {"choices":[{"delta":{"content":"App()"}}]}'; yield "data: [DONE]"; })(); const got = []; for await (const c of completionChunks(lines)) got.push(c); assert.deepEqual(got, ["root = ", "App()"]); const err = (async function* () { yield 'data: {"error":{"message":"bad key"}}'; })(); await assert.rejects(async () => { for await (const _ of completionChunks(err)) { /* nothing */ } }, /bad key/); }); test("a recorded stream replays its chunks in order", async () => { const got = []; for await (const c of replay({ chunks: ["a", "b", "c"], pause_ms: 1 })) got.push(c); assert.deepEqual(got, ["a", "b", "c"]); });