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]>
105 lines
5.4 KiB
JavaScript
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"]);
|
|
});
|