open-standards/wai/wai-app/web/web.test.mjs
Transaction Science 608cd843e9 wai-app: Create's crate, a library with no binary, whose export wai create writes
wai/wai-ui becomes wai/wai-app, the library `wai_app`, with no binary and no `cli`
feature: `wai`, built with its feature `app`, is its command line and its MCP
server. Its served files are named wai-app-* (the WebAssembly package wai_app),
the recordings wai-app-*.json.

- export.rs gives the files of a starter project for a program and the facts its
  project file holds (title, summary, `wai dev`'s mode for the app's room, a watch
  room's file, a show's audience); `wai create --from app.oui` writes them, the
  project file and the licence. The export no longer writes app.json, room.toml,
  relay.toml, package.json, check.mjs or serve.mjs: wai.json names the room, `wai
  dev` runs it and `wai app check` checks the project. Its README and AGENTS.md
  say so, and the README its licence.
- runtime.rs reads a Create runtime by its listing (create/wai-app-runtime.json),
  checking each file's SHA-256.
- bridge.rs keeps the bridge on 127.0.0.1 a coding agent streams a program into
  the page through, with a stream helper; the stdio server, its tools and the
  binary's test go (`wai mcp --toolset app` serves them).
- The page's Export hands over app.oui, the program in its canonical form, and the
  command that makes it a project; the ZIP writer and the page's export in
  WebAssembly go. The page names `wai mcp --toolset app` for the coding agent.
- joule-ui/docs/create.md §4 and §5: the export as `wai create --from app.oui`, and
  the agent's tools app_guide, app_check, app_stream and app_export.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 05:13:27 -04:00

105 lines
5.4 KiB
JavaScript

// 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"]);
});