| 1 | // Tests for the member-app sandbox (web/static/sandbox.js — the host side — |
| 2 | // and web/static/sandbox-frame.js — the in-frame runtime). |
| 3 | // node test/sandbox.test.mjs |
| 4 | import test from "node:test"; |
| 5 | import assert from "node:assert/strict"; |
| 6 | |
| 7 | import { |
| 8 | addItem as hostAddItem, |
| 9 | updateItem as hostUpdateItem, |
| 10 | removeItem as hostRemoveItem, |
| 11 | partitionByGrace as hostPartition, |
| 12 | DONE_GRACE_MS as HOST_GRACE, |
| 13 | namespace as hostNamespace, |
| 14 | defineApp, |
| 15 | foldEvent, |
| 16 | appStateFor, |
| 17 | seedThread, |
| 18 | takeSnapshot, |
| 19 | resetThread, |
| 20 | installMemberAppState, |
| 21 | isAppType, |
| 22 | appByName, |
| 23 | groupCapableApps, |
| 24 | appIsQuiet, |
| 25 | commandTakenBy, |
| 26 | } from "../web/static/apps.js"; |
| 27 | import { |
| 28 | addItem, |
| 29 | updateItem, |
| 30 | removeItem, |
| 31 | partitionByGrace, |
| 32 | DONE_GRACE_MS, |
| 33 | namespace, |
| 34 | ALLOWED_IMPORTS, |
| 35 | ALLOWED_FMT, |
| 36 | stripCode, |
| 37 | transformModuleSource, |
| 38 | evalMemberApp, |
| 39 | normalizeCommands, |
| 40 | manifestOf, |
| 41 | isSubmitTrigger, |
| 42 | isEnterSubmit, |
| 43 | } from "../web/static/sandbox-frame.js"; |
| 44 | import { validateManifest, cleanJSON, sanitizeAppEvent, sanitizeChat, sanitizePanelSpec, registerMemberApp, resolveDark, manifestIsLegacy } from "../web/static/sandbox.js"; |
| 45 | import { tokenizeText as realTokenize, formatBytes as realBytes } from "../web/static/format.js"; |
| 46 | |
| 47 | // ---- SDK drift pins: the sandbox copies must behave exactly like apps.js ---- |
| 48 | |
| 49 | test("sandbox SDK helpers match apps.js behaviour", () => { |
| 50 | const list = [{ id: "a", n: 1 }]; |
| 51 | for (const [host, sand] of [ |
| 52 | [hostAddItem, addItem], |
| 53 | [hostUpdateItem, updateItem], |
| 54 | [hostRemoveItem, removeItem], |
| 55 | ]) { |
| 56 | assert.equal(typeof host, "function"); |
| 57 | assert.equal(typeof sand, "function"); |
| 58 | } |
| 59 | assert.deepEqual(addItem(list, { id: "b" }), hostAddItem(list, { id: "b" })); |
| 60 | assert.deepEqual(addItem(list, { id: "a" }), hostAddItem(list, { id: "a" })); // dedupe |
| 61 | assert.deepEqual(updateItem(list, "a", { n: 2 }), hostUpdateItem(list, "a", { n: 2 })); |
| 62 | assert.deepEqual(removeItem(list, "a"), hostRemoveItem(list, "a")); |
| 63 | assert.equal(DONE_GRACE_MS, HOST_GRACE); |
| 64 | const items = [{ id: 1, d: 0 }, { id: 2, d: 900 }, { id: 3, d: 100 }]; |
| 65 | assert.deepEqual( |
| 66 | partitionByGrace(items, (i) => i.d, 1000, 500), |
| 67 | hostPartition(items, (i) => i.d, 1000, 500), |
| 68 | ); |
| 69 | // namespace() must behave identically to the in-page one (same wire types + classes). |
| 70 | const s = namespace("todo"); |
| 71 | const h = hostNamespace("todo"); |
| 72 | assert.deepEqual([s.ns, s.ev("add"), s.cls("item"), s.ev("chat.msg")], [h.ns, h.ev("add"), h.cls("item"), h.ev("chat.msg")]); |
| 73 | }); |
| 74 | |
| 75 | test("namespace is an allowed import and works inside a member app", () => { |
| 76 | assert.ok(ALLOWED_IMPORTS.includes("namespace"), "namespace must be importable by a member app"); |
| 77 | // A member app declaring its types via namespace() gets the same owned types as |
| 78 | // one that spells them out — the wire strings are identical either way. |
| 79 | const src = ` |
| 80 | import { defineApp, namespace, addItem } from "../apps.js"; |
| 81 | const N = namespace("habit"); |
| 82 | const app = defineApp({ |
| 83 | name: "habit", title: "Habit", glyph: "H", |
| 84 | initialState: () => ({ items: [] }), |
| 85 | events: { |
| 86 | [N.ev("add")]: (s, e) => ({ items: addItem(s.items, { id: e.id, text: e.text }) }), |
| 87 | [N.ev("tick")]: (s) => s, |
| 88 | }, |
| 89 | render() {}, |
| 90 | }); |
| 91 | `; |
| 92 | const spec = evalMemberApp(src, "habit"); |
| 93 | assert.deepEqual(spec.types.sort(), ["habit.add", "habit.tick"].sort()); |
| 94 | assert.equal(spec.ns.ev("add"), "habit.add"); |
| 95 | assert.equal(spec.ns.cls("row"), "habit-row"); |
| 96 | // The derived manifest still validates — its types are name-prefixed, as the |
| 97 | // sandbox requires (namespace defaults to name). |
| 98 | assert.equal(validateManifest(manifestOf(spec)), null); |
| 99 | }); |
| 100 | |
| 101 | // ---- module transform ------------------------------------------------------- |
| 102 | |
| 103 | test("transformModuleSource rewrites the legal import and strips exports", () => { |
| 104 | const out = transformModuleSource( |
| 105 | `import { defineApp, addItem as add } from "../apps.js";\nexport const app = defineApp({});\nexport default app;\n`, |
| 106 | ); |
| 107 | assert.match(out, /const \{ defineApp, addItem: add \} = __sdk;/); |
| 108 | assert.match(out, /const app = defineApp/); |
| 109 | assert.match(out, /const __defaultExport = app/); |
| 110 | assert.doesNotMatch(out, /(^|[^\w$])export\s/); |
| 111 | }); |
| 112 | |
| 113 | test("transformModuleSource rejects contract violations", () => { |
| 114 | assert.throws(() => transformModuleSource(`import { foldEvent } from "../apps.js";`), /not an allowed import/); |
| 115 | assert.throws(() => transformModuleSource(`import { defineApp } from "../format.js";`), /not an allowed import/); |
| 116 | assert.throws(() => transformModuleSource(`import { nameList } from "../format.js";`), /not an allowed import/); |
| 117 | assert.throws(() => transformModuleSource(`import x from "https://evil.example/x.js";`), /apps\.js/); |
| 118 | assert.throws(() => transformModuleSource(`const m = await import("x");`), /apps\.js/); |
| 119 | assert.throws(() => transformModuleSource(""), /empty/); |
| 120 | // Prose mentioning import in comments/strings is fine. |
| 121 | transformModuleSource(`// never use import() here\nconst s = "import nothing";\nconst app = 1;`); |
| 122 | }); |
| 123 | |
| 124 | test("transformModuleSource accepts format.js helper imports", () => { |
| 125 | const out = transformModuleSource( |
| 126 | `import { defineApp } from "../apps.js";\nimport { tokenizeText, formatBytes as fb } from "../format.js";\nconst app = 1;`, |
| 127 | ); |
| 128 | assert.match(out, /const \{ defineApp \} = __sdk;/); |
| 129 | assert.match(out, /const \{ tokenizeText, formatBytes: fb \} = __fmt;/); |
| 130 | }); |
| 131 | |
| 132 | test("stripCode drops comments and string contents", () => { |
| 133 | const out = stripCode(`const a = "import x"; // import y\n/* import z */ real();`); |
| 134 | assert.doesNotMatch(out, /import/); |
| 135 | assert.match(out, /real\(\)/); |
| 136 | }); |
| 137 | |
| 138 | // ---- evalMemberApp: real generated-app shape runs against the shim SDK ------ |
| 139 | |
| 140 | const NOTES_APP = ` |
| 141 | import { defineApp, addItem, removeItem } from "../apps.js"; |
| 142 | export const app = defineApp({ |
| 143 | name: "notes", |
| 144 | title: "Notes", |
| 145 | glyph: "📝", |
| 146 | initialState: () => ({ items: [] }), |
| 147 | events: { |
| 148 | "notes.add": (s, e) => (e.id && e.text ? { items: addItem(s.items, { id: e.id, text: e.text }) } : s), |
| 149 | "notes.remove": (s, e) => ({ items: removeItem(s.items, e.id) }), |
| 150 | }, |
| 151 | render(state, el, ctx) {}, |
| 152 | }); |
| 153 | `; |
| 154 | |
| 155 | test("evalMemberApp captures the spec and synthesises a working reducer", () => { |
| 156 | const spec = evalMemberApp(NOTES_APP, "notes"); |
| 157 | assert.equal(spec.name, "notes"); |
| 158 | assert.deepEqual(spec.types, ["notes.add", "notes.remove"]); |
| 159 | let s = spec.reduce(undefined, { type: "notes.add", id: "a", text: "hi" }); |
| 160 | s = spec.reduce(s, { type: "notes.add", id: "a", text: "hi" }); // idempotent |
| 161 | assert.equal(s.items.length, 1); |
| 162 | s = spec.reduce(s, { type: "unrelated.event" }); |
| 163 | assert.equal(s.items.length, 1); |
| 164 | s = spec.reduce(s, { type: "notes.remove", id: "a" }); |
| 165 | assert.equal(s.items.length, 0); |
| 166 | }); |
| 167 | |
| 168 | test("evalMemberApp enforces the manifest name and single registration", () => { |
| 169 | assert.throws(() => evalMemberApp(NOTES_APP, "other"), /does not match/); |
| 170 | assert.throws(() => evalMemberApp(`const x = 1;`, "notes"), /never called defineApp/); |
| 171 | assert.throws( |
| 172 | () => evalMemberApp(`import { defineApp } from "../apps.js";\ndefineApp({name:"a"});\ndefineApp({name:"a"});`, "a"), |
| 173 | /more than once/, |
| 174 | ); |
| 175 | }); |
| 176 | |
| 177 | // ---- format helpers in the sandbox ----------------------------------------- |
| 178 | |
| 179 | test("format helpers are wired into the sandbox and behave like format.js", () => { |
| 180 | assert.deepEqual(ALLOWED_FMT.sort(), [ |
| 181 | "autoGroupTitle", "blockTokens", "emojiOnlyCount", "firstLink", "formatBytes", |
| 182 | "replySnippet", "richTokens", "tokenizeText", "typingSummary", "youtubeId", |
| 183 | ].sort()); |
| 184 | // An app that imports and uses a format helper runs, and the helper produces |
| 185 | // the same result as the real format.js module. |
| 186 | const src = ` |
| 187 | import { defineApp } from "../apps.js"; |
| 188 | import { tokenizeText, formatBytes } from "../format.js"; |
| 189 | globalThis.__fmtProbe = { toks: tokenizeText("see http://x.com"), size: formatBytes(2048) }; |
| 190 | const app = defineApp({ name: "fx", title: "FX", glyph: "F", initialState: () => ({}), events: { "fx.e": (s) => s }, render() {} }); |
| 191 | `; |
| 192 | evalMemberApp(src, "fx"); |
| 193 | assert.deepEqual(globalThis.__fmtProbe.toks, realTokenize("see http://x.com")); |
| 194 | assert.equal(globalThis.__fmtProbe.size, realBytes(2048)); |
| 195 | delete globalThis.__fmtProbe; |
| 196 | }); |
| 197 | |
| 198 | // ---- commands in the sandbox ----------------------------------------------- |
| 199 | |
| 200 | test("evalMemberApp normalizes commands and the handler runs against a ctx", () => { |
| 201 | const src = ` |
| 202 | import { defineApp } from "../apps.js"; |
| 203 | const app = defineApp({ |
| 204 | name: "cmd", title: "Cmd", glyph: "C", |
| 205 | initialState: () => ({ items: [] }), |
| 206 | events: { "cmd.add": (s, e) => ({ items: [...s.items, e.text] }) }, |
| 207 | commands: { |
| 208 | "/cmd": { desc: "add", args: "<t>", run: (args, ctx) => { |
| 209 | if (!args) return ctx.reply({ text: "usage" }); |
| 210 | ctx.emit({ type: "cmd.add", id: ctx.id(), text: args }); |
| 211 | ctx.reply({ text: "added " + args, short: "added" }); |
| 212 | } }, |
| 213 | }, |
| 214 | render() {}, |
| 215 | }); |
| 216 | `; |
| 217 | const spec = evalMemberApp(src, "cmd"); |
| 218 | assert.equal(typeof spec.commands["/cmd"].run, "function"); |
| 219 | assert.equal(spec.commands["/cmd"].desc, "add"); |
| 220 | const emits = []; |
| 221 | let replied = null; |
| 222 | const ctx = { emit: (e) => emits.push(e), id: () => "id1", reply: (r) => (replied = r) }; |
| 223 | spec.commands["/cmd"].run("buy milk", ctx); |
| 224 | assert.deepEqual(emits, [{ type: "cmd.add", id: "id1", text: "buy milk" }]); |
| 225 | assert.deepEqual(replied, { text: "added buy milk", short: "added" }); |
| 226 | // Bad input → usage reply, no emit. |
| 227 | emits.length = 0; |
| 228 | spec.commands["/cmd"].run("", ctx); |
| 229 | assert.equal(emits.length, 0); |
| 230 | assert.deepEqual(replied, { text: "usage" }); |
| 231 | }); |
| 232 | |
| 233 | test("manifestOf derives a valid manifest from an evaluated spec", () => { |
| 234 | const spec = evalMemberApp( |
| 235 | `import { defineApp } from "../apps.js"; |
| 236 | const app = defineApp({ |
| 237 | name: "mx", title: "MX", glyph: "🧩", |
| 238 | initialState: () => ({}), |
| 239 | events: { "mx.a": (s) => s, "mx.b": (s) => s }, |
| 240 | commands: { "/mx": { run: () => {}, desc: "do", args: "<x>" } }, |
| 241 | chatMessage: () => null, |
| 242 | render() {}, |
| 243 | });`, |
| 244 | "mx", |
| 245 | ); |
| 246 | const m = manifestOf(spec); |
| 247 | assert.equal(validateManifest(m), null); // the derived manifest is valid |
| 248 | assert.deepEqual(m.places, ["you"], "nothing declared → the personal place, in the current vocabulary"); |
| 249 | assert.deepEqual(m.types.sort(), ["mx.a", "mx.b"]); |
| 250 | assert.deepEqual(m.commands, [{ name: "/mx", desc: "do", args: "<x>" }]); |
| 251 | assert.equal(m.chat, true); |
| 252 | assert.equal(m.defaultCommand, undefined); // none declared |
| 253 | |
| 254 | // defaultCommand is derived and must name a declared command. |
| 255 | const dc = manifestOf( |
| 256 | evalMemberApp( |
| 257 | `import { defineApp } from "../apps.js"; |
| 258 | const app = defineApp({ |
| 259 | name: "dc", title: "DC", glyph: "D", initialState: () => ({}), |
| 260 | events: { "dc.add": (s) => s }, |
| 261 | commands: { "/add": (a, ctx) => {} }, defaultCommand: "/add", |
| 262 | render() {}, |
| 263 | });`, |
| 264 | "dc", |
| 265 | ), |
| 266 | ); |
| 267 | assert.equal(dc.defaultCommand, "/add"); |
| 268 | assert.equal(validateManifest(dc), null); |
| 269 | // A defaultCommand that isn't a declared command is rejected. |
| 270 | assert.match(validateManifest({ ...dc, defaultCommand: "/nope" }), /defaultCommand/); |
| 271 | // No chatMessage → chat false. |
| 272 | const plain = evalMemberApp( |
| 273 | `import { defineApp } from "../apps.js"; |
| 274 | const app = defineApp({ name: "px", title: "PX", glyph: "P", initialState: () => ({}), events: { "px.a": (s) => s }, render() {} });`, |
| 275 | "px", |
| 276 | ); |
| 277 | assert.equal(manifestOf(plain).chat, false); |
| 278 | assert.deepEqual(manifestOf(plain).commands, []); |
| 279 | |
| 280 | // Placement is read from what the source declares — it can't be derived |
| 281 | // from code shape the way chat is, since it's a trust decision. A legacy |
| 282 | // groupCapable source normalizes into places; a places-first source passes |
| 283 | // through — either way the minted manifest speaks the current vocabulary. |
| 284 | const grouped = evalMemberApp( |
| 285 | `import { defineApp } from "../apps.js"; |
| 286 | const app = defineApp({ |
| 287 | name: "gx", title: "GX", glyph: "G", groupCapable: true, |
| 288 | initialState: () => ({}), events: { "gx.a": (s) => s }, render() {}, |
| 289 | });`, |
| 290 | "gx", |
| 291 | ); |
| 292 | assert.deepEqual(manifestOf(grouped).places, ["you", "chat"]); |
| 293 | const placed = evalMemberApp( |
| 294 | `import { defineApp } from "../apps.js"; |
| 295 | const app = defineApp({ |
| 296 | name: "plx", title: "PLX", glyph: "P", places: ["you", "chat"], |
| 297 | initialState: () => ({}), events: { "plx.a": (s) => s }, render() {}, |
| 298 | });`, |
| 299 | "plx", |
| 300 | ); |
| 301 | assert.deepEqual(manifestOf(placed).places, ["you", "chat"]); |
| 302 | |
| 303 | // quiet is read verbatim too (issue #227), same as groupCapable — a trust/ |
| 304 | // behavior decision the author declares, not derivable from code shape. |
| 305 | const quiet = evalMemberApp( |
| 306 | `import { defineApp } from "../apps.js"; |
| 307 | const app = defineApp({ |
| 308 | name: "qx", title: "QX", glyph: "Q", quiet: true, |
| 309 | initialState: () => ({}), events: { "qx.a": (s) => s }, render() {}, |
| 310 | });`, |
| 311 | "qx", |
| 312 | ); |
| 313 | assert.equal(manifestOf(quiet).quiet, true); |
| 314 | assert.equal(manifestOf(grouped).quiet, false); // not declared → false, not just absent |
| 315 | assert.equal(validateManifest(manifestOf(quiet)), null); |
| 316 | }); |
| 317 | |
| 318 | test("normalizeCommands accepts a bare function or a {run} object", () => { |
| 319 | const fn = () => {}; |
| 320 | const out = normalizeCommands({ "/a": fn, "/b": { run: fn, desc: "d", args: "x" }, "/c": { desc: "no run" } }); |
| 321 | assert.deepEqual(Object.keys(out), ["/a", "/b"]); // "/c" has no run → dropped |
| 322 | assert.equal(out["/a"].run, fn); |
| 323 | assert.equal(out["/b"].desc, "d"); |
| 324 | }); |
| 325 | |
| 326 | // ---- companion projection -------------------------------------------------- |
| 327 | |
| 328 | test("a companion app's chatMessage projects events into chat lines", () => { |
| 329 | const src = ` |
| 330 | import { defineApp, addItem } from "../apps.js"; |
| 331 | const app = defineApp({ |
| 332 | name: "todo", title: "To-do", glyph: "T", chat: true, |
| 333 | initialState: () => ({ items: [] }), |
| 334 | events: { "todo.add": (s, e) => ({ items: addItem(s.items, { id: e.id, text: e.text }) }), |
| 335 | "todo.done": (s) => s }, |
| 336 | chatMessage: (e) => { |
| 337 | if (e.type === "todo.add") return { text: "added “" + e.text + "”", action: true }; |
| 338 | return null; // done is silent |
| 339 | }, |
| 340 | render() {}, |
| 341 | }); |
| 342 | `; |
| 343 | const spec = evalMemberApp(src, "todo"); |
| 344 | assert.equal(typeof spec.chatMessage, "function"); |
| 345 | const add = spec.chatMessage({ type: "todo.add", text: "milk" }); |
| 346 | assert.deepEqual(add, { text: "added “milk”", action: true }); |
| 347 | assert.equal(spec.chatMessage({ type: "todo.done", id: "x" }), null); |
| 348 | }); |
| 349 | |
| 350 | test("resolveDark: explicit wins, 'light dark' follows the OS", () => { |
| 351 | // Forced light/dark ignore the OS. |
| 352 | assert.equal(resolveDark("light", true), false); |
| 353 | assert.equal(resolveDark("dark", false), true); |
| 354 | // "light dark" (auto) follows the OS — the bug was reading this as always dark, |
| 355 | // which forced every sandbox app onto a black UA canvas in light mode. |
| 356 | assert.equal(resolveDark("light dark", false), false); |
| 357 | assert.equal(resolveDark("light dark", true), true); |
| 358 | // Unset/normal also follow the OS. |
| 359 | assert.equal(resolveDark("", false), false); |
| 360 | assert.equal(resolveDark("normal", true), true); |
| 361 | }); |
| 362 | |
| 363 | test("sanitizeChat clamps and drops malformed companion lines", () => { |
| 364 | const clean = sanitizeChat([ |
| 365 | { mid: 5, ts: 100, text: "hi", action: true }, |
| 366 | { mid: 6, text: "no ts" }, // ts defaults to 0 |
| 367 | { text: "no mid" }, // dropped |
| 368 | { mid: 7, text: "" }, // empty text dropped |
| 369 | "nope", // dropped |
| 370 | { mid: 8, ts: 1, text: "x".repeat(5000), action: "truthy" }, |
| 371 | ]); |
| 372 | assert.equal(clean.length, 3); // "no mid", empty text, and "nope" dropped |
| 373 | assert.deepEqual(clean[0], { mid: 5, ts: 100, text: "hi", action: true }); |
| 374 | assert.equal(clean[1].ts, 0); |
| 375 | assert.equal(clean[2].text.length, 400); // clamped |
| 376 | assert.equal(clean[2].action, true); |
| 377 | }); |
| 378 | |
| 379 | // ---- manifest + event validation --------------------------------------------- |
| 380 | |
| 381 | const MANIFEST = { v: 1, name: "notes", title: "Notes", glyph: "📝", types: ["notes.add", "notes.remove"] }; |
| 382 | |
| 383 | test("validateManifest accepts the good and names the bad", () => { |
| 384 | assert.equal(validateManifest(MANIFEST), null); |
| 385 | assert.match(validateManifest(null), /missing/); |
| 386 | assert.match(validateManifest({ ...MANIFEST, v: 2 }), /version/); |
| 387 | assert.match(validateManifest({ ...MANIFEST, name: "Bad Name" }), /name/); |
| 388 | assert.match(validateManifest({ ...MANIFEST, title: "" }), /title/); |
| 389 | assert.match(validateManifest({ ...MANIFEST, glyph: "" }), /glyph/); |
| 390 | assert.match(validateManifest({ ...MANIFEST, scope: "shared" }), /personal/); |
| 391 | assert.match(validateManifest({ ...MANIFEST, types: [] }), /types/); |
| 392 | assert.match(validateManifest({ ...MANIFEST, types: ["other.add"] }), /prefixed/); |
| 393 | assert.match(validateManifest({ ...MANIFEST, types: ["notes.<x>"] }), /bad event type/); |
| 394 | // Optional commands + chat. |
| 395 | assert.equal(validateManifest({ ...MANIFEST, commands: [{ name: "/note", desc: "d", args: "<t>" }] }), null); |
| 396 | assert.equal(validateManifest({ ...MANIFEST, chat: true }), null); |
| 397 | assert.match(validateManifest({ ...MANIFEST, commands: "no" }), /commands list/); |
| 398 | assert.match(validateManifest({ ...MANIFEST, commands: [{ name: "note" }] }), /command name/); // no leading slash |
| 399 | assert.match(validateManifest({ ...MANIFEST, commands: [{ name: "/UP" }] }), /command name/); |
| 400 | assert.match(validateManifest({ ...MANIFEST, commands: [{ name: "/n", desc: "x".repeat(90) }] }), /command desc/); |
| 401 | assert.match(validateManifest({ ...MANIFEST, chat: "yes" }), /chat flag/); |
| 402 | // Optional groupCapable — orthogonal to (and doesn't loosen) scope: personal. |
| 403 | assert.equal(validateManifest({ ...MANIFEST, groupCapable: true }), null); |
| 404 | assert.equal(validateManifest({ ...MANIFEST, groupCapable: false }), null); |
| 405 | assert.match(validateManifest({ ...MANIFEST, groupCapable: "yes" }), /groupCapable flag/); |
| 406 | assert.equal(validateManifest({ ...MANIFEST, groupCapable: true, scope: "personal" }), null); |
| 407 | }); |
| 408 | |
| 409 | test("cleanJSON admits plain data and rejects the hostile", () => { |
| 410 | assert.deepEqual(cleanJSON({ a: [1, "x", true, null] }), { a: [1, "x", true, null] }); |
| 411 | assert.equal(cleanJSON({ fn: () => {} }), undefined); |
| 412 | assert.equal(cleanJSON({ n: NaN }), undefined); |
| 413 | assert.equal(cleanJSON(JSON.parse('{"__proto__": {"polluted": 1}}')), undefined); |
| 414 | assert.equal(cleanJSON({ constructor: 1 }), undefined); |
| 415 | let deep = 1; |
| 416 | for (let i = 0; i < 12; i++) deep = { deep }; |
| 417 | assert.equal(cleanJSON(deep), undefined); |
| 418 | }); |
| 419 | |
| 420 | test("sanitizeAppEvent enforces ownership, strips transport fields, clamps notices", () => { |
| 421 | const types = MANIFEST.types; |
| 422 | assert.equal(sanitizeAppEvent("notes", types, { type: "chat.msg", text: "spoof" }), null); |
| 423 | assert.equal(sanitizeAppEvent("notes", types, { type: "notes.unknown" }), null); |
| 424 | assert.equal(sanitizeAppEvent("notes", types, "nope"), null); |
| 425 | const e = sanitizeAppEvent("notes", types, { |
| 426 | type: "notes.add", |
| 427 | id: "a", |
| 428 | text: "hi", |
| 429 | name: "Forged", // transport injects these — a frame must not choose them |
| 430 | ts: 1, |
| 431 | uid: "someone-else", |
| 432 | notice: { title: "T".repeat(300), body: "B", actions: [{ type: "set_theme" }] }, |
| 433 | }); |
| 434 | assert.equal(e.type, "notes.add"); |
| 435 | assert.equal(e.name, undefined); |
| 436 | assert.equal(e.ts, undefined); |
| 437 | assert.equal(e.uid, undefined); |
| 438 | assert.equal(e.notice.title.length, 120); |
| 439 | assert.equal(e.notice.actions, undefined); |
| 440 | // Oversized events are refused outright. |
| 441 | assert.equal(sanitizeAppEvent("notes", types, { type: "notes.add", big: "x".repeat(40000) }), null); |
| 442 | }); |
| 443 | |
| 444 | // ---- host proxy: queue fold + frame-state install + snapshot round-trip ------- |
| 445 | |
| 446 | test("registerMemberApp registers a queueing proxy; frame state installs and trims", () => { |
| 447 | const res = registerMemberApp({ id: "app-t1", manifest: MANIFEST, source: NOTES_APP, author: "Ana" }); |
| 448 | assert.equal(res.ok, true, res.reason); |
| 449 | assert.equal(isAppType("notes.add"), true); |
| 450 | |
| 451 | const tid = "thread-sandbox-test"; |
| 452 | resetThread(tid); |
| 453 | foldEvent(tid, { type: "notes.add", id: "a", text: "one", ts: 5 }, { id: 101, ts: 5 }); |
| 454 | foldEvent(tid, { type: "notes.add", id: "b", text: "two", ts: 6 }, { id: 102, ts: 6 }); |
| 455 | let st = appStateFor(tid, "notes"); |
| 456 | assert.equal(st.pend.length, 2); |
| 457 | assert.equal(st.v, null); |
| 458 | // Same server message id folds once (framework dedupe). |
| 459 | foldEvent(tid, { type: "notes.add", id: "b", text: "two", ts: 6 }, { id: 102, ts: 6 }); |
| 460 | assert.equal(appStateFor(tid, "notes").pend.length, 2); |
| 461 | |
| 462 | // The frame reports its fold through id 101 → v swaps in, pend trims. |
| 463 | installMemberAppState(tid, "notes", { items: [{ id: "a", text: "one" }] }, 101); |
| 464 | st = appStateFor(tid, "notes"); |
| 465 | assert.deepEqual(st.v.items, [{ id: "a", text: "one" }]); |
| 466 | assert.equal(st.vUpTo, 101); |
| 467 | assert.equal(st.pend.length, 1); |
| 468 | assert.equal(st.pend[0].meta.id, 102); |
| 469 | |
| 470 | // Snapshot → seed round-trips the {v, vUpTo, pend} wrapper unchanged. |
| 471 | const snap = takeSnapshot(tid); |
| 472 | assert.equal(snap.upTo, 102); |
| 473 | resetThread(tid); |
| 474 | seedThread(tid, snap.states, snap.upTo); |
| 475 | const seeded = appStateFor(tid, "notes"); |
| 476 | assert.deepEqual(seeded, st); |
| 477 | |
| 478 | // installMemberAppState is gated on the memberApp marker: a non-sandbox app |
| 479 | // can't have its state swapped through this doorway. |
| 480 | defineApp({ name: "plainapp-sbtest", title: "x", glyph: "x", scope: "personal", initialState: () => ({ n: 1 }), events: { "plainapp-sbtest.e": (s) => s } }); |
| 481 | foldEvent(tid, { type: "plainapp-sbtest.e" }, { id: 103, ts: 1 }); |
| 482 | installMemberAppState(tid, "plainapp-sbtest", { hacked: true }, 103); |
| 483 | assert.equal(appStateFor(tid, "plainapp-sbtest").hacked, undefined); |
| 484 | }); |
| 485 | |
| 486 | test("a groupCapable member app registers as group-capable; a personal one doesn't", () => { |
| 487 | const grouped = { v: 1, name: "gscore", title: "Scoreboard", glyph: "🏆", types: ["gscore.set"], groupCapable: true }; |
| 488 | const res = registerMemberApp({ id: "app-group1", manifest: grouped, source: NOTES_APP.replace(/notes/g, "gscore"), author: "Ana" }); |
| 489 | assert.equal(res.ok, true, res.reason); |
| 490 | const app = appByName("gscore"); |
| 491 | // The stored manifest's legacy groupCapable boolean normalizes into places |
| 492 | // — "you" is fixed (member apps are always personal, so it still gets its |
| 493 | // own personal thread too), "chat" is the author's opt-in. |
| 494 | assert.deepEqual(app.places, ["you", "chat"]); |
| 495 | assert.ok(groupCapableApps().some((a) => a.name === "gscore")); |
| 496 | |
| 497 | const personal = { v: 1, name: "gplain", title: "Plain", glyph: "P", types: ["gplain.set"] }; |
| 498 | const res2 = registerMemberApp({ id: "app-group2", manifest: personal, source: NOTES_APP.replace(/notes/g, "gplain") }); |
| 499 | assert.equal(res2.ok, true, res2.reason); |
| 500 | assert.deepEqual(appByName("gplain").places, ["you"], "a plain member app stays personal-only"); |
| 501 | assert.ok(!groupCapableApps().some((a) => a.name === "gplain")); |
| 502 | }); |
| 503 | |
| 504 | // Members can build gif-shaped apps: one shared copy for the whole space and |
| 505 | // no personal thread at all. Which places an app lives in is the author's |
| 506 | // call — the only rule is that it lives somewhere. |
| 507 | test("a space-only member app is legal — places pass through, no personal copy forced", () => { |
| 508 | const spacey = { v: 1, name: "gspace", title: "Space Lib", glyph: "🎞️", types: ["gspace.set"], places: ["space"] }; |
| 509 | assert.equal(validateManifest(spacey), null); |
| 510 | const res = registerMemberApp({ id: "app-space1", manifest: spacey, source: NOTES_APP.replace(/notes/g, "gspace"), author: "Ana" }); |
| 511 | assert.equal(res.ok, true, res.reason); |
| 512 | assert.deepEqual(appByName("gspace").places, ["space"]); |
| 513 | assert.ok(!groupCapableApps().some((a) => a.name === "gspace")); |
| 514 | // Somewhere is still required: an empty places list has nowhere to live. |
| 515 | assert.equal(validateManifest({ ...spacey, places: [] }), "places must name at least one place"); |
| 516 | }); |
| 517 | |
| 518 | // The command-collision gate: a member app must not shadow an existing slash |
| 519 | // command — the built-ins register first, member apps later, so without this |
| 520 | // a crafted blob claiming "/todo" (or "/help") would overwrite commandOwner |
| 521 | // on every client and route members' typed arguments into a log the |
| 522 | // hijacker can read. LOAD-time security, mirroring the event-type check. |
| 523 | test("registerMemberApp refuses a command name an existing app or the host already owns", () => { |
| 524 | const mk = (name, cmd) => ({ v: 1, name, title: name, glyph: "×", types: [`${name}.set`], places: ["you"], commands: [{ name: cmd, desc: "", args: "" }] }); |
| 525 | // First app claims /clash — succeeds and registers the command. |
| 526 | const first = registerMemberApp({ id: "app-cmd0", manifest: mk("cmdown", "/clash"), source: NOTES_APP.replace(/notes/g, "cmdown") }); |
| 527 | assert.equal(first.ok, true, first.reason); |
| 528 | // A second app claiming the SAME command is refused (app collision) — and |
| 529 | // the ORIGINAL owner is untouched (the reject returns before defineApp). |
| 530 | const hijack = registerMemberApp({ id: "app-cmd1", manifest: mk("cmdhij", "/clash"), source: NOTES_APP.replace(/notes/g, "cmdhij") }); |
| 531 | assert.equal(hijack.ok, false); |
| 532 | assert.match(hijack.reason, /already taken.*cmdown/); |
| 533 | assert.equal(commandTakenBy("/clash"), "cmdown", "the hijack never mutated commandOwner"); |
| 534 | // A host command (/help) can't be shadowed either. |
| 535 | const host = registerMemberApp({ id: "app-cmd2", manifest: mk("cmdhij2", "/help"), source: NOTES_APP.replace(/notes/g, "cmdhij2") }); |
| 536 | assert.equal(host.ok, false); |
| 537 | assert.match(host.reason, /already taken.*built-in/); |
| 538 | // A free command name registers fine. |
| 539 | const fine = registerMemberApp({ id: "app-cmd3", manifest: mk("cmdok", "/cmdok"), source: NOTES_APP.replace(/notes/g, "cmdok") }); |
| 540 | assert.equal(fine.ok, true, fine.reason); |
| 541 | }); |
| 542 | |
| 543 | // manifestIsLegacy powers App Studio's "older format" flag + Upgrade offer |
| 544 | // (docs/member-apps.md § Legacy formats): legacy = the stored manifest has no |
| 545 | // places array, so loading falls back to the old scope/flag fields. |
| 546 | test("manifestIsLegacy: no places array = legacy; a places manifest is current", () => { |
| 547 | assert.equal(manifestIsLegacy({ v: 1, name: "x", scope: "personal" }), true); |
| 548 | assert.equal(manifestIsLegacy({ v: 1, name: "x", scope: "personal", groupCapable: true }), true); |
| 549 | assert.equal(manifestIsLegacy({ v: 1, name: "x", places: ["you"] }), false); |
| 550 | // Leftover legacy fields BESIDE places don't count — places wins at load. |
| 551 | assert.equal(manifestIsLegacy({ v: 1, name: "x", places: ["you"], groupCapable: true }), false); |
| 552 | assert.equal(manifestIsLegacy(null), false); |
| 553 | }); |
| 554 | |
| 555 | // The full /gif shape: places ["space"] + listed:false — implicitly placed, |
| 556 | // out of the catalogues, the one shared copy used on first command with no |
| 557 | // add gesture (docs/space-apps.md). A member app can declare it too. |
| 558 | test("an unlisted space-only member app registers implicitly placed", () => { |
| 559 | const implicit = { v: 1, name: "gimp", title: "Implicit", glyph: "✨", types: ["gimp.set"], places: ["space"], listed: false }; |
| 560 | assert.equal(validateManifest(implicit), null); |
| 561 | assert.equal(validateManifest({ ...implicit, listed: "no" }), "bad listed flag"); |
| 562 | const res = registerMemberApp({ id: "app-space2", manifest: implicit, source: NOTES_APP.replace(/notes/g, "gimp"), author: "Ana" }); |
| 563 | assert.equal(res.ok, true, res.reason); |
| 564 | const app = appByName("gimp"); |
| 565 | assert.deepEqual(app.places, ["space"]); |
| 566 | assert.equal(app.listed, false, "stays out of the Apps catalogue"); |
| 567 | }); |
| 568 | |
| 569 | // A member app opts into `quiet` (issue #227) exactly like groupCapable — a |
| 570 | // boolean the author declares, read straight off the manifest, no different |
| 571 | // from a built-in app's `quiet`. Same mechanism (appIsQuiet), same enforcement |
| 572 | // (shell.js), for both origins. |
| 573 | test("a member app can declare quiet:true; validateManifest rejects a non-boolean", () => { |
| 574 | const quiet = { v: 1, name: "qmember", title: "Quiet", glyph: "Q", types: ["qmember.set"], quiet: true }; |
| 575 | const res = registerMemberApp({ id: "app-quiet1", manifest: quiet, source: NOTES_APP.replace(/notes/g, "qmember"), author: "Ana" }); |
| 576 | assert.equal(res.ok, true, res.reason); |
| 577 | assert.equal(appByName("qmember").quiet, true); |
| 578 | assert.equal(appIsQuiet("qmember"), true); |
| 579 | |
| 580 | const notQuiet = { v: 1, name: "notqmember", title: "Loud", glyph: "L", types: ["notqmember.set"] }; |
| 581 | const res2 = registerMemberApp({ id: "app-quiet2", manifest: notQuiet, source: NOTES_APP.replace(/notes/g, "notqmember") }); |
| 582 | assert.equal(res2.ok, true, res2.reason); |
| 583 | assert.equal(appByName("notqmember").quiet, undefined); |
| 584 | assert.equal(appIsQuiet("notqmember"), false); |
| 585 | |
| 586 | assert.equal(validateManifest({ ...quiet, quiet: "yes" }), "bad quiet flag"); |
| 587 | }); |
| 588 | |
| 589 | // quiet and groupCapable are orthogonal, same as for built-in apps: a member |
| 590 | // app can declare both, and quiet only ever governs the PERSONAL thread's |
| 591 | // visibility — it carries no meaning for the shared group instance. |
| 592 | test("a member app can be both groupCapable and quiet at once", () => { |
| 593 | const both = { v: 1, name: "qgroup", title: "QG", glyph: "Q", types: ["qgroup.set"], groupCapable: true, quiet: true }; |
| 594 | const res = registerMemberApp({ id: "app-quiet3", manifest: both, source: NOTES_APP.replace(/notes/g, "qgroup") }); |
| 595 | assert.equal(res.ok, true, res.reason); |
| 596 | const app = appByName("qgroup"); |
| 597 | assert.deepEqual(app.places, ["you", "chat"]); |
| 598 | assert.equal(app.quiet, true); |
| 599 | assert.ok(groupCapableApps().some((a) => a.name === "qgroup")); |
| 600 | }); |
| 601 | |
| 602 | test("installMemberAppState preserves the `lost` overflow flag", () => { |
| 603 | const manifest = { v: 1, name: "lossyapp", title: "L", glyph: "L", types: ["lossyapp.e"] }; |
| 604 | const res = registerMemberApp({ id: "app-lost1", manifest, source: NOTES_APP.replace(/notes/g, "lossyapp") }); |
| 605 | assert.equal(res.ok, true, res.reason); |
| 606 | const tid = "thread-lost"; |
| 607 | resetThread(tid); |
| 608 | // Simulate an overflow having set lost on the stored state. |
| 609 | seedThread(tid, { lossyapp: { v: null, vUpTo: 0, pend: [{ e: { type: "lossyapp.e" }, meta: { id: 5 } }], lost: true } }, 0); |
| 610 | // A frame state report must NOT wipe the flag. |
| 611 | installMemberAppState(tid, "lossyapp", { done: true }, 5); |
| 612 | assert.equal(appStateFor(tid, "lossyapp").lost, true, "lost flag erased by state install"); |
| 613 | }); |
| 614 | |
| 615 | test("installMemberAppState ignores a stale (lower-upTo) report", () => { |
| 616 | const manifest = { v: 1, name: "monoapp", title: "M", glyph: "M", types: ["monoapp.e"] }; |
| 617 | const res = registerMemberApp({ id: "app-mono", manifest, source: NOTES_APP.replace(/notes/g, "monoapp") }); |
| 618 | assert.equal(res.ok, true, res.reason); |
| 619 | const tid = "thread-mono"; |
| 620 | resetThread(tid); |
| 621 | seedThread(tid, { monoapp: { v: null, vUpTo: 0, pend: [] } }, 0); |
| 622 | installMemberAppState(tid, "monoapp", { at: 10 }, 10); |
| 623 | assert.deepEqual(appStateFor(tid, "monoapp").v, { at: 10 }); |
| 624 | // A second frame reports an OLDER horizon — must be ignored. |
| 625 | installMemberAppState(tid, "monoapp", { at: 5 }, 5); |
| 626 | assert.deepEqual(appStateFor(tid, "monoapp").v, { at: 10 }, "stale report regressed state"); |
| 627 | assert.equal(appStateFor(tid, "monoapp").vUpTo, 10); |
| 628 | // A newer horizon still advances. |
| 629 | installMemberAppState(tid, "monoapp", { at: 12 }, 12); |
| 630 | assert.deepEqual(appStateFor(tid, "monoapp").v, { at: 12 }); |
| 631 | }); |
| 632 | |
| 633 | test("registerMemberApp refuses collisions and double registration", () => { |
| 634 | assert.equal(registerMemberApp({ id: "app-t1", manifest: MANIFEST, source: NOTES_APP }).reason, "already registered"); |
| 635 | assert.match(registerMemberApp({ id: "app-t2", manifest: MANIFEST, source: NOTES_APP }).reason, /already exists/); |
| 636 | // A manifest claiming another app's event types is refused even under a |
| 637 | // fresh name. |
| 638 | assert.match( |
| 639 | registerMemberApp({ |
| 640 | id: "app-t4", |
| 641 | manifest: { ...MANIFEST, name: "notes2", types: ["notes2.ok", "notes.add"] }, |
| 642 | source: NOTES_APP, |
| 643 | }).reason, |
| 644 | /prefixed/, |
| 645 | ); |
| 646 | }); |
| 647 | |
| 648 | // ---- command panels (the declarative sandbox form) -------------------------- |
| 649 | |
| 650 | test("sanitizePanelSpec clamps the title, whitelists position/animate, caps data", () => { |
| 651 | const sp = sanitizePanelSpec({ title: "Hi", position: "top", animate: false, data: { a: 1 } }); |
| 652 | assert.deepEqual(sp, { title: "Hi", data: { a: 1 }, position: "top", animate: false }); |
| 653 | assert.equal(sanitizePanelSpec({ title: "x".repeat(200) }).title.length, 80); |
| 654 | // A hostile position/animate never passes through; missing data → null. |
| 655 | const loose = sanitizePanelSpec({ position: "overlay", animate: "yes" }); |
| 656 | assert.equal(loose.position, undefined); |
| 657 | assert.equal(loose.animate, undefined); |
| 658 | assert.equal(loose.data, null); |
| 659 | // Un-cleanable or oversize data rejects the whole request. |
| 660 | assert.equal(sanitizePanelSpec({ data: { f: () => {} } }), null); |
| 661 | assert.equal(sanitizePanelSpec({ data: "y".repeat(70 * 1024) }), null); |
| 662 | assert.equal(sanitizePanelSpec("nope"), null); |
| 663 | }); |
| 664 | |
| 665 | test("evalMemberApp captures a panel hook; the mood example evaluates whole", async () => { |
| 666 | const { readFileSync } = await import("node:fs"); |
| 667 | const src = readFileSync(new URL("../web/static/apps-disabled/mood.js", import.meta.url), "utf8"); |
| 668 | const spec = evalMemberApp(src, "mood"); |
| 669 | assert.equal(typeof spec.panel, "function", "the panel hook survives the sandbox transform"); |
| 670 | assert.ok(spec.commands["/mood"], "the /mood command registers"); |
| 671 | // The reducer folds a check-in and validates hostile input to a no-op. |
| 672 | const s1 = spec.reduce(undefined, { type: "mood.set", id: "a", mood: "🙂", note: "ok", name: "Ana", ts: 5 }); |
| 673 | assert.equal(s1.items.length, 1); |
| 674 | assert.equal(spec.reduce(s1, { type: "mood.set", id: "b", mood: "<script>" }).items.length, 1, "unknown mood folds to a no-op"); |
| 675 | // Its manifest extracts cleanly and validates for deploy. |
| 676 | const m = manifestOf(spec); |
| 677 | assert.equal(validateManifest(m), null); |
| 678 | assert.equal(m.chat, true); |
| 679 | }); |
| 680 | |
| 681 | // The form-submission shim (bootFrame): a member app can use a plain |
| 682 | // <form onsubmit> because the runtime intercepts the submission TRIGGER (a |
| 683 | // submit button's click, an Enter implicit-submit) and re-dispatches a scripted |
| 684 | // submit — no native submission, so no "Blocked form submission" console error |
| 685 | // and no navigation, with the sandbox unchanged (still no allow-forms). These |
| 686 | // pin the pure trigger predicates the shim keys on. |
| 687 | test("isSubmitTrigger: submit buttons / image inputs / typeless buttons in a form", () => { |
| 688 | assert.equal(isSubmitTrigger("BUTTON", null, true), true, "typeless <button> defaults to submit"); |
| 689 | assert.equal(isSubmitTrigger("BUTTON", "submit", true), true); |
| 690 | assert.equal(isSubmitTrigger("INPUT", "submit", true), true); |
| 691 | assert.equal(isSubmitTrigger("INPUT", "image", true), true); |
| 692 | }); |
| 693 | test("isSubmitTrigger: NOT a trigger for type=button, plain inputs, or no form", () => { |
| 694 | assert.equal(isSubmitTrigger("BUTTON", "button", true), false, "type=button never submits"); |
| 695 | assert.equal(isSubmitTrigger("INPUT", "text", true), false); |
| 696 | assert.equal(isSubmitTrigger("BUTTON", "submit", false), false, "no associated form"); |
| 697 | assert.equal(isSubmitTrigger("A", null, true), false); |
| 698 | }); |
| 699 | test("isEnterSubmit: Enter in a single-line input in a form, respecting Shift/IME", () => { |
| 700 | assert.equal(isEnterSubmit("Enter", false, false, "INPUT", true), true); |
| 701 | assert.equal(isEnterSubmit("Enter", true, false, "INPUT", true), false, "Shift+Enter = newline"); |
| 702 | assert.equal(isEnterSubmit("Enter", false, true, "INPUT", true), false, "mid IME composition"); |
| 703 | assert.equal(isEnterSubmit("Enter", false, false, "TEXTAREA", true), false, "textarea keeps Enter"); |
| 704 | assert.equal(isEnterSubmit("Enter", false, false, "INPUT", false), false, "no associated form"); |
| 705 | assert.equal(isEnterSubmit("a", false, false, "INPUT", true), false); |
| 706 | }); |
| 707 | |