| 1 | // End-to-end test of the sandbox FRAME runtime's bridge (sandbox-frame.js), |
| 2 | // driven the way the host (sandbox.js) drives it — over a real MessageChannel, |
| 3 | // with a minimal DOM shim so the untrusted runtime boots as it does in the |
| 4 | // browser. Verifies the two capabilities that only exist across the bridge: |
| 5 | // companion chat projection (reported alongside state) and slash commands |
| 6 | // (handler runs in-frame, emits bridge out, reply comes back). |
| 7 | // node test/sandbox-bridge.test.mjs |
| 8 | import test from "node:test"; |
| 9 | import assert from "node:assert/strict"; |
| 10 | import { MessageChannel } from "node:worker_threads"; |
| 11 | |
| 12 | // ---- minimal DOM + window shim --------------------------------------------- |
| 13 | // bootFrame() runs once per module import and accepts exactly ONE init, so each |
| 14 | // test gets a fresh frame via a cache-busted re-import against a fresh window. |
| 15 | const PARENT = { name: "parent-window" }; |
| 16 | function fakeNode() { |
| 17 | const node = { |
| 18 | style: { setProperty() {}, cssText: "" }, |
| 19 | dataset: {}, |
| 20 | children: [], |
| 21 | textContent: "", |
| 22 | appendChild(c) { |
| 23 | this.children.push(c); |
| 24 | return c; |
| 25 | }, |
| 26 | prepend() {}, |
| 27 | remove() {}, |
| 28 | querySelector: () => null, |
| 29 | setAttribute() {}, |
| 30 | addEventListener() {}, |
| 31 | classList: { add() {}, toggle() {} }, |
| 32 | }; |
| 33 | return node; |
| 34 | } |
| 35 | function freshDom() { |
| 36 | globalThis.document = { |
| 37 | createElement: () => fakeNode(), |
| 38 | body: fakeNode(), |
| 39 | head: fakeNode(), |
| 40 | documentElement: { style: { setProperty() {}, colorScheme: "" } }, |
| 41 | getElementById: () => null, |
| 42 | addEventListener() {}, // the frame installs a global submit guard on boot |
| 43 | }; |
| 44 | } |
| 45 | |
| 46 | let bust = 0; |
| 47 | // connect boots a fresh frame against a fresh window, delivers an init with our |
| 48 | // MessageChannel, and returns a driver ({ post, next, close }). |
| 49 | async function connect({ source, manifest, seed, extract }) { |
| 50 | freshDom(); |
| 51 | const listeners = {}; |
| 52 | globalThis.window = { |
| 53 | parent: PARENT, |
| 54 | addEventListener: (t, fn) => ((listeners[t] ||= []).push(fn)), |
| 55 | removeEventListener: (t, fn) => { |
| 56 | listeners[t] = (listeners[t] || []).filter((f) => f !== fn); |
| 57 | }, |
| 58 | }; |
| 59 | await import(`../web/static/sandbox-frame.js?b=${++bust}`); // triggers bootFrame() |
| 60 | |
| 61 | const chan = new MessageChannel(); |
| 62 | const inbox = []; |
| 63 | const waiters = []; |
| 64 | chan.port1.onmessage = (ev) => { |
| 65 | inbox.push(ev.data); |
| 66 | for (const w of waiters.splice(0)) w(); |
| 67 | }; |
| 68 | // Simulate window.postMessage(init) from the parent — call the frame's |
| 69 | // registered "message" listener directly with a MessageEvent-shaped object. |
| 70 | for (const fn of listeners.message || []) { |
| 71 | const data = extract |
| 72 | ? { type: "lchat-sandbox-init", extract: true, source } |
| 73 | : { type: "lchat-sandbox-init", source, manifest, seed, me: { uid: "u1", name: "Ana" }, theme: {} }; |
| 74 | fn({ source: PARENT, origin: "http://instance.test", data, ports: [chan.port2] }); |
| 75 | } |
| 76 | const next = (pred) => |
| 77 | new Promise((resolve) => { |
| 78 | const check = () => { |
| 79 | const i = inbox.findIndex(pred); |
| 80 | if (i >= 0) return resolve(inbox.splice(i, 1)[0]); |
| 81 | waiters.push(check); |
| 82 | }; |
| 83 | check(); |
| 84 | }); |
| 85 | // port1 is our end (bidirectional): posting on it reaches the frame's port2 |
| 86 | // listener; the frame's replies arrive on our port1.onmessage above. |
| 87 | return { |
| 88 | post: (m) => chan.port1.postMessage(m), |
| 89 | next, |
| 90 | close: () => { |
| 91 | chan.port1.close(); |
| 92 | chan.port2.close(); |
| 93 | }, |
| 94 | }; |
| 95 | } |
| 96 | |
| 97 | const COMPANION_APP = ` |
| 98 | import { defineApp, addItem } from "../apps.js"; |
| 99 | export const app = defineApp({ |
| 100 | name: "todo", title: "To-do", glyph: "T", chat: true, |
| 101 | initialState: () => ({ items: [] }), |
| 102 | events: { |
| 103 | "todo.add": (s, e) => (e.text ? { items: addItem(s.items, { id: e.id, text: e.text }) } : s), |
| 104 | "todo.done": (s, e) => ({ items: s.items.map((i) => (i.id === e.id ? { ...i, done: true } : i)) }), |
| 105 | }, |
| 106 | chatMessage: (e) => { |
| 107 | if (e.type === "todo.add") return { text: "added " + e.text, action: true }; |
| 108 | return null; // done is silent |
| 109 | }, |
| 110 | commands: { |
| 111 | "/todo": (args, ctx) => { |
| 112 | const text = args.trim(); |
| 113 | if (!text) return ctx.reply({ text: "Usage: /todo <text>" }); |
| 114 | ctx.emit({ type: "todo.add", id: ctx.id(), text }); |
| 115 | ctx.reply({ text: "Added “" + text + "”", short: "added" }); |
| 116 | }, |
| 117 | }, |
| 118 | render() {}, |
| 119 | }); |
| 120 | `; |
| 121 | const MANIFEST = { v: 1, name: "todo", title: "To-do", glyph: "T", types: ["todo.add", "todo.done"], chat: true, commands: [{ name: "/todo", desc: "Add", args: "<t>" }] }; |
| 122 | |
| 123 | test("frame reports state + companion chat lines from seeded events", async () => { |
| 124 | const d = await connect({ |
| 125 | source: COMPANION_APP, |
| 126 | manifest: MANIFEST, |
| 127 | seed: { |
| 128 | v: null, |
| 129 | vUpTo: 0, |
| 130 | pend: [ |
| 131 | { e: { type: "todo.add", id: "a", text: "milk", ts: 10 }, meta: { id: 101, ts: 10 } }, |
| 132 | { e: { type: "todo.add", id: "b", text: "eggs", ts: 11 }, meta: { id: 102, ts: 11 } }, |
| 133 | { e: { type: "todo.done", id: "a", ts: 12 }, meta: { id: 103, ts: 12 } }, |
| 134 | ], |
| 135 | chat: [], |
| 136 | }, |
| 137 | }); |
| 138 | const st = await d.next((m) => m.type === "state"); |
| 139 | assert.equal(st.upTo, 103); |
| 140 | assert.equal(st.state.items.length, 2); |
| 141 | assert.equal(st.state.items.find((i) => i.id === "a").done, true); |
| 142 | // chatMessage projected the two adds (action lines), not the done. |
| 143 | assert.equal(st.chat.length, 2); |
| 144 | assert.deepEqual(st.chat.map((c) => c.text), ["added milk", "added eggs"]); |
| 145 | assert.equal(st.chat[0].mid, 101); |
| 146 | assert.equal(st.chat[0].action, true); |
| 147 | d.close(); |
| 148 | }); |
| 149 | |
| 150 | // The App Studio starter: initialState + a render that reads a state field. With |
| 151 | // no events to fold, the frame must still seed initialState so render doesn't see |
| 152 | // `undefined` (regression: "Cannot read properties of undefined (reading 'count')"). |
| 153 | const HELLO_APP = ` |
| 154 | import { defineApp } from "../apps.js"; |
| 155 | export const app = defineApp({ |
| 156 | name: "hello", title: "Hello", glyph: "H", |
| 157 | initialState: () => ({ count: 0 }), |
| 158 | events: { "hello.tap": (s) => ({ count: s.count + 1 }) }, |
| 159 | render(state, el) { el.textContent = "count " + state.count; }, |
| 160 | }); |
| 161 | `; |
| 162 | |
| 163 | test("a fresh app with no events reports its initialState (not null)", async () => { |
| 164 | const d = await connect({ |
| 165 | source: HELLO_APP, |
| 166 | manifest: { v: 1, name: "hello", title: "Hello", glyph: "H", types: ["hello.tap"], chat: false, commands: [] }, |
| 167 | seed: { v: null, vUpTo: 0, pend: [], chat: [] }, |
| 168 | }); |
| 169 | const st = await d.next((m) => m.type === "state"); |
| 170 | assert.deepEqual(st.state, { count: 0 }); |
| 171 | assert.equal(st.upTo, 0); |
| 172 | d.close(); |
| 173 | }); |
| 174 | |
| 175 | test("a live event folds, re-projects, and re-reports", async () => { |
| 176 | const d = await connect({ source: COMPANION_APP, manifest: MANIFEST, seed: { v: null, vUpTo: 0, pend: [], chat: [] } }); |
| 177 | await d.next((m) => m.type === "state"); // initial (empty) |
| 178 | d.post({ type: "events", batch: [{ e: { type: "todo.add", id: "c", text: "bread", ts: 20 }, meta: { id: 201, ts: 20 } }] }); |
| 179 | const st = await d.next((m) => m.type === "state" && m.upTo === 201); |
| 180 | assert.equal(st.state.items.length, 1); |
| 181 | assert.deepEqual(st.chat.map((c) => c.text), ["added bread"]); |
| 182 | d.close(); |
| 183 | }); |
| 184 | |
| 185 | test("a slash command runs in-frame: emits the event and returns its reply", async () => { |
| 186 | const d = await connect({ source: COMPANION_APP, manifest: MANIFEST, seed: { v: null, vUpTo: 0, pend: [], chat: [] } }); |
| 187 | await d.next((m) => m.type === "state"); |
| 188 | d.post({ type: "command", seq: 7, cmd: "/todo", args: "call mum" }); |
| 189 | const emit = await d.next((m) => m.type === "emit"); |
| 190 | assert.equal(emit.event.type, "todo.add"); |
| 191 | assert.equal(emit.event.text, "call mum"); |
| 192 | const reply = await d.next((m) => m.type === "command-reply" && m.seq === 7); |
| 193 | assert.deepEqual(reply.spec, { text: "Added “call mum”", short: "added" }); |
| 194 | d.close(); |
| 195 | }); |
| 196 | |
| 197 | test("a command with bad input replies usage and emits nothing", async () => { |
| 198 | const d = await connect({ source: COMPANION_APP, manifest: MANIFEST, seed: { v: null, vUpTo: 0, pend: [], chat: [] } }); |
| 199 | await d.next((m) => m.type === "state"); |
| 200 | d.post({ type: "command", seq: 8, cmd: "/todo", args: " " }); |
| 201 | const reply = await d.next((m) => m.type === "command-reply" && m.seq === 8); |
| 202 | assert.equal(reply.spec.text, "Usage: /todo <text>"); |
| 203 | d.close(); |
| 204 | }); |
| 205 | |
| 206 | test("seeded companion chat is carried forward (survives compaction)", async () => { |
| 207 | const d = await connect({ |
| 208 | source: COMPANION_APP, |
| 209 | manifest: MANIFEST, |
| 210 | seed: { |
| 211 | v: { items: [{ id: "old", text: "past" }] }, |
| 212 | vUpTo: 50, |
| 213 | pend: [{ e: { type: "todo.add", id: "n", text: "new", ts: 60 }, meta: { id: 60, ts: 60 } }], |
| 214 | chat: [{ mid: 40, ts: 40, text: "added past", action: true }], |
| 215 | }, |
| 216 | }); |
| 217 | const st = await d.next((m) => m.type === "state"); |
| 218 | // Past line from the snapshot + the freshly-projected one. |
| 219 | assert.deepEqual(st.chat.map((c) => c.text), ["added past", "added new"]); |
| 220 | assert.equal(st.state.items.length, 2); |
| 221 | d.close(); |
| 222 | }); |
| 223 | |
| 224 | test("extract mode reports a manifest derived from the real defineApp", async () => { |
| 225 | const d = await connect({ source: COMPANION_APP, extract: true }); |
| 226 | const m = await d.next((x) => x.type === "extracted"); |
| 227 | assert.ok(!m.error, m.error); |
| 228 | assert.equal(m.manifest.name, "todo"); |
| 229 | assert.equal(m.manifest.title, "To-do"); |
| 230 | assert.equal(m.manifest.glyph, "T"); |
| 231 | assert.deepEqual(m.manifest.places, ["you"], "a minted manifest speaks places, the current vocabulary"); |
| 232 | assert.deepEqual(m.manifest.types.sort(), ["todo.add", "todo.done"]); |
| 233 | assert.equal(m.manifest.chat, true); |
| 234 | assert.deepEqual(m.manifest.commands, [{ name: "/todo", desc: "", args: "" }]); |
| 235 | d.close(); |
| 236 | }); |
| 237 | |
| 238 | test("extract mode reports the error for broken source", async () => { |
| 239 | const d = await connect({ source: `import { nope } from "../evil.js"; nope();`, extract: true }); |
| 240 | const m = await d.next((x) => x.type === "extracted"); |
| 241 | assert.ok(m.error, "expected an extraction error"); |
| 242 | assert.match(m.error, /apps\.js|format\.js/); |
| 243 | d.close(); |
| 244 | }); |
| 245 | |