// End-to-end test of the sandbox FRAME runtime's bridge (sandbox-frame.js), // driven the way the host (sandbox.js) drives it — over a real MessageChannel, // with a minimal DOM shim so the untrusted runtime boots as it does in the // browser. Verifies the two capabilities that only exist across the bridge: // companion chat projection (reported alongside state) and slash commands // (handler runs in-frame, emits bridge out, reply comes back). // node test/sandbox-bridge.test.mjs import test from "node:test"; import assert from "node:assert/strict"; import { MessageChannel } from "node:worker_threads"; // ---- minimal DOM + window shim --------------------------------------------- // bootFrame() runs once per module import and accepts exactly ONE init, so each // test gets a fresh frame via a cache-busted re-import against a fresh window. const PARENT = { name: "parent-window" }; function fakeNode() { const node = { style: { setProperty() {}, cssText: "" }, dataset: {}, children: [], textContent: "", appendChild(c) { this.children.push(c); return c; }, prepend() {}, remove() {}, querySelector: () => null, setAttribute() {}, addEventListener() {}, classList: { add() {}, toggle() {} }, }; return node; } function freshDom() { globalThis.document = { createElement: () => fakeNode(), body: fakeNode(), head: fakeNode(), documentElement: { style: { setProperty() {}, colorScheme: "" } }, getElementById: () => null, addEventListener() {}, // the frame installs a global submit guard on boot }; } let bust = 0; // connect boots a fresh frame against a fresh window, delivers an init with our // MessageChannel, and returns a driver ({ post, next, close }). async function connect({ source, manifest, seed, extract }) { freshDom(); const listeners = {}; globalThis.window = { parent: PARENT, addEventListener: (t, fn) => ((listeners[t] ||= []).push(fn)), removeEventListener: (t, fn) => { listeners[t] = (listeners[t] || []).filter((f) => f !== fn); }, }; await import(`../web/static/sandbox-frame.js?b=${++bust}`); // triggers bootFrame() const chan = new MessageChannel(); const inbox = []; const waiters = []; chan.port1.onmessage = (ev) => { inbox.push(ev.data); for (const w of waiters.splice(0)) w(); }; // Simulate window.postMessage(init) from the parent — call the frame's // registered "message" listener directly with a MessageEvent-shaped object. for (const fn of listeners.message || []) { const data = extract ? { type: "lchat-sandbox-init", extract: true, source } : { type: "lchat-sandbox-init", source, manifest, seed, me: { uid: "u1", name: "Ana" }, theme: {} }; fn({ source: PARENT, origin: "http://instance.test", data, ports: [chan.port2] }); } const next = (pred) => new Promise((resolve) => { const check = () => { const i = inbox.findIndex(pred); if (i >= 0) return resolve(inbox.splice(i, 1)[0]); waiters.push(check); }; check(); }); // port1 is our end (bidirectional): posting on it reaches the frame's port2 // listener; the frame's replies arrive on our port1.onmessage above. return { post: (m) => chan.port1.postMessage(m), next, close: () => { chan.port1.close(); chan.port2.close(); }, }; } const COMPANION_APP = ` import { defineApp, addItem } from "../apps.js"; export const app = defineApp({ name: "todo", title: "To-do", glyph: "T", chat: true, initialState: () => ({ items: [] }), events: { "todo.add": (s, e) => (e.text ? { items: addItem(s.items, { id: e.id, text: e.text }) } : s), "todo.done": (s, e) => ({ items: s.items.map((i) => (i.id === e.id ? { ...i, done: true } : i)) }), }, chatMessage: (e) => { if (e.type === "todo.add") return { text: "added " + e.text, action: true }; return null; // done is silent }, commands: { "/todo": (args, ctx) => { const text = args.trim(); if (!text) return ctx.reply({ text: "Usage: /todo " }); ctx.emit({ type: "todo.add", id: ctx.id(), text }); ctx.reply({ text: "Added “" + text + "”", short: "added" }); }, }, render() {}, }); `; const MANIFEST = { v: 1, name: "todo", title: "To-do", glyph: "T", types: ["todo.add", "todo.done"], chat: true, commands: [{ name: "/todo", desc: "Add", args: "" }] }; test("frame reports state + companion chat lines from seeded events", async () => { const d = await connect({ source: COMPANION_APP, manifest: MANIFEST, seed: { v: null, vUpTo: 0, pend: [ { e: { type: "todo.add", id: "a", text: "milk", ts: 10 }, meta: { id: 101, ts: 10 } }, { e: { type: "todo.add", id: "b", text: "eggs", ts: 11 }, meta: { id: 102, ts: 11 } }, { e: { type: "todo.done", id: "a", ts: 12 }, meta: { id: 103, ts: 12 } }, ], chat: [], }, }); const st = await d.next((m) => m.type === "state"); assert.equal(st.upTo, 103); assert.equal(st.state.items.length, 2); assert.equal(st.state.items.find((i) => i.id === "a").done, true); // chatMessage projected the two adds (action lines), not the done. assert.equal(st.chat.length, 2); assert.deepEqual(st.chat.map((c) => c.text), ["added milk", "added eggs"]); assert.equal(st.chat[0].mid, 101); assert.equal(st.chat[0].action, true); d.close(); }); // The App Studio starter: initialState + a render that reads a state field. With // no events to fold, the frame must still seed initialState so render doesn't see // `undefined` (regression: "Cannot read properties of undefined (reading 'count')"). const HELLO_APP = ` import { defineApp } from "../apps.js"; export const app = defineApp({ name: "hello", title: "Hello", glyph: "H", initialState: () => ({ count: 0 }), events: { "hello.tap": (s) => ({ count: s.count + 1 }) }, render(state, el) { el.textContent = "count " + state.count; }, }); `; test("a fresh app with no events reports its initialState (not null)", async () => { const d = await connect({ source: HELLO_APP, manifest: { v: 1, name: "hello", title: "Hello", glyph: "H", types: ["hello.tap"], chat: false, commands: [] }, seed: { v: null, vUpTo: 0, pend: [], chat: [] }, }); const st = await d.next((m) => m.type === "state"); assert.deepEqual(st.state, { count: 0 }); assert.equal(st.upTo, 0); d.close(); }); test("a live event folds, re-projects, and re-reports", async () => { const d = await connect({ source: COMPANION_APP, manifest: MANIFEST, seed: { v: null, vUpTo: 0, pend: [], chat: [] } }); await d.next((m) => m.type === "state"); // initial (empty) d.post({ type: "events", batch: [{ e: { type: "todo.add", id: "c", text: "bread", ts: 20 }, meta: { id: 201, ts: 20 } }] }); const st = await d.next((m) => m.type === "state" && m.upTo === 201); assert.equal(st.state.items.length, 1); assert.deepEqual(st.chat.map((c) => c.text), ["added bread"]); d.close(); }); test("a slash command runs in-frame: emits the event and returns its reply", async () => { const d = await connect({ source: COMPANION_APP, manifest: MANIFEST, seed: { v: null, vUpTo: 0, pend: [], chat: [] } }); await d.next((m) => m.type === "state"); d.post({ type: "command", seq: 7, cmd: "/todo", args: "call mum" }); const emit = await d.next((m) => m.type === "emit"); assert.equal(emit.event.type, "todo.add"); assert.equal(emit.event.text, "call mum"); const reply = await d.next((m) => m.type === "command-reply" && m.seq === 7); assert.deepEqual(reply.spec, { text: "Added “call mum”", short: "added" }); d.close(); }); test("a command with bad input replies usage and emits nothing", async () => { const d = await connect({ source: COMPANION_APP, manifest: MANIFEST, seed: { v: null, vUpTo: 0, pend: [], chat: [] } }); await d.next((m) => m.type === "state"); d.post({ type: "command", seq: 8, cmd: "/todo", args: " " }); const reply = await d.next((m) => m.type === "command-reply" && m.seq === 8); assert.equal(reply.spec.text, "Usage: /todo "); d.close(); }); test("seeded companion chat is carried forward (survives compaction)", async () => { const d = await connect({ source: COMPANION_APP, manifest: MANIFEST, seed: { v: { items: [{ id: "old", text: "past" }] }, vUpTo: 50, pend: [{ e: { type: "todo.add", id: "n", text: "new", ts: 60 }, meta: { id: 60, ts: 60 } }], chat: [{ mid: 40, ts: 40, text: "added past", action: true }], }, }); const st = await d.next((m) => m.type === "state"); // Past line from the snapshot + the freshly-projected one. assert.deepEqual(st.chat.map((c) => c.text), ["added past", "added new"]); assert.equal(st.state.items.length, 2); d.close(); }); test("extract mode reports a manifest derived from the real defineApp", async () => { const d = await connect({ source: COMPANION_APP, extract: true }); const m = await d.next((x) => x.type === "extracted"); assert.ok(!m.error, m.error); assert.equal(m.manifest.name, "todo"); assert.equal(m.manifest.title, "To-do"); assert.equal(m.manifest.glyph, "T"); assert.deepEqual(m.manifest.places, ["you"], "a minted manifest speaks places, the current vocabulary"); assert.deepEqual(m.manifest.types.sort(), ["todo.add", "todo.done"]); assert.equal(m.manifest.chat, true); assert.deepEqual(m.manifest.commands, [{ name: "/todo", desc: "", args: "" }]); d.close(); }); test("extract mode reports the error for broken source", async () => { const d = await connect({ source: `import { nope } from "../evil.js"; nope();`, extract: true }); const m = await d.next((x) => x.type === "extracted"); assert.ok(m.error, "expected an extraction error"); assert.match(m.error, /apps\.js|format\.js/); d.close(); });