eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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
8import test from "node:test";
9import assert from "node:assert/strict";
10import { 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.
15const PARENT = { name: "parent-window" };
16function 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}
35function 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
46let 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 }).
49async 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
97const COMPANION_APP = `
98import { defineApp, addItem } from "../apps.js";
99export 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`;
121const 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
123test("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')").
153const HELLO_APP = `
154import { defineApp } from "../apps.js";
155export 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
163test("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
175test("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
185test("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
197test("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
206test("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
224test("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
238test("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