// Tests for the mini-app framework + the /todo app. node test/apps.test.mjs import assert from "node:assert/strict"; import { parseCommand, parseMeCommand, runCommand, commandCatalog, isAppType, foldEvent, appStateFor, resetThread, defineApp, namespace, setEmitTransport, setScheduleTransport, setEphemeralTransport, setAppThreadResolver, setHostBridge, setCurrentUser, setThreadHasBot, setThreadOwner, defaultCommandFor, setToast, messageActionsFor, notifyLive, deliverEphemeral, appPreview, appUnread, foldHostEvent, seedThread, takeSnapshot, addItem, updateItem, removeItem, partitionByGrace, DONE_GRACE_MS, keyedList, appWantsChatView, appChatOnly, appDefaultView, noticeParts, noticeIsCompact, noticeCalmsDown, NOTICE_COMPACT_AFTER_MS, awaitAnswer, cancelAnswer, setAnswerCancelledHook, deliverAnswer, groupAppsApp, groupAppsFor, groupCapableApps, appGlyphInto, installGroupApp, spaceAppsApp, spaceAppsFor, spaceCapableApps, spaceAppInfo, installSpaceApp, setSpaceInstallPolicy, setSpaceThread, setSpaceThreadResolver, listedApps, instanceThread, parseAppMention, parseHere, parseEveryone, parseMeFlag, HERE_FLAG, ME_FLAG, EVERYONE_FLAG, placePhrase, placeRowPhrase, installRowPhrase, spaceRowTitle, setInstanceOpener, runAppMention, setThreadIsGroup, setCommandPanelHost, openCommandPanel, closeCommandPanel, appIsQuiet, commandTakenBy, } from "../web/static/apps.js"; import { app as todoApp } from "../web/static/apps/todo.js"; // registers the /todo app import { app as pollApp, pollSummary } from "../web/static/apps/poll.js"; import { app as remindApp, parseReminder } from "../web/static/apps/remind.js"; import { app as bookmarksApp } from "../web/static/apps/bookmarks.js"; // registers "Save link" import { app as boardApp, sanitizePoints } from "../web/static/apps/board.js"; import { app as dnsimpleApp } from "../web/static/apps/dnsimple.js"; // registers the DNSimple companion app import { app as setupApp } from "../web/static/apps/setup.js"; // the first-run setup conversation's app import { app as welcomeApp } from "../web/static/apps/welcome.js"; // the joiner welcome conversation's app import { app as adminApp } from "../web/static/apps/admin.js"; // the admin panel (registers /admin) import { app as chatApp, viewItems, threadItems, newlyDirtyMentionSids } from "../web/static/apps/chat.js"; // the chat app import { app as vaultApp } from "../web/static/apps/vault.js"; // the Vault app (registers /vault) import { app as emojiApp, searchEmoji } from "../web/static/apps/emoji.js"; // registers /emoji (the command-panel reference app) import { app as gifApp, pickRendition, renditionCandidates, gifItems, GIF_MAX_BYTES } from "../web/static/apps/gif.js"; // registers /gif import { app as diceApp, parseSides } from "../web/static/disabled-apps/dice.js"; // DISABLED quiet-app POC (issue #227) — not wired into shell.js // Each app's fold is synthesised from its `events` map by defineApp — the same // (state, event) → state reducer the framework folds with. Test it directly. const reduce = todoApp.reduce; const pollReduce = pollApp.reduce; const remindReduce = remindApp.reduce; const bookmarksReduce = bookmarksApp.reduce; const boardReduce = boardApp.reduce; // todo is a "personal" app: its command routes to the user's app-thread. In tests // we stub the resolver so it lands in a known thread, and give ctx a user id. setCurrentUser("u1"); setAppThreadResolver(async () => "todoThread"); const tick = () => new Promise((r) => setTimeout(r, 0)); // let runCommand's async emit flush let failures = 0; async function test(name, fn) { try { await fn(); console.log("PASS " + name); } catch (e) { console.log("FAIL " + name + " — " + (e && e.message)); failures++; } } // ---- namespace helper --------------------------------------------------- await test("namespace() resolves event types with '.' and CSS classes with '-'", () => { const N = namespace("todo"); assert.equal(N.ns, "todo"); assert.equal(N.ev("add"), "todo.add"); assert.equal(N.cls("item"), "todo-item"); assert.equal(N.cls("list--done"), "todo-list--done"); // modifier suffix rides along // A name already carrying a "." passes through untouched — a full type or // another app's type still resolves, so migration is a safe find-and-replace. assert.equal(N.ev("chat.msg"), "chat.msg"); // Tolerant of a missing/empty base and a nullish name (never throws). assert.equal(namespace("").ev("x"), ".x"); assert.equal(N.ev(null), "todo.null"); }); await test("defineApp defaults namespace to name and exposes app.ns", () => { // todo declared no explicit namespace → it defaults to its name, and the // synthesised types match the wire contract the event vectors pin. assert.equal(todoApp.namespace, "todo"); assert.equal(todoApp.ns.ev("add"), "todo.add"); assert.deepEqual([...todoApp.types].sort(), ["todo.add", "todo.done", "todo.move", "todo.remove", "todo.reopen"].sort()); // An explicit namespace overrides the name (built-in apps only; member apps // are name-prefix validated by the sandbox). const custom = defineApp({ name: "nstest-app", namespace: "ns", initialState: () => ({}), events: { "ns.e": (s) => s } }); assert.equal(custom.namespace, "ns"); assert.equal(custom.ns.cls("box"), "ns-box"); }); // ---- command parsing ---------------------------------------------------- await test("parseCommand splits a registered command and its args", () => { const c = parseCommand("/todo buy milk"); assert.equal(c.name, "/todo"); assert.equal(c.args, "buy milk"); assert.ok(c.app, "a registered command resolves its owning app"); }); await test("parseCommand trims surrounding arg whitespace", () => { const c = parseCommand("/todo buy milk "); assert.equal(c.name, "/todo"); assert.equal(c.args, "buy milk"); }); await test("parseCommand allows a bare command with no args", () => { const c = parseCommand("/todo"); assert.equal(c.name, "/todo"); assert.equal(c.args, ""); assert.ok(c.app); }); await test("parseCommand flags an unknown /command (app:null) but ignores plain text", () => { assert.equal(parseCommand("hello"), null); // no leading "/" → not a command attempt assert.equal(parseCommand(""), null); assert.equal(parseCommand("/ hi"), null); // a lone "/" is punctuation, sent as chat const c = parseCommand("/nope do a thing"); assert.equal(c.name, "/nope"); assert.equal(c.args, "do a thing"); assert.equal(c.app, null); // unowned verb → a failed command, not chat (caller replies) }); await test("parseMeCommand pulls the action out of a /me line", () => { assert.equal(parseMeCommand("/me waves"), "waves"); assert.equal(parseMeCommand("/me waves hello "), "waves hello"); assert.equal(parseMeCommand("/ME waves"), "waves"); // case-insensitive, like other commands }); await test("parseMeCommand allows a bare /me (composer treats it as nothing to say)", () => { assert.equal(parseMeCommand("/me"), ""); assert.equal(parseMeCommand("/me "), ""); }); await test("parseMeCommand ignores non-/me text, including other commands", () => { assert.equal(parseMeCommand("hello"), null); assert.equal(parseMeCommand(""), null); assert.equal(parseMeCommand("/todo buy milk"), null); assert.equal(parseMeCommand("/mean thing"), null); // "/me" must be its own token, not a prefix }); // ---- command execution → emitted event ---------------------------------- await test("runCommand routes a personal /todo into the app-thread and emits", async () => { const sent = []; setEmitTransport((threadId, event) => { sent.push({ threadId, event }); return Promise.resolve(true); }); // typed from some chat "chatX" — it must land in the resolved app-thread, not chatX assert.equal(runCommand("/todo buy milk", "chatX"), true); await tick(); assert.equal(sent.length, 1); assert.equal(sent[0].threadId, "todoThread"); // routed to the app-thread, not "chatX" assert.equal(sent[0].event.type, "todo.add"); assert.equal(sent[0].event.text, "buy milk"); assert.ok(sent[0].event.id, "add carries a generated id"); }); await test("runCommand returns false (and emits nothing) for non-commands", async () => { const sent = []; setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); assert.equal(runCommand("just a message", "chatX"), false); await tick(); assert.equal(sent.length, 0); }); await test("an empty /todo emits nothing", async () => { const sent = []; setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); assert.equal(runCommand("/todo ", "chatX"), true); // handled (consumed), but no event await tick(); assert.equal(sent.length, 0); }); // AUDIT (quiet apps, issue #227): runCommand resolves — and so creates — a // personal app's own thread BEFORE the handler runs, no matter what the // handler ends up doing. A reply-only turn (no ctx.emit at all) still pays // for a thread. That's BY DESIGN (the thread is where any future ctx.emit // from this app would land, so it has to exist) — `quiet` never changes this // resolution. What `quiet` changes is entirely about the thread's VISIBILITY // afterward (see shell.js's createAppThread + the "msg" WS handler, and // apps/README.md § Quiet apps) — this test just pins the resolution itself so // that distinction stays deliberate, not accidental. await test("a reply-only personal command still resolves (creates) the app-thread — quiet only hides it", async () => { let resolves = 0; setAppThreadResolver(async () => { resolves++; return "remindThread"; }); const sent = []; setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); const replies = []; // Unparseable time → remind.js only ctx.reply()s a clarifying question; it // never calls addReminder/ctx.emit. Typed from an unrelated chat. assert.equal(runCommand("/remind call mum", "chatX", (r) => replies.push(r)), true); await tick(); setAppThreadResolver(async () => "todoThread"); // restore for later tests assert.equal(sent.length, 0, "the handler never emitted anything"); assert.equal(replies.length, 1, "it only answered in place"); // resolveAppThread ran regardless — the thread exists whether or not this // particular turn emitted anything. Whether it's SHOWN is a separate // decision, made in shell.js (createAppThread hides it on creation for a // quiet app; the WS handler keeps it hidden through later activity too). assert.equal(resolves, 1); }); await test("/help is answered privately (client-side); other bot commands pass through", () => { const replies = []; const respond = (r) => replies.push(r); // /help is caught + answered right here with a private notice — no bot needed. assert.equal(runCommand("/help", "chatX", respond), true); assert.equal(replies.length, 1); assert.match(replies[0].text, /Commands you can use/); assert.match(replies[0].text, /\/help/); assert.match(replies[0].text, /\/todo/); // an app command, listed everywhere // No bot injected → /help omits the bot's commands, exactly like the typeahead. assert.doesNotMatch(replies[0].text, /\/theme/); // /theme is still Eleven's — the client SENDS it (returns false so the composer // posts it, and the bot answers), never caught locally. assert.equal(runCommand("/theme lagoon", "chatX", respond), false); assert.equal(replies.length, 1, "a bot command isn't answered locally"); // A truly unknown "/word" is handled (not broadcast) and answered privately. assert.equal(runCommand("/blahblah", "chatX", respond), true); assert.equal(replies.length, 2); assert.match(replies[1].text, /isn't a command/); // The catalog flags /help non-bot (typeahead lists it everywhere); /theme stays // bot-gated; app commands are non-bot. const cat = commandCatalog(); assert.ok(cat.some((c) => c.name === "/help" && c.bot === false)); assert.ok(cat.some((c) => c.name === "/theme" && c.bot === true)); assert.ok(cat.some((c) => c.name === "/todo" && c.bot === false)); // /me is listed for discoverability, but it's never routed through runCommand — // the composer (chat.js) handles it directly via parseMeCommand, since (unlike // every other command) it broadcasts a real message rather than replying privately. assert.ok(cat.some((c) => c.name === "/me" && c.bot === false)); }); await test("commandTakenBy names the owner of a taken slash command, '' when free", () => { // An app command → the owning app's name (the member-app gates reject these). assert.equal(commandTakenBy("/todo"), "todo"); // The host set — bot verbs, /help, /me — reads as "host". assert.equal(commandTakenBy("/theme"), "host"); assert.equal(commandTakenBy("/help"), "host"); assert.equal(commandTakenBy("/me"), "host"); // A free name is takeable. assert.equal(commandTakenBy("/neverregistered"), ""); }); // ---- local commands + the default command -------------------------------- // todo's /add is `local: true` — it exists only inside the To-do thread. Stub the // thread-owner lookup the way shell.js injects its threadApp map. setThreadOwner((id) => (id === "todoThread" ? "todo" : id === "remindThread" ? "remind" : null)); await test("a local command resolves only in its app's own thread", () => { const inThread = parseCommand("/add buy milk", "todoThread"); assert.equal(inThread.name, "/add"); assert.equal(inThread.args, "buy milk"); assert.equal(inThread.app, todoApp); assert.equal(inThread.local, true); // Elsewhere (or with no thread context) /add is just an unknown verb. assert.equal(parseCommand("/add buy milk", "chatX").app, null); assert.equal(parseCommand("/add buy milk").app, null); }); await test("a local command is never in the global catalog, and leads its thread's", () => { const everywhere = commandCatalog(); assert.ok(!everywhere.some((c) => c.name === "/add"), "/add stays out of other threads"); assert.ok(everywhere.some((c) => c.name === "/todo")); const here = commandCatalog("todoThread"); assert.equal(here[0].name, "/add", "local commands lead in the owning thread"); // The app's global routing alias is redundant noise in its own thread. assert.ok(!here.some((c) => c.name === "/todo")); // Other apps' global commands still work from anywhere, so they stay listed. assert.ok(here.some((c) => c.name === "/remind")); }); await test("runCommand runs a local command in place — no routing, no echo reply", async () => { const sent = []; const replies = []; setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); // Resolver poisoned: a local command must NOT consult it (it targets where you are). setAppThreadResolver(async () => { throw new Error("local commands never route"); }); assert.equal(runCommand("/add buy milk", "todoThread", (r) => replies.push(r)), true); await tick(); setAppThreadResolver(async () => "todoThread"); // restore for later tests assert.equal(sent.length, 1); assert.equal(sent[0].threadId, "todoThread"); assert.equal(sent[0].event.type, "todo.add"); assert.equal(sent[0].event.text, "buy milk"); // In the app's own thread the event lands as a visible action line, so the // private confirmation is suppressed (same rule as /todo typed in-thread). assert.equal(replies.length, 0); }); await test("an unknown command in an app-thread is answered by the app persona", () => { const replies = []; assert.equal(runCommand("/blahblah", "todoThread", (r) => replies.push(r)), true); assert.equal(replies.length, 1); assert.equal(replies[0].sender.name, "To-do"); assert.match(replies[0].text, /isn't a command/); }); await test("defaultCommandFor: To-do's thread runs /add; other threads have none", () => { const dc = defaultCommandFor("todoThread"); assert.ok(dc); assert.equal(dc.name, "/add"); assert.equal(dc.app, todoApp); assert.ok(dc.app.composerPlaceholder, "the composer must advertise the routing"); assert.equal(defaultCommandFor("chatX"), null); }); await test("defaultCommandFor steps aside when a bot member is present", () => { // With an LLM in the thread, plain text is a real message for it to answer. setThreadHasBot((id) => id === "todoThread"); assert.equal(defaultCommandFor("todoThread"), null); setThreadHasBot(null); assert.ok(defaultCommandFor("todoThread")); }); await test("plain text through the default command becomes a to-do (the composer flow)", async () => { const sent = []; setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); // What chat.js does for non-slash text in a default-command thread: const dc = defaultCommandFor("todoThread"); assert.equal(runCommand(dc.name + " " + "buy milk", "todoThread"), true); await tick(); assert.equal(sent.length, 1); assert.equal(sent[0].event.type, "todo.add"); assert.equal(sent[0].event.text, "buy milk"); }); // ---- Reminders: a GLOBAL verb as the default command ---------------------- await test("Reminders' default is its global /remind (no local twin needed)", () => { const dc = defaultCommandFor("remindThread"); assert.ok(dc); assert.equal(dc.name, "/remind"); assert.equal(dc.app, remindApp); assert.ok(dc.app.composerPlaceholder, "the composer must advertise the routing"); }); await test("an app with no locals leads its own thread's catalog with its globals", () => { const here = commandCatalog("remindThread"); assert.equal(here[0].name, "/remind", "the global verb IS the in-thread verb — it leads"); assert.equal(here.filter((c) => c.name === "/remind").length, 1, "listed once, not repeated"); assert.ok(here.some((c) => c.name === "/todo"), "other apps' globals stay"); assert.ok(!here.some((c) => c.name === "/add"), "another app's LOCAL never leaks in"); }); await test("plain text with a time becomes a reminder; timeless text gets asked 'when?'", async () => { const sent = []; const replies = []; setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); setAppThreadResolver(async (name) => (name === "remind" ? "remindThread" : "todoThread")); const dc = defaultCommandFor("remindThread"); // "call mum in 10 minutes" → remind.add in this thread, and the confirmation // (a `short`-carrying reply) is suppressed in-thread — the action line suffices. assert.equal(runCommand(dc.name + " " + "call mum in 10 minutes", "remindThread", (r) => replies.push(r)), true); await tick(); assert.equal(sent.filter((e) => e.event.type === "remind.add").length, 1); assert.equal(sent.find((e) => e.event.type === "remind.add").event.text, "call mum"); assert.equal(replies.length, 0, "in-thread confirmation is suppressed"); // No parseable time → the command pushes back, and THAT reply (no `short`) // renders even in-thread: an error is the command's only feedback. assert.equal(runCommand(dc.name + " " + "call mum", "remindThread", (r) => replies.push(r)), true); await tick(); assert.equal(sent.filter((e) => e.event.type === "remind.add").length, 1, "nothing added"); assert.equal(replies.length, 1); assert.match(replies[0].text, /When should I remind you/); assert.equal(replies[0].sender.name, "Reminders"); setAppThreadResolver(async () => "todoThread"); // restore for later tests }); await test("bare /add in the To-do thread shows its usage line (error replies render in-thread)", async () => { const replies = []; assert.equal(runCommand("/add", "todoThread", (r) => replies.push(r)), true); await tick(); // replies land after instanceThread's (uniform) await assert.equal(replies.length, 1); assert.match(replies[0].text, /Usage: \/add/); }); // ---- type ownership (derived from each app's events map) ---------------- await test("isAppType recognises todo events, not chat", () => { assert.equal(isAppType("todo.add"), true); assert.equal(isAppType("todo.done"), true); assert.equal(isAppType("chat"), false); assert.equal(isAppType(null), false); assert.equal(isAppType(undefined), false); }); await test("an app's owned types are the keys of its events map", () => { // defineApp derives app.types from Object.keys(events) — one source of truth, // so a handled event can never be missing from the owned-type set. assert.deepEqual(todoApp.types, ["todo.add", "todo.done", "todo.reopen", "todo.remove", "todo.move"]); assert.deepEqual(pollApp.types, ["poll.create", "poll.vote", "poll.close"]); }); await test("appWantsChatView: chatMessage opts into companion; its absence is takeover", () => { // The presence of a chatMessage hook is the ONE gate between the two // presentations — companion (chat + interface) vs takeover (full surface). assert.equal(typeof todoApp.chatMessage, "function"); assert.equal(appWantsChatView("todo"), true); // To-do projects events → companion assert.equal(appWantsChatView("remind"), true); // Reminders too // A takeover app defines no chatMessage — register one and confirm it's takeover. defineApp({ name: "takeover-fixture", title: "Fixture", scope: "personal", events: {}, render() {} }); assert.equal(appWantsChatView("takeover-fixture"), false); assert.equal(appWantsChatView("no-such-app"), false); // unknown name → not companion }); await test("appWantsChatView: a groupFeedMessage-only app stays a personal takeover", () => { // Vault projects add/remove into a GROUP's chat feed via groupFeedMessage // (see below) and deliberately has NO chatMessage, so its OWN thread stays // a pure takeover: no Chat view, no header toggle, ever. The hook shape IS // the declaration — the old groupChatOnly flag is gone. assert.equal(vaultApp.chatMessage, undefined); assert.equal(typeof vaultApp.groupFeedMessage, "function"); assert.equal(appWantsChatView("vault"), false); // A chatMessage app is a companion, with no flag able to say otherwise. assert.equal(appWantsChatView("todo"), true); }); await test("vault: groupFeedMessage projects add/remove into a group's feed, never naming the secret", () => { const add = { type: "vault.add", id: "s1", aid: "a1", label: "GITHUB_TOKEN", value: "hunter2" }; const remove = { type: "vault.remove", id: "s1", aid: "a2" }; const change = { type: "vault.change", id: "s1", aid: "a3", value: "newvalue" }; // The host only calls this hook when folding a group/DM feed // (foldGroupAppEventBubble) — with no chatMessage, personal-thread events // have no projection path at all. const groupAdd = vaultApp.groupFeedMessage(add); assert.equal(groupAdd.action, true); assert.equal(groupAdd.text, "added a secret to the Vault"); assert.doesNotMatch(groupAdd.text, /GITHUB_TOKEN|hunter2/); const groupRemove = vaultApp.groupFeedMessage(remove); assert.equal(groupRemove.action, true); assert.equal(groupRemove.text, "removed a secret from the Vault"); // …but a value CHANGE (nothing new to notice, same name) stays silent. assert.equal(vaultApp.groupFeedMessage(change), null); }); await test("vault: preview names the actor ('You' for the reader's own event), never the secret", () => { // setCurrentUser("u1") above makes u1 "the reader" for the rest of this file. assert.equal(vaultApp.preview({ type: "vault.add", uid: "u1", name: "James" }), "🔒 You added a secret to the Vault"); assert.equal(vaultApp.preview({ type: "vault.remove", uid: "u2", name: "Ciara" }), "🔒 Ciara removed a secret from the Vault"); assert.equal(vaultApp.preview({ type: "vault.change", uid: "u1", name: "James" }), "🔒 You updated a secret in the Vault"); // No uid (a legacy/malformed event) falls back to the sender's name, not "You". assert.equal(vaultApp.preview({ type: "vault.add", name: "James" }), "🔒 James added a secret to the Vault"); assert.equal(vaultApp.preview({ type: "vault.other" }), null); }); await test("defineApp normalizes every legacy generation into places, then drops the old fields (#405)", () => { const mk = (spec) => defineApp({ title: "x", glyph: "y", events: {}, initialState: () => ({}), render() {}, ...spec }); const shape = (a) => [a.places, a.listed]; // Legacy scope declarations (stored member-app manifests still carry them)… assert.deepEqual(shape(mk({ name: "flag-legacy-personal", scope: "personal" })), [["you"], true]); assert.deepEqual(shape(mk({ name: "flag-legacy-system", scope: "system" })), [[], false]); // …the default archetype (nothing declared) is a personal, listed app… assert.deepEqual(shape(mk({ name: "flag-default" })), [["you"], true]); // …and the capability flags normalize the same way. assert.deepEqual(shape(mk({ name: "flag-first-both", personal: true, groupCapable: true })), [["you", "chat"], true]); assert.deepEqual(shape(mk({ name: "flag-first-grouponly", personal: false, groupCapable: true })), [["chat"], true]); assert.deepEqual(shape(mk({ name: "flag-first-hostonly", listed: false })), [[], false]); // The legacy fields are INPUT-ONLY: normalized in, then deleted, so a stale // flag can never disagree with places — nothing downstream can read one. const legacy = mk({ name: "flag-dropped", personal: true, groupCapable: true, spaceCapable: true, scope: "personal" }); assert.deepEqual(shape(legacy), [["you", "chat", "space"], true]); for (const f of ["personal", "groupCapable", "spaceCapable", "scope"]) assert.ok(!(f in legacy), f + " is dropped after normalization"); // The real apps keep their classification (behaviour-neutral migration). assert.deepEqual(shape(todoApp), [["you", "chat"], true]); assert.deepEqual(shape(pollApp), [["chat"], true]); }); // ---- collection helpers (the immutable list ops reducers build on) ------- await test("addItem appends, dedupes by id, and never mutates the input", () => { const a = [{ id: "1", v: 1 }]; const b = addItem(a, { id: "2", v: 2 }); assert.deepEqual(b.map((i) => i.id), ["1", "2"]); assert.equal(a.length, 1, "input array untouched"); const c = addItem(b, { id: "2", v: 99 }); // duplicate id → no-op add assert.deepEqual(c.map((i) => i.id), ["1", "2"]); assert.equal(c.find((i) => i.id === "2").v, 2, "existing item wins, not overwritten"); assert.notEqual(c, b, "still returns a fresh array (safe to sort in place)"); }); await test("updateItem patches one item into a fresh array; removeItem drops it", () => { const a = [{ id: "1", done: false }, { id: "2", done: false }]; const b = updateItem(a, "2", { done: true }); assert.equal(b.find((i) => i.id === "2").done, true); assert.equal(a.find((i) => i.id === "2").done, false, "input untouched"); assert.notEqual(b, a); const c = removeItem(b, "1"); assert.deepEqual(c.map((i) => i.id), ["2"]); assert.equal(b.length, 2, "input untouched"); }); // ---- the reducer (pure) ------------------------------------------------- await test("reduce adds, completes, reopens, and removes items", () => { let s = reduce(undefined, { type: "todo.add", id: "a", text: "apples", name: "Jo" }); assert.deepEqual(s.items, [{ id: "a", text: "apples", done: false, doneAt: 0, by: "Jo" }]); s = reduce(s, { type: "todo.add", id: "b", text: "bread" }); assert.equal(s.items.length, 2); // done records WHEN (the event's send time) so the grace→archive split converges. s = reduce(s, { type: "todo.done", id: "a", ts: 1700 }); assert.equal(s.items.find((i) => i.id === "a").done, true); assert.equal(s.items.find((i) => i.id === "a").doneAt, 1700); s = reduce(s, { type: "todo.reopen", id: "a" }); assert.equal(s.items.find((i) => i.id === "a").done, false); assert.equal(s.items.find((i) => i.id === "a").doneAt, 0, "reopen clears doneAt"); s = reduce(s, { type: "todo.remove", id: "a" }); assert.deepEqual(s.items.map((i) => i.id), ["b"]); }); await test("partitionByGrace: active / recently-done (grace) / archived by doneAt", () => { const now = 100000; const items = [ { id: "a", doneAt: 0 }, // active (not done) { id: "b", doneAt: now - 1000 }, // done 1s ago → grace (< DONE_GRACE_MS) { id: "c", doneAt: now - (DONE_GRACE_MS + 5000) }, // done long ago → archived { id: "d", doneAt: now - (DONE_GRACE_MS - 1) }, // just inside the window → grace { id: "e", doneAt: now - DONE_GRACE_MS }, // exactly at the edge → archived ]; const { active, grace, archived } = partitionByGrace(items, (i) => i.doneAt, now); assert.deepEqual(active.map((i) => i.id), ["a"]); assert.deepEqual(grace.map((i) => i.id), ["b", "d"]); assert.deepEqual(archived.map((i) => i.id), ["c", "e"]); // A custom grace window is honoured. assert.deepEqual( partitionByGrace(items, (i) => i.doneAt, now, 500).grace.map((i) => i.id), [], "nothing is within a 500ms window", ); }); await test("reduce ignores a malformed add (missing text) and unknown events", () => { let s = reduce(undefined, { type: "todo.add", id: "a" }); // no text assert.deepEqual(s.items, []); s = reduce(s, { type: "todo.done", id: "ghost" }); // unknown id — no-op assert.deepEqual(s.items, []); s = reduce(s, { type: "something.else" }); // not ours assert.deepEqual(s.items, []); }); await test("reduce does not mutate the previous state (snapshot-safe)", () => { const s0 = reduce(undefined, { type: "todo.add", id: "a", text: "apples" }); const s1 = reduce(s0, { type: "todo.done", id: "a" }); assert.equal(s0.items[0].done, false, "prior state untouched"); assert.equal(s1.items[0].done, true); assert.notEqual(s0, s1); }); // ---- folding a stream into per-thread state ----------------------------- await test("foldEvent accumulates a thread's app state and isolates threads", () => { resetThread("t1"); resetThread("t2"); const app = foldEvent("t1", { type: "todo.add", id: "a", text: "apples" }, {}); assert.equal(app.name, "todo"); foldEvent("t1", { type: "todo.add", id: "b", text: "bread" }, {}); foldEvent("t2", { type: "todo.add", id: "c", text: "cheese" }, {}); foldEvent("t1", { type: "todo.done", id: "a" }, {}); const s1 = appStateFor("t1", "todo"); assert.deepEqual(s1.items.map((i) => i.id), ["a", "b"]); assert.equal(s1.items.find((i) => i.id === "a").done, true); assert.deepEqual(appStateFor("t2", "todo").items.map((i) => i.id), ["c"]); }); await test("foldEvent returns null for a type no app owns", () => { assert.equal(foldEvent("t1", { type: "chat", text: "hi" }, {}), null); }); await test("resetThread clears a thread's folded state", () => { foldEvent("t3", { type: "todo.add", id: "x", text: "x" }, {}); assert.ok(appStateFor("t3", "todo")); resetThread("t3"); assert.equal(appStateFor("t3", "todo"), undefined); }); await test("foldEvent is idempotent per server message id (no dup on re-fold)", () => { resetThread("t4"); // The same message (msg id 42) folded twice — as happens when catch-up re-reads // the window on every thread re-open — must add the item only once. const first = foldEvent("t4", { type: "todo.add", id: "a", text: "apples" }, { id: 42 }); const again = foldEvent("t4", { type: "todo.add", id: "a", text: "apples" }, { id: 42 }); assert.equal(first.name, "todo"); // first fold applied, returns the app assert.equal(again, null); // duplicate message → skipped, returns null (no line) assert.deepEqual(appStateFor("t4", "todo").items.map((i) => i.id), ["a"]); // A genuinely different message still folds. foldEvent("t4", { type: "todo.add", id: "b", text: "bread" }, { id: 43 }); assert.deepEqual(appStateFor("t4", "todo").items.map((i) => i.id), ["a", "b"]); // After reset, the same message id may fold again (fresh window rebuild). resetThread("t4"); const afterReset = foldEvent("t4", { type: "todo.add", id: "a", text: "apples" }, { id: 42 }); assert.equal(afterReset.name, "todo"); assert.deepEqual(appStateFor("t4", "todo").items.map((i) => i.id), ["a"]); }); // ---- the other apps' reducers (pure) ------------------------------------ await test("poll: create then votes tally, last-writer-wins per user", () => { let s = pollReduce(undefined, { type: "poll.create", id: "p1", q: "Lunch?", options: ["Pizza", "Sushi"] }); assert.equal(s.polls.length, 1); s = pollReduce(s, { type: "poll.create", id: "p1", q: "Lunch?", options: ["Pizza", "Sushi"] }); // dup id assert.equal(s.polls.length, 1, "poll.create is deduped by id"); s = pollReduce(s, { type: "poll.vote", pollId: "p1", choice: 0, uid: "a" }); s = pollReduce(s, { type: "poll.vote", pollId: "p1", choice: 1, uid: "b" }); s = pollReduce(s, { type: "poll.vote", pollId: "p1", choice: 1, uid: "a" }); // a changes vote const votes = s.polls[0].votes; assert.equal(votes["a"], 1, "last vote wins"); assert.equal(votes["b"], 1); s = pollReduce(s, { type: "poll.vote", pollId: "ghost", choice: 0, uid: "a" }); // unknown poll assert.equal(Object.keys(s.polls[0].votes).length, 2); }); await test("poll: close is creator-only in the fold; legacy polls accept anyone", () => { let s = pollReduce(undefined, { type: "poll.create", id: "p1", q: "Lunch?", options: ["Pizza", "Sushi"], uid: "alice" }); s = pollReduce(s, { type: "poll.vote", pollId: "p1", choice: 0, uid: "bob" }); // A non-creator's close folds to a no-op — the poll stays. s = pollReduce(s, { type: "poll.close", pollId: "p1", uid: "bob" }); assert.equal(s.polls.length, 1, "only the creator may close"); // The creator's close removes it from the panel. s = pollReduce(s, { type: "poll.close", pollId: "p1", uid: "alice" }); assert.deepEqual(s.polls, []); // A pre-`by` poll (older event log) can be closed by anyone. let legacy = pollReduce(undefined, { type: "poll.create", id: "p2", q: "Q?", options: ["a", "b"] }); legacy = pollReduce(legacy, { type: "poll.close", pollId: "p2", uid: "whoever" }); assert.deepEqual(legacy.polls, []); }); await test("poll: pollSummary reports counts and the verdict (win / tie / no votes)", () => { const base = { q: "Lunch?", options: ["Pizza", "Sushi"] }; assert.equal( pollSummary({ ...base, votes: { a: 0, b: 0, c: 1 } }), "📊 Poll closed: Lunch?\n• Pizza — 2\n• Sushi — 1\nPizza wins (3 votes)", ); assert.equal( pollSummary({ ...base, votes: { a: 0, b: 1 } }), "📊 Poll closed: Lunch?\n• Pizza — 1\n• Sushi — 1\nTied: Pizza & Sushi (2 votes)", ); assert.equal(pollSummary({ ...base, votes: {} }), "📊 Poll closed: Lunch?\n• Pizza — 0\n• Sushi — 0\nNo votes were cast."); }); // ---- board (shared whiteboard) ------------------------------------------- await test("board: sanitizePoints filters bad entries, clamps to canvas, requires 2+", () => { assert.equal(sanitizePoints("nope"), null, "not an array"); assert.equal(sanitizePoints([[1, 2]]), null, "fewer than 2 points"); assert.equal( sanitizePoints([ [1, 2], ["x", 2], [3, 4], ]).length, 2, "non-numeric entries are dropped", ); assert.deepEqual(sanitizePoints([[-5, -5], [9999, 9999]]), [ [0, 0], [640, 320], ]); }); await test("board: open is idempotent; stroke is dropped without valid points or id", () => { let s = boardReduce(undefined, { type: "board.open" }); assert.equal(s.open, true); const same = boardReduce(s, { type: "board.open" }); assert.equal(same, s, "re-opening returns the same state object"); s = boardReduce(s, { type: "board.stroke", id: "s1", points: [[0, 0]] }); // 1 point assert.deepEqual(s.strokes, [], "a single-point stroke is not a line"); s = boardReduce(s, { type: "board.stroke", points: [[0, 0], [1, 1]] }); // no id assert.deepEqual(s.strokes, [], "no id, no stroke"); }); await test("board: stroke folds with sane color/width fallbacks; add is deduped by id", () => { let s = boardReduce(undefined, { type: "board.stroke", id: "s1", points: [[0, 0], [10, 10]], color: "not-a-color", width: 999, }); assert.equal(s.strokes.length, 1); assert.equal(s.strokes[0].color, "#2a211b", "unknown color falls back to the default"); assert.equal(s.strokes[0].width, 6, "unknown width falls back to the medium brush"); s = boardReduce(s, { type: "board.stroke", id: "s1", points: [[5, 5], [6, 6]], color: "#c2410c" }); assert.equal(s.strokes.length, 1, "duplicate id is a no-op add"); assert.equal(s.strokes[0].color, "#2a211b", "the original stroke wins, not overwritten"); }); await test("board: clear empties strokes but leaves the panel open", () => { let s = boardReduce(undefined, { type: "board.open" }); s = boardReduce(s, { type: "board.stroke", id: "s1", points: [[0, 0], [1, 1]] }); s = boardReduce(s, { type: "board.clear" }); assert.deepEqual(s.strokes, []); assert.equal(s.open, true, "clearing the drawing doesn't hide the panel"); }); await test("board: close hides the panel without touching strokes; idempotent", () => { let s = boardReduce(undefined, { type: "board.open" }); s = boardReduce(s, { type: "board.stroke", id: "s1", points: [[0, 0], [1, 1]] }); s = boardReduce(s, { type: "board.close" }); assert.equal(s.open, false); assert.equal(s.strokes.length, 1, "closing preserves the drawing for next time"); const same = boardReduce(s, { type: "board.close" }); assert.equal(same, s, "re-closing returns the same state object"); // Reopening picks up right where it left off. s = boardReduce(s, { type: "board.open" }); assert.equal(s.open, true); assert.equal(s.strokes.length, 1); }); await test("installGroupApp adds the app and, for openEvent apps (board), opens it too", async () => { resetThread("installGroupChat1"); const sent = []; setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); assert.equal(await installGroupApp("installGroupChat1", boardApp), true); assert.deepEqual(sent.map((s) => s.event.type), ["groupapps.add", "board.open"]); assert.ok(sent.every((s) => s.threadId === "installGroupChat1")); }); await test("installGroupApp is a no-op when the app is already added — no re-add, no re-open", async () => { resetThread("installGroupChat2"); foldEvent("installGroupChat2", { type: "groupapps.add", app: "board", name: "Ana" }, { id: 1 }); assert.ok(groupAppsFor("installGroupChat2").some((a) => a.name === "board")); const sent = []; setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); assert.equal(await installGroupApp("installGroupChat2", boardApp), true); assert.equal(sent.length, 0, "already added — nothing re-emitted, including no re-open"); }); await test("installGroupApp doesn't fire an openEvent for apps that don't declare one (poll)", async () => { resetThread("installGroupChat3"); const sent = []; setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); assert.equal(await installGroupApp("installGroupChat3", pollApp), true); assert.deepEqual(sent.map((e) => e.type), ["groupapps.add"]); }); await test("board: owned types are exactly open/close/stroke/clear", () => { assert.deepEqual(boardApp.types, ["board.open", "board.close", "board.stroke", "board.clear"]); }); await test("remind: parseReminder handles relative, clock, and explicit times", () => { // Relative: text before "in ", when = now + offset. assert.deepEqual(parseReminder("Call mom in 2h", 0), { text: "Call mom", when: 2 * 3600000 }); assert.deepEqual(parseReminder("stretch in 45m", 1000), { text: "stretch", when: 1000 + 45 * 60000 }); // Clock time: next occurrence of 9:00, always in the future, within a day. const at = parseReminder("Standup at 9:00", 0); assert.equal(at.text, "Standup"); assert.ok(at.when > 0 && at.when <= 24 * 3600000, "next 9:00 is within a day"); // Explicit date after "|" (local midnight → 09:00 default), text preserved. const abs = parseReminder("Renew passport | 2026-08-01", 0); assert.equal(abs.text, "Renew passport"); const d = new Date(abs.when); assert.equal(d.getFullYear(), 2026); assert.equal(d.getMonth(), 7); // August (0-based) assert.equal(d.getDate(), 1); }); await test("remind: parseReminder accepts Slack phrasing (me / front-loaded time / to)", () => { // "/remind me in 10 seconds to do a thing" — leading "me" dropped, time leads. assert.deepEqual(parseReminder("me in 10 seconds to do a thing", 0), { text: "do a thing", when: 10000 }); assert.deepEqual(parseReminder("in 5 minutes to stretch", 1000), { text: "stretch", when: 1000 + 5 * 60000 }); assert.deepEqual(parseReminder("me to water plants in 1 h", 0), { text: "water plants", when: 3600000 }); // "meeting" must NOT be read as the "me" keyword. assert.deepEqual(parseReminder("meeting in 10m", 0), { text: "meeting", when: 10 * 60000 }); }); await test("remind: parseReminder rejects text with no time or a bad unit", () => { assert.equal(parseReminder("just a note", 0), null); assert.equal(parseReminder("", 0), null); assert.equal(parseReminder("do it in 3x", 0), null); // unknown unit assert.equal(parseReminder("in 2h", 0), null); // no text before the time // Explicit dates/times must be rejected, not rolled over, when out of range. assert.equal(parseReminder("bad | 2026-06-31", 0), null); // June has 30 days assert.equal(parseReminder("bad | 2026-02-29", 0), null); // 2026 isn't a leap year assert.equal(parseReminder("bad | 2026-13-01", 0), null); // month > 12 assert.equal(parseReminder("bad | 2026-08-01 24:00", 0), null); // hour > 23 assert.equal(parseReminder("bad | 2026-08-01 12:60", 0), null); // minute > 59 }); await test("remind: reduce adds (soonest first), fires, and dismisses", () => { let s = remindReduce(undefined, { type: "remind.add", id: "r1", text: "later", when: 300, ts: 1 }); s = remindReduce(s, { type: "remind.add", id: "r1", text: "later", when: 300, ts: 1 }); // dup id assert.equal(s.items.length, 1); s = remindReduce(s, { type: "remind.add", id: "r2", text: "sooner", when: 100, ts: 2 }); assert.equal(s.items[0].id, "r2", "sorted soonest-first"); // fire records firedAt (the event's send time) so it can tuck into Previous. s = remindReduce(s, { type: "remind.fire", id: "r2", ts: 555 }); assert.equal(s.items.find((i) => i.id === "r2").fired, true); assert.equal(s.items.find((i) => i.id === "r2").firedAt, 555); s = remindReduce(s, { type: "remind.done", id: "r2" }); assert.deepEqual(s.items.map((i) => i.id), ["r1"]); }); await test("bookmarks: adds dedupe by id, newest first, carry `from`, removable", () => { let s = bookmarksReduce(undefined, { type: "bookmark.add", id: "b1", url: "https://a.com", note: "", ts: 1 }); s = bookmarksReduce(s, { type: "bookmark.add", id: "b1", url: "https://a.com", note: "", ts: 1 }); // dup assert.equal(s.items.length, 1); s = bookmarksReduce(s, { type: "bookmark.add", id: "b2", url: "https://b.com", from: "Bob", ts: 2 }); assert.equal(s.items[0].id, "b2", "newest first"); assert.equal(s.items[0].from, "Bob", "keeps who shared it"); s = bookmarksReduce(s, { type: "bookmark.remove", id: "b1" }); assert.deepEqual(s.items.map((i) => i.id), ["b2"]); }); // ---- snapshots: state that outlives the message window ------------------- await test("seedThread + foldEvent: snapshot state, skip covered ids, fold newer", () => { resetThread("s1"); // Seed a snapshot covering message ids <= 100 with one existing item. seedThread("s1", { todo: { items: [{ id: "old", text: "from snapshot", done: false, by: "" }] } }, 100); assert.deepEqual(appStateFor("s1", "todo").items.map((i) => i.id), ["old"]); // An event AT or BELOW the horizon is recognised (returns the app — its inline // line may render) but not re-reduced: the snapshot already contains it. const covered = foldEvent("s1", { type: "todo.add", id: "old", text: "from snapshot" }, { id: 100 }); assert.equal(covered && covered.name, "todo"); assert.equal(appStateFor("s1", "todo").items.length, 1, "covered event not re-folded"); // An event past the horizon folds normally on top of the seeded state. foldEvent("s1", { type: "todo.add", id: "new", text: "after snapshot" }, { id: 101 }); assert.deepEqual(appStateFor("s1", "todo").items.map((i) => i.id), ["old", "new"]); }); await test("seedThread reinstalls the snapshot as the authoritative base, superseding a stale live fold", () => { resetThread("s2"); // A live event folded before the snapshot loaded (the openThread race). Its id (5) // is BELOW the snapshot horizon (99), so the snapshot at 99 already accounts for it // — the snapshot is the authoritative floor and supersedes the partial live fold. // (An event ABOVE the horizon is instead restored by the caller's catch-up; see the // race test below.) The old guard kept the live fold and dropped the snapshot, which // lost every item the interim events don't re-cover. foldEvent("s2", { type: "todo.add", id: "a", text: "live" }, { id: 5 }); seedThread("s2", { todo: { items: [] } }, 99); assert.deepEqual(appStateFor("s2", "todo").items.map((i) => i.id), []); }); await test("seedThread survives a live event that raced the snapshot load (no lost history)", () => { resetThread("race"); // The data-loss race: openThread resets, then awaits the snapshot; a live app event // for the thread folds FIRST (id 150, above the eventual horizon), creating the store. foldEvent("race", { type: "todo.add", id: "live", text: "raced in" }, { id: 150 }); // The snapshot (older history, horizon 100) then loads and installs. It must NOT be // skipped just because interim state exists — the older item has to survive. seedThread("race", { todo: { items: [{ id: "old", text: "from snapshot", done: false, by: "" }] } }, 100); // The caller's catch-up re-reads everything after the horizon and re-folds the interim // event onto the base — seedThread cleared its dedupe mark, so this isn't skipped. foldEvent("race", { type: "todo.add", id: "live", text: "raced in" }, { id: 150 }); assert.deepEqual( appStateFor("race", "todo").items.map((i) => i.id), ["old", "live"], "snapshot history survived AND the raced event was restored", ); // And the persisted snapshot is NOT truncated at an advanced horizon: it covers both, // anchored at the highest folded id (150), so the next open won't skip the old history. const snap = takeSnapshot("race"); assert.equal(snap.upTo, 150); assert.deepEqual(snap.states.todo.items.map((i) => i.id), ["old", "live"]); }); await test("takeSnapshot returns new state once, anchored at the highest folded id", () => { resetThread("s3"); assert.equal(takeSnapshot("s3"), null, "nothing folded → nothing to save"); foldEvent("s3", { type: "todo.add", id: "a", text: "apples" }, { id: 7 }); foldEvent("s3", { type: "todo.add", id: "b", text: "bread" }, { id: 9 }); const snap = takeSnapshot("s3"); assert.equal(snap.upTo, 9); assert.deepEqual(snap.states.todo.items.map((i) => i.id), ["a", "b"]); assert.equal(takeSnapshot("s3"), null, "clean after taking — no re-save loop"); foldEvent("s3", { type: "todo.done", id: "a" }, { id: 10 }); const snap2 = takeSnapshot("s3"); assert.equal(snap2.upTo, 10, "re-dirtied by the next fold"); }); // ---- scheduling: ctx.schedule / ctx.unschedule ---------------------------- await test("/remind schedules a server-delivered fire, then records the sid", async () => { const sent = []; const scheduled = []; setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); setScheduleTransport({ schedule: async (threadId, event, whenMs) => { scheduled.push({ threadId, event, whenMs }); return "sid-1"; }, cancel: async () => true, }); assert.equal(runCommand("/remind Call mom in 2h", "chatX"), true); await tick(); // The future remind.fire went to the scheduler, carrying its own notice // (what the service worker shows when the app is closed). assert.equal(scheduled.length, 1); assert.equal(scheduled[0].threadId, "todoThread"); // the resolved app-thread assert.equal(scheduled[0].event.type, "remind.fire"); assert.equal(scheduled[0].event.notice.title, "⏰ Reminder"); assert.equal(scheduled[0].event.notice.body, "Call mom"); // The recorded reminder carries the schedule id, so ANY device can cancel it. assert.equal(sent.length, 1); assert.equal(sent[0].event.type, "remind.add"); assert.equal(sent[0].event.sid, "sid-1"); assert.equal(sent[0].event.id, scheduled[0].event.id, "fire targets the same reminder id"); }); await test("a failed schedule still records the reminder, sid-less (client fallback)", async () => { const sent = []; setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); setScheduleTransport({ schedule: async () => null, cancel: async () => false }); runCommand("/remind stretch in 5m", "chatX"); await tick(); assert.equal(sent.length, 1); assert.equal(sent[0].sid, "", "no sid — the render ticker owns firing"); }); // ---- spontaneous app → user communication --------------------------------- await test("notifyLive: app events never floating-toast; a hidden page gets a system notification", () => { // A hook-less app: the notice riding the event is what speaks. defineApp({ name: "notix", title: "Notix", glyph: "×", scope: "system", initialState: () => ({}), events: { "notix.ping": (s) => s }, }); const toasts = []; setToast((m) => toasts.push(m)); resetThread("n1"); // Stand in for the browser: a hidden page with notifications granted. const notes = []; const priorDoc = globalThis.document; const priorNote = globalThis.Notification; globalThis.document = { visibilityState: "hidden" }; globalThis.Notification = function (title, opts) { notes.push({ title, body: opts && opts.body }); }; globalThis.Notification.permission = "granted"; try { notifyLive("n1", { type: "notix.ping", notice: { title: "⏰ Reminder", body: "Call mom" } }); // No floating toast — a live app event announces itself via its thread (which // floats up the chat list with an unread dot), never a pop-up over your reading. assert.deepEqual(toasts, []); // But a hidden page still gets the background system notification — the // in-session equivalent of the push a fully-closed app would receive. assert.deepEqual(notes, [{ title: "⏰ Reminder", body: "Call mom" }]); // A VISIBLE page raises no system notification (you're already here) — and still no toast. globalThis.document.visibilityState = "visible"; notifyLive("n1", { type: "notix.ping", notice: { title: "⏰ Reminder", body: "again" } }); assert.equal(notes.length, 1); assert.equal(toasts.length, 0); // An event with no notice (and no notify hook) stays silent; so does a type no app owns. globalThis.document.visibilityState = "hidden"; notifyLive("n1", { type: "notix.ping" }); notifyLive("n1", { type: "mystery.event", notice: { title: "spoof" } }); assert.equal(notes.length, 1); } finally { globalThis.document = priorDoc; globalThis.Notification = priorNote; } }); await test("command reply: only confirmations (those with a `short`) calm down", () => { // A confirmation supplies a terse `short` → it counts down and shrinks. assert.equal(noticeCalmsDown({ text: "Added “milk” to your to-do list.", short: "added “milk”" }), true); // A genuine content reply like /help gives no `short` → it stays a real message. assert.equal(noticeCalmsDown({ sender: { name: "Eleven" }, text: "Commands you can use…" }), false); assert.equal(noticeCalmsDown({ short: " " }), false); // whitespace-only doesn't count assert.equal(noticeCalmsDown({}), false); assert.equal(noticeCalmsDown(), false); }); await test("command reply: compact one-liner parts + the loud→calm age threshold", () => { // The calmed-down one-liner prefers the reply's own terse `short`, else its text. assert.deepEqual( noticeParts({ sender: { name: "Reminders" }, text: "I'll remind you: “fart”.", short: "added “fart”" }), { from: "Reminders", what: "added “fart”" }, ); assert.deepEqual( noticeParts({ sender: { name: "Eleven" }, text: "Commands you can use…" }), { from: "Eleven", what: "Commands you can use…" }, ); assert.deepEqual(noticeParts({}), { from: "", what: "" }); // A fresh reply is loud; once aged past the threshold it renders compact. A reply // with no timestamp never auto-collapses (stays as first built). const now = 1_000_000; assert.equal(noticeIsCompact(now, now), false); assert.equal(noticeIsCompact(now - NOTICE_COMPACT_AFTER_MS + 1, now), false); assert.equal(noticeIsCompact(now - NOTICE_COMPACT_AFTER_MS, now), true); assert.equal(noticeIsCompact(0, now), false); }); await test("remind: chatMessage — fire is a bot message, setting is an action line", () => { // A fired reminder is a message from the bot (a string → a bubble), not a toast. assert.equal( remindApp.chatMessage({ type: "remind.fire", id: "r1", notice: { title: "⏰ Reminder", body: "Call mom" } }), "You asked me to remind you: “Call mom”", ); // Setting one is an action you took → a /me-style line, not a bubble. const add = remindApp.chatMessage({ type: "remind.add", id: "r1", text: "Call mom", when: 99 }); assert.equal(add.action, true); assert.match(add.text, /^set a reminder: “Call mom”/); // Housekeeping events produce no line. assert.equal(remindApp.chatMessage({ type: "remind.done", id: "r1" }), null); }); await test("todo: chatMessage renders an add as an action line, others silent", () => { const add = todoApp.chatMessage({ type: "todo.add", id: "t1", text: "buy milk" }); assert.equal(add.action, true); assert.match(add.text, /added “buy milk”/); assert.equal(todoApp.chatMessage({ type: "todo.done", id: "t1" }), null); }); await test("one message folds into TWO apps (owner state + chat action bubble)", () => { resetThread("dual"); // The SAME server message id (70) folds into the owning app (state) AND the chat // app (an action-line bubble). De-dupe must be per-app, or the chat fold is // silently dropped (the bug: the Interface updated but no action line appeared). foldEvent("dual", { type: "todo.add", id: "x1", text: "milk" }, { id: 70 }); foldHostEvent("dual", "chat", { type: "chat.msg", name: "To-do", text: "added milk", uid: "app:todo", action: true }, { id: 70 }); assert.equal(appStateFor("dual", "todo").items.length, 1, "owner app folded the event"); const chat = appStateFor("dual", "chat"); assert.equal(chat.msgs.length, 1, "chat app also folded the same message id"); assert.equal(chat.msgs[0].action, true, "and it's flagged as an action line"); }); await test("chat: an action line's raw payload keeps action:true (so editing it doesn't turn it into a bubble)", () => { const reduce = chatApp.reduce; const s = reduce(undefined, { type: "chat.msg", text: "Alice waves", name: "Alice", uid: "u-al", action: true }, { id: 90 }); assert.equal(s.msgs[0].raw.action, true, "{ ...raw, text } re-sends action:true on edit"); }); // ---- chat as an app (the lab) -------------------------------------------- // The chat app consumes the EXISTING protocol: original-shaped messages fold as host // events keyed by server message id, and reactions/edits/deletes are host-only // translations of the server's own frames. await test("chat: folds original-shaped messages (text, attachments, replies), dedupes by server id", () => { const reduce = chatApp.reduce; let s = reduce(undefined, { type: "chat.msg", text: "hi", name: "Al", uid: "u-al", ts: 1 }, { id: 10 }); s = reduce(s, { type: "chat.msg", text: "hi", name: "Al", uid: "u-al", ts: 1 }, { id: 10 }); // re-fold assert.equal(s.msgs.length, 1); assert.equal(s.msgs[0].id, "m10", "keyed by the server message id — the original identity"); // Attachment-only messages (a photo with no caption) still fold, with the RAW // metadata preserved (blob id, dimensions — needed to rebuild an edit payload). const att = { id: "blob1", name: "cat.jpg", type: "image/jpeg", size: 1024, w: 640, h: 480 }; s = reduce(s, { type: "chat.msg", attachments: [att], name: "Bo" }, { id: 11 }); assert.deepEqual(s.msgs[1].attachments, [att]); assert.deepEqual(s.msgs[1].raw.attachments, [att], "raw payload kept for { ...orig, text } edits"); // A reply carries its parent link + snippet through (the id feeds tallies). s = reduce(s, { type: "chat.msg", text: "yes!", replyTo: { id: 10, name: "Al", snippet: "hi" } }, { id: 12 }); assert.deepEqual(s.msgs[2].reply, { id: 10, name: "Al", snippet: "hi" }); // The parent's viewItems row tallies it. The tally rides the SOFT rev (srev), // so it patches the reply-count in place — it must NOT be in the hard vrev, // which would rebuild the whole bubble (and reload a photo). const v = viewItems(s.msgs); assert.equal(v[0].replyCount, 1, "parent shows one reply"); assert.ok(v[0].srev.endsWith("|1"), "the tally rides the soft rev (patched in place)"); assert.equal(v[0].vrev, "0|D|0", "the tally is NOT in the hard rev — no bubble rebuild"); s = reduce(s, { type: "chat.msg", text: " " }, { id: 13 }); // nothing to say assert.equal(s.msgs.length, 3); }); await test("chat: a scroll-back page folded later still sorts before newer messages", () => { const reduce = chatApp.reduce; let s = reduce(undefined, { type: "chat.msg", text: "newer", ts: 5 }, { id: 30 }); s = reduce(s, { type: "chat.msg", text: "older", ts: 1 }, { id: 10 }); // paged in afterwards assert.deepEqual(s.msgs.map((m) => m.sid), [10, 30], "sorted by server id, not fold order"); }); await test("chat: a legacy row with no payload ts dates from the fold meta (created_at), not 1970", () => { const reduce = chatApp.reduce; // Rows predating ts-stamping carry no inline ts; chatAppFoldMessage passes the // server's created_at (ms) through as meta.ts so they date correctly, not Jan 1970. const s = reduce(undefined, { type: "chat.msg", text: "legacy" }, { id: 40, ts: 1720000000000 }); assert.equal(s.msgs[0].ts, 1720000000000, "falls back to meta.ts, not 0"); }); await test("chat: viewItems derives time separators and sender runs (original feed rules)", () => { const DAY = 24 * 3600 * 1000; const base = Date.UTC(2026, 6, 1, 12, 0, 0); const msgs = [ { id: "m1", sid: 1, name: "Al", ts: base, rev: 0 }, { id: "m2", sid: 2, name: "Al", ts: base + 60 * 1000, rev: 0 }, // same run: 1 min later { id: "m3", sid: 3, name: "Bo", ts: base + 2 * 60 * 1000, rev: 0 }, // new sender { id: "m4", sid: 4, name: "Bo", ts: base + 20 * 60 * 1000, rev: 0 }, // >15 min gap, same day { id: "m5", sid: 5, name: "Bo", ts: base + DAY, rev: 0 }, // next day ]; const v = viewItems(msgs); assert.deepEqual(v[0].timeSep, { withDay: true }, "the oldest message opens with a dated separator"); assert.equal(v[0].cont, false); assert.equal(v[1].timeSep, null); assert.equal(v[1].cont, true, "same sender, no separator — a continuation"); assert.equal(v[2].cont, false, "sender changed"); assert.deepEqual(v[3].timeSep, { withDay: false }, ">15 min gap gets a time-only separator"); assert.equal(v[3].cont, false, "a separator restarts the run even for the same sender"); assert.deepEqual(v[4].timeSep, { withDay: true }, "a calendar-day change gets a dated separator"); // Presentation rides the rev, so a neighbour change re-renders the right bubble. assert.notEqual(v[1].vrev, v[2].vrev); }); await test("chat: viewItems groups runs by uid — a rename doesn't split, nameless senders don't merge", () => { const base = Date.UTC(2026, 6, 1, 12, 0, 0); const v = viewItems([ { id: "m1", sid: 1, uid: "u1", name: "Dermo", ts: base, rev: 0 }, { id: "m2", sid: 2, uid: "u1", name: "Dermot", ts: base + 1000, rev: 0 }, { id: "m3", sid: 3, uid: "u2", name: "", ts: base + 2000, rev: 0 }, { id: "m4", sid: 4, uid: "u3", name: "", ts: base + 3000, rev: 0 }, ]); assert.equal(v[1].cont, true, "same uid continues the run across a rename"); assert.equal(v[2].cont, false); assert.equal(v[3].cont, false, "different uids never group, even both nameless"); }); await test("chat: threadItems selects a root's whole thread — legacy chains resolve into it", () => { const msgs = [ { id: "m1", sid: 1, name: "Al", text: "root", ts: 1, reply: null, rev: 0 }, { id: "m2", sid: 2, name: "Bo", text: "aside", ts: 2, reply: null, rev: 0 }, { id: "m3", sid: 3, name: "Cy", text: "re", ts: 3, reply: { id: 1, name: "Al", snippet: "root" }, rev: 0 }, // A legacy chain: this reply quotes the REPLY (m3), not the root — it must // still resolve transitively into m1's thread. { id: "m4", sid: 4, name: "Al", text: "re-re", ts: 4, reply: { id: 3, name: "Cy", snippet: "re" }, rev: 0 }, { id: "m5", sid: 5, name: "Bo", text: "other thread", ts: 5, reply: { id: 2, name: "Bo", snippet: "aside" }, rev: 0 }, ]; assert.deepEqual( threadItems(msgs, 1).map((m) => m.sid), [1, 3, 4], "root first, then its thread in server order; the reply-to-a-reply resolves in; asides and other threads stay out", ); assert.deepEqual(threadItems(msgs, 2).map((m) => m.sid), [2, 5], "the other thread folds on its own root"); // Root scrolled out of the loaded window: the thread still folds from the // replies we can see (the view renders the quoted snapshot as its header — // the chain resolves to the same unloaded sid either way). assert.deepEqual(threadItems(msgs.slice(2), 1).map((m) => m.sid), [3, 4], "unloaded root — replies only, chain intact"); assert.deepEqual(threadItems(msgs, 99), [], "an unknown root has no thread"); }); await test("chat: reactions, edits, deletes fold as host translations of server frames", () => { const reduce = chatApp.reduce; let s = reduce(undefined, { type: "chat.msg", text: "hello", name: "Al", ts: 1, uid: "alice" }, { id: 20 }); const rev0 = s.msgs[0].rev; s = reduce(s, { type: "chat.reaction", targetSid: 20, emoji: "👍", uid: "bob", on: true, ts: 2 }, {}, { me: "alice" }); assert.deepEqual(s.msgs[0].reactions, [{ emoji: "👍", count: 1, mine: false }]); assert.equal(s.msgs[0].rev, rev0 + 1, "a reaction bumps the soft rev"); s = reduce(s, { type: "chat.reaction", targetSid: 20, emoji: "👍", uid: "charlie", on: true, ts: 3 }, {}, { me: "alice" }); assert.deepEqual(s.msgs[0].reactions, [{ emoji: "👍", count: 2, mine: false }]); const revBeforeEdit = s.msgs[0].rev; s = reduce(s, { type: "chat.edit", msgId: 20, text: "hello again" }); assert.equal(s.msgs[0].text, "hello again"); assert.equal(s.msgs[0].edited, true); assert.ok(s.msgs[0].rev > revBeforeEdit, "an edit DOES bump the hard rev — the bubble re-renders its text"); s = reduce(s, { type: "chat.edit", msgId: 999, text: "ghost" }); // unknown target — no-op assert.equal(s.msgs.length, 1); s = reduce(s, { type: "chat.delete", msgId: 20 }); assert.deepEqual(s.msgs, []); }); await test("chat mutations are host-only: wire payloads can't spoof them", () => { resetThread("c0"); // A member posting {type:"chat.delete"} as a message must fold NOTHING — the // type is not wire-owned (deletes are server-verified, author-only). Same for // chat.reaction and chat.edit — only the host (shell.js) can emit those. assert.equal(foldEvent("c0", { type: "chat.delete", msgId: 1 }, { id: 2 }), null); assert.equal(foldEvent("c0", { type: "chat.reaction", targetSid: 1, emoji: "👍", uid: "x", on: true }, { id: 3 }), null); assert.equal(foldEvent("c0", { type: "chat.edit", msgId: 1, text: "edited" }, { id: 4 }), null); assert.equal(foldEvent("c0", { type: "chat.msg", text: "spoof" }, { id: 5 }), null); // The host doorway reaches them. const app = foldHostEvent("c0", "chat", { type: "chat.msg", text: "real" }, { id: 6 }); assert.equal(app && app.name, "chat"); assert.equal(appStateFor("c0", "chat").msgs.length, 1); }); await test("chat: opts out of snapshots — its state IS the log", () => { resetThread("c1"); foldHostEvent("c1", "chat", { type: "chat.msg", text: "hi" }, { id: 5 }); assert.equal(appStateFor("c1", "chat").msgs.length, 1); assert.equal(takeSnapshot("c1"), null, "chat folds never dirty the snapshot"); // A snapshotting app in the SAME thread still snapshots — without chat state. foldEvent("c1", { type: "poll.create", id: "p", q: "Q?", options: ["a", "b"] }, { id: 6 }); const snap = takeSnapshot("c1"); assert.ok(snap.states.poll, "poll state saved"); assert.equal(snap.states.chat, undefined, "chat state excluded"); }); await test("another app's snapshot horizon never blocks chat folds", () => { resetThread("c2"); // A poll snapshot covers message ids <= 100 — chat isn't in any snapshot, so // an untyped chat message at id 50 must still fold into the chat feed. seedThread("c2", { poll: { polls: [] } }, 100); foldHostEvent("c2", "chat", { type: "chat.msg", text: "history" }, { id: 50 }); assert.equal(appStateFor("c2", "chat").msgs.length, 1, "folded despite the horizon"); }); // ---- sidebar presence hooks (preview / unread) ----------------------------- await test("apps opt into sidebar presence: poll previews + marks unread on create", () => { assert.equal(appPreview({ type: "poll.create", q: "Lunch?", options: ["a", "b"] }), "📊 Lunch?"); assert.equal(appUnread({ type: "poll.create", q: "Lunch?" }), true); // Votes stay silent — the tally updates live anyway. assert.equal(appPreview({ type: "poll.vote", pollId: "p", choice: 0 }), null); assert.equal(appUnread({ type: "poll.vote", pollId: "p" }), false); // To-do opts in for adds (how a group notices shared-list activity); its // quiet events (done/reopen/remove) stay silent. assert.equal(appPreview({ type: "todo.add", id: "a", text: "milk", name: "Ana" }), "✓ Ana added “milk” to the to-dos"); assert.equal(appUnread({ type: "todo.add", id: "a" }), true); assert.equal(appPreview({ type: "todo.done", id: "a" }), null); assert.equal(appUnread({ type: "todo.done", id: "a" }), false); // Unowned types are silent too. assert.equal(appPreview({ type: "mystery.event" }), null); }); await test("board previews + marks unread on open; strokes stay silent", () => { assert.equal(appPreview({ type: "board.open" }), "🎨 opened the whiteboard"); assert.equal(appUnread({ type: "board.open" }), true); assert.equal(appPreview({ type: "board.stroke", id: "s1" }), null); assert.equal(appUnread({ type: "board.stroke", id: "s1" }), false); }); // ---- ephemeral events (relay-only, never folded) --------------------------- await test("deliverEphemeral routes to the owning app's hook; fold state untouched", () => { const pings = []; defineApp({ name: "probe", title: "Probe", glyph: "×", scope: "system", initialState: () => ({ n: 0 }), events: { "probe.ping": (s) => s }, // owned via a no-op fold, like chat.typing ephemeral(e, ctx) { pings.push({ from: e.name, thread: ctx.threadId }); }, }); resetThread("e1"); deliverEphemeral("e1", { type: "probe.ping", name: "Al" }); assert.deepEqual(pings, [{ from: "Al", thread: "e1" }]); deliverEphemeral("e1", { type: "unowned.type", name: "Al" }); // silently ignored assert.equal(pings.length, 1); assert.equal(takeSnapshot("e1"), null, "nothing folded, nothing to save"); }); await test("ctx.emitEphemeral goes through the ephemeral transport, not emit", async () => { const stored = []; const relayed = []; setEmitTransport((tid, e) => (stored.push(e), Promise.resolve(true))); setEphemeralTransport((tid, e) => (relayed.push({ tid, e }), Promise.resolve(true))); // The probe app registered above owns "probe.ping"; drive its hook via a // second app? No — reach ctx directly through a command on a fresh app. defineApp({ name: "probe2", title: "Probe2", glyph: "×", scope: "shared", initialState: () => ({}), events: { "probe2.beat": (s) => s }, commands: { "/beat": (args, ctx) => ctx.emitEphemeral({ type: "probe2.beat" }) }, }); runCommand("/beat", "chatY"); await tick(); assert.equal(stored.length, 0, "nothing persisted"); assert.deepEqual(relayed[0].tid, "chatY"); assert.equal(relayed[0].e.type, "probe2.beat"); }); // ---- keyedList (incremental list reconciliation) --------------------------- // A minimal DOM stand-in: just the surface keyedList touches (children, // firstChild, insertBefore, replaceWith, remove, dataset, nextSibling). function fakeContainer() { const el = { children: [], get firstChild() { return el.children[0] || null; }, insertBefore(node, ref) { const cur = el.children.indexOf(node); if (cur !== -1) el.children.splice(cur, 1); const i = ref ? el.children.indexOf(ref) : el.children.length; el.children.splice(i === -1 ? el.children.length : i, 0, node); node.parent = el; }, }; return el; } function fakeNode(label) { return { label, dataset: {}, parent: null, get nextSibling() { const sib = this.parent ? this.parent.children : []; const i = sib.indexOf(this); return i === -1 ? null : sib[i + 1] || null; }, replaceWith(other) { const sib = this.parent.children; sib[sib.indexOf(this)] = other; other.parent = this.parent; this.parent = null; }, remove() { if (!this.parent) return; const sib = this.parent.children; const i = sib.indexOf(this); if (i !== -1) sib.splice(i, 1); this.parent = null; }, }; } await test("keyedList appends without rebuilding, re-renders on rev, removes gone", () => { const el = fakeContainer(); let built = 0; const draw = (items) => keyedList(el, items, { key: (i) => i.id, rev: (i) => i.rev || 0, render: (i) => (built++, fakeNode(i.id + ":" + (i.rev || 0))), }); draw([{ id: "a" }, { id: "b" }]); assert.deepEqual(el.children.map((n) => n.label), ["a:0", "b:0"]); assert.equal(built, 2); const [nodeA, nodeB] = el.children; // Append: existing nodes are reused untouched (scroll/focus/images survive). draw([{ id: "a" }, { id: "b" }, { id: "c" }]); assert.equal(el.children[0], nodeA, "a reused"); assert.equal(el.children[1], nodeB, "b reused"); assert.equal(built, 3, "only c was built"); // A rev change re-renders just that item, in place. draw([{ id: "a" }, { id: "b", rev: 1 }, { id: "c" }]); assert.equal(el.children[0], nodeA, "a still reused"); assert.equal(el.children[1].label, "b:1", "b re-rendered"); assert.equal(built, 4); // Removal drops the node; order holds. draw([{ id: "a" }, { id: "c" }]); assert.deepEqual(el.children.map((n) => n.label), ["a:0", "c:0"]); }); await test("keyedList patches a retained node in place when only its soft rev changed", () => { const el = fakeContainer(); let built = 0; const patched = []; const draw = (items) => keyedList(el, items, { key: (i) => i.id, rev: (i) => i.rev || 0, srev: (i) => i.srev || 0, render: (i) => (built++, fakeNode(i.id)), update: (node, i) => patched.push([node.label, i.srev]), }); draw([{ id: "a", srev: 0 }]); const nodeA = el.children[0]; assert.equal(built, 1); // A soft-rev change patches the SAME node — no rebuild (a loaded image survives). draw([{ id: "a", srev: 1 }]); assert.equal(el.children[0], nodeA, "node reused, not replaced"); assert.equal(built, 1, "nothing rebuilt"); assert.deepEqual(patched, [["a", 1]], "update() ran once with the new item"); // A hard-rev change still rebuilds — and does NOT also fire update(). draw([{ id: "a", rev: 1, srev: 2 }]); assert.notEqual(el.children[0], nodeA, "rebuilt on the hard rev"); assert.equal(built, 2); assert.deepEqual(patched, [["a", 1]], "update() not called on a rebuild"); // No change at all → neither a rebuild nor a patch. draw([{ id: "a", rev: 1, srev: 2 }]); assert.equal(built, 2); assert.deepEqual(patched, [["a", 1]]); }); // ---- app-contributed message actions ------------------------------------ await test("messageActionsFor offers Save link only on messages with a link", () => { assert.deepEqual(messageActionsFor({ text: "no links here", from: "Bob" }), []); const acts = messageActionsFor({ text: "look at https://x.com now", from: "Bob" }); assert.equal(acts.length, 1); assert.equal(acts[0].label, "Save link"); }); await test("Save link routes into the bookmarks app-thread with attribution", async () => { const sent = []; setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); const acts = messageActionsFor({ text: "read https://x.com/post today", from: "Bob" }); await acts[0].run(); // resolves the app-thread (stubbed → "todoThread") and emits assert.equal(sent.length, 1); assert.equal(sent[0].event.type, "bookmark.add"); assert.equal(sent[0].event.url, "https://x.com/post"); assert.equal(sent[0].event.from, "Bob", "records who posted the link"); }); // ---- DNSimple companion app --------------------------------------------- const dnsimpleReduce = dnsimpleApp.reduce; await test("dnsimple: a fetched list replaces state; normalises + drops malformed rows", () => { const s0 = dnsimpleApp.initialState(); assert.deepEqual(s0.domains, []); // Structured rows (name + table fields) are stored as-is. const rows = [ { name: "a.com", expires_on: "2027-01-01", auto_renew: true }, { name: "b.net", expires_on: "", auto_renew: false }, ]; const s1 = dnsimpleReduce(s0, { type: "dnsimple.domains", domains: rows, ts: 100 }); assert.deepEqual(s1.domains.map((d) => d.name), ["a.com", "b.net"]); assert.equal(s1.domains[0].auto_renew, true, "table fields survive the fold"); assert.equal(s1.fetchedAt, 100); // The latest refresh WINS (replace, not append) — dropped domains disappear. const s2 = dnsimpleReduce(s1, { type: "dnsimple.domains", domains: [{ name: "a.com" }], ts: 200 }); assert.deepEqual(s2.domains.map((d) => d.name), ["a.com"]); assert.equal(s2.fetchedAt, 200); // Old string form is normalised to objects (back-compat with pre-table events). const s3 = dnsimpleReduce(s2, { type: "dnsimple.domains", domains: ["x.com", "y.io"], ts: 300 }); assert.deepEqual(s3.domains.map((d) => d.name), ["x.com", "y.io"]); // Hostile / malformed inputs fold to a no-op or drop bad entries (never crash). assert.equal(dnsimpleReduce(s3, { type: "dnsimple.domains" }), s3, "missing domains → unchanged"); assert.equal(dnsimpleReduce(s3, { type: "dnsimple.domains", domains: "nope", ts: 400 }), s3, "non-array → unchanged"); const s4 = dnsimpleReduce(s3, { type: "dnsimple.domains", domains: [{ name: "ok.com" }, 42, null, {}], ts: 500 }); assert.deepEqual(s4.domains.map((d) => d.name), ["ok.com"], "entries without a string name are dropped"); }); await test("dnsimple: takeover app — the table is the whole thread, no chat echo", () => { // No chatMessage → takeover (Interface only), so a fetched list never echoes as // a chat bubble. The domain list lives in the table, nowhere else. assert.equal(dnsimpleApp.chatMessage, undefined, "no chatMessage hook (takeover, not companion)"); assert.equal(appWantsChatView("dnsimple"), false, "no chatMessage → not a companion (takeover)"); assert.equal(dnsimpleApp.requiresService, "dnsimple", "gated on the connected service"); }); await test("dnsimple: registers no slash command (the Interface IS the list)", () => { // No `/list` (or any) command — the domains live in the app's own view, so a // verb would be redundant and only risks routing through (and waking) the bot. assert.deepEqual(Object.keys(dnsimpleApp.commands || {}), []); assert.equal(parseCommand("/list").app, null, "nothing owns /list"); }); await test("setup: steps fold idempotently and hostile events are no-ops", () => { const r = setupApp.reduce; let s = r(undefined, { type: "setup.step", step: "group", label: "named this space “C”", value: "C" }); assert.equal(s.steps.group.done, true); assert.equal(s.steps.group.value, "C"); const again = r(s, { type: "setup.step", step: "group", label: "named this space “C”", value: "C" }); assert.equal(again.steps.group.done, true, "refold converges"); assert.equal(r(s, { type: "setup.step" }), s, "missing step → unchanged"); const two = r(s, { type: "setup.step", step: "keys", label: "skipped the key backup", value: "skipped" }); assert.equal(two.steps.group.done, true, "earlier steps survive"); assert.equal(two.steps.keys.value, "skipped"); }); await test("setup: chat-only projection — completed steps become action lines", () => { assert.equal(appWantsChatView("setup"), true, "chatMessage → the chat view is the surface"); assert.equal(appChatOnly("setup"), true, "no Interface view or toggle — the conversation is the thread"); assert.equal(setupApp.render, undefined, "the progress card is gone (the Admin panel superseded it)"); const line = setupApp.chatMessage({ type: "setup.step", step: "name", label: "noted: you're Paul" }); assert.deepEqual(line, { text: "noted: you're Paul", action: true }); assert.equal(setupApp.chatMessage({ type: "setup.step", step: "x" }), null, "no label → silent"); assert.equal(setupApp.chatMessage({ type: "other" }), null); assert.equal(setupApp.listed, false, "never offered in the Apps menu"); }); await test("admin: interface-first companion with a read-only update stream", () => { assert.equal(adminApp.listed, false, "never offered in the Apps menu"); // The panel leads: a companion (chatMessage → Chat view exists) that opens in // its Interface, with the update stream one toggle away — and read-only there. assert.equal(appWantsChatView("admin"), true, "chatMessage → companion (the update stream is the Chat view)"); assert.equal(appChatOnly("admin"), false, "the Interface (settings panel) is a real view"); assert.equal(appDefaultView("admin"), "interface", "opens as the app, not the split"); assert.equal(appDefaultView("todo"), "split", "companions without a preference keep the split default"); assert.equal(adminApp.chatReadOnly, true, "the app posts into the stream; the admin doesn't"); assert.equal(adminApp.senderKind, undefined, "Admin reads as an APP; only setup overrides to BOT"); assert.equal(setupApp.senderKind, "bot", "the setup thread presents as Eleven (BOT), not APP"); }); await test("admin: release notes fold as a seen-watermark and project as Admin bubbles", () => { const r = adminApp.reduce; // A posted note records its id; re-folding the same id is a no-op (two devices // may race the same entry). let s = r(undefined, { type: "admin.note", id: "2026-07-08-read-sync", text: "Unread dots now sync." }); assert.deepEqual(s.seen, ["2026-07-08-read-sync"]); assert.equal(r(s, { type: "admin.note", id: "2026-07-08-read-sync", text: "dup" }), s, "duplicate id → unchanged"); assert.equal(r(s, { type: "admin.note", text: "no id" }), s, "id-less note → unchanged"); // First-run baseline: everything current marked seen, and merges are a union. s = r(undefined, { type: "admin.baseline", ids: ["a", "b"] }); assert.deepEqual(s.seen, ["a", "b"]); assert.deepEqual(r(s, { type: "admin.baseline", ids: ["b", "c"] }).seen, ["a", "b", "c"], "second baseline merges"); // Projections: a note is a bubble from Admin (a string, not an action line); // the baseline is silent housekeeping. assert.equal(adminApp.chatMessage({ type: "admin.note", id: "x", text: "Shipped a thing." }), "Shipped a thing."); assert.equal(adminApp.chatMessage({ type: "admin.note", id: "x" }), null, "text-less note → silent"); assert.equal(adminApp.chatMessage({ type: "admin.baseline", ids: ["a"] }), null); }); await test("welcome: the joiner conversation mirrors setup's shape", () => { const r = welcomeApp.reduce; const s = r(undefined, { type: "welcome.step", step: "name", label: "noted: you're Ada", value: "Ada" }); assert.equal(s.steps.name.done, true); assert.equal(r(s, { type: "welcome.step" }), s, "missing step → unchanged"); assert.equal(appWantsChatView("welcome"), true); assert.equal(appChatOnly("welcome"), true, "conversation-only, no Interface"); assert.equal(welcomeApp.senderKind, "bot", "presents as Eleven (BOT) in the sidebar"); assert.equal(welcomeApp.listed, false, "never offered in the Apps menu"); const line = welcomeApp.chatMessage({ type: "welcome.step", step: "name", label: "noted: you're Ada" }); assert.deepEqual(line, { text: "noted: you're Ada", action: true }); }); await test("conversational answers: one waiter per thread, consumed on delivery", () => { const got = []; awaitAnswer("t1", (text) => got.push(text)); assert.equal(deliverAnswer("t1", "Paul", () => {}), true, "waiter consumes the answer"); assert.deepEqual(got, ["Paul"]); assert.equal(deliverAnswer("t1", "again", () => {}), false, "consumed — nothing listening"); awaitAnswer("t2", () => {}); cancelAnswer("t2"); assert.equal(deliverAnswer("t2", "x", () => {}), false, "cancelled waiter never fires"); }); await test("conversational answers: every cancel fires the cancelled hook (#1375)", () => { // chat.js registers this to clear the labelled composer — the dismiss X, a // chip's cancelInput, and the folded re-derive all ride the same path. const cleared = []; setAnswerCancelledHook((threadId) => cleared.push(threadId)); awaitAnswer("t9", () => {}); cancelAnswer("t9"); cancelAnswer("t-idle"); // no waiter parked — the label still clears assert.deepEqual(cleared, ["t9", "t-idle"]); setAnswerCancelledHook(null); }); await test("conversational answers: attachments ride the payload (the photo step)", () => { const got = []; awaitAnswer("t3", (text, payload) => got.push([text, ((payload && payload.attachments) || []).length])); deliverAnswer("t3", { text: "", attachments: [{ id: "b1", type: "image/jpeg" }] }, () => {}); assert.deepEqual(got, [["", 1]], "an attachment-only send still answers the waiter"); }); // ---- group-scoped apps (groupapps registry + @-mention routing) ------------ const groupAppsReduce = groupAppsApp.reduce; await test("groupapps: adds fold idempotently, removes drop, malformed no-op", () => { let s = groupAppsReduce(undefined, { type: "groupapps.add", app: "todo", name: "Ana", ts: 5 }); assert.deepEqual(s, { apps: [{ id: "todo", by: "Ana", ts: 5 }] }); // Re-adding (another member, a refold) is a no-op — the item id IS the app name. const s2 = groupAppsReduce(s, { type: "groupapps.add", app: "todo", name: "Bob", ts: 9 }); assert.deepEqual(s2.apps, s.apps); // Unknown app names still fold (state converges across client versions). s = groupAppsReduce(s, { type: "groupapps.add", app: "flarp", name: "Bob", ts: 9 }); assert.equal(s.apps.length, 2); s = groupAppsReduce(s, { type: "groupapps.remove", app: "todo" }); assert.deepEqual(s.apps.map((a) => a.id), ["flarp"]); // Hostile / malformed events are no-ops, never a crash (Invariant #8). assert.equal(groupAppsReduce(s, { type: "groupapps.add" }), s); assert.equal(groupAppsReduce(s, { type: "groupapps.add", app: 7 }), s); assert.deepEqual(groupAppsReduce(s, { type: "groupapps.remove", app: "missing" }).apps, s.apps); }); await test("groupapps: add/remove project as action lines; previews name the actor", () => { // groupFeedMessage, not chatMessage — the registry narrates group feeds // and must never read as wanting a Chat view of its own. assert.deepEqual(groupAppsApp.groupFeedMessage({ type: "groupapps.add", app: "todo" }), { text: "added To-do to this chat", action: true }); assert.deepEqual(groupAppsApp.groupFeedMessage({ type: "groupapps.remove", app: "todo" }), { text: "removed To-do from this chat", action: true }); // An app this client doesn't know still reads sensibly. assert.equal(groupAppsApp.groupFeedMessage({ type: "groupapps.add", app: "flarp" }).text, "added flarp to this chat"); assert.equal(appPreview({ type: "groupapps.add", app: "todo", name: "Ana" }), "Ana added To-do"); assert.equal(appPreview({ type: "groupapps.remove", app: "todo", name: "Ana" }), "Ana removed To-do"); }); await test("groupAppsFor resolves added apps, filtering unknown and non-group-capable", () => { resetThread("grp1"); foldEvent("grp1", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 }); foldEvent("grp1", { type: "groupapps.add", app: "flarp", name: "Ana" }, { id: 2 }); // unknown → inert foldEvent("grp1", { type: "groupapps.add", app: "connect", name: "Ana" }, { id: 3 }); // not groupCapable → inert assert.deepEqual(groupAppsFor("grp1").map((a) => a.name), ["todo"]); foldEvent("grp1", { type: "groupapps.remove", app: "todo" }, { id: 4 }); assert.deepEqual(groupAppsFor("grp1"), []); resetThread("grp1"); }); await test("the group-capable roster: shared-list apps in, credential-backed apps out", () => { const names = groupCapableApps().map((a) => a.name); for (const n of ["todo", "remind", "bookmarks"]) assert.ok(names.includes(n), n + " is addable to groups"); // Their state derives from one member's private service credentials — sharing // those needs its own design, so they stay personal-only on purpose. for (const n of ["connect", "dnsimple"]) assert.ok(!names.includes(n), n + " stays personal-only"); // Poll/Whiteboard are chat-place apps since the ambient panel retired (#405). for (const n of ["poll", "board"]) assert.ok(names.includes(n), n + " lives in chats now"); }); await test("Reminders' fire bubble drops 'you asked me' in a group", () => { const e = { type: "remind.fire", id: "r1", notice: { title: "⏰ Reminder", body: "call mum" } }; assert.equal(remindApp.chatMessage(e), "You asked me to remind you: “call mum”"); assert.equal(remindApp.chatMessage(e, { group: true }), "Reminder: “call mum”"); // The add action line is host-prefixed with the actor either way. const add = remindApp.chatMessage({ type: "remind.add", id: "r1", text: "call mum", when: 0 }, { group: true }); assert.equal(add.action, true); }); await test("parseAppMention matches an added app by title or name, leading-@ only", () => { resetThread("grp2"); foldEvent("grp2", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 }); let m = parseAppMention("@To-do buy milk", "grp2"); assert.equal(m.app.name, "todo"); assert.equal(m.args, "buy milk"); m = parseAppMention("@to-DO buy milk", "grp2"); // case-insensitive assert.equal(m.args, "buy milk"); m = parseAppMention("@todo buy milk", "grp2"); // the app name works too assert.equal(m.args, "buy milk"); assert.equal(parseAppMention("@To-do", "grp2").args, "", "a bare mention parses with empty args"); // Must break on whitespace — "@To-dos…" is conversation, not an address. assert.equal(parseAppMention("@To-dos are great", "grp2"), null); assert.equal(parseAppMention("hey @To-do buy milk", "grp2"), null, "leading-@ only"); assert.equal(parseAppMention("@To-do buy milk", "grpEmpty"), null, "no apps added → no match"); resetThread("grp2"); }); await test("runAppMention emits the default command's event into the GROUP thread", async () => { resetThread("grp3"); foldEvent("grp3", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 }); const sent = []; const replies = []; setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); // Resolver poisoned: a group mention must NOT route to the personal app-thread. setAppThreadResolver(async () => { throw new Error("group mentions never route"); }); assert.equal(runAppMention("@To-do buy milk", "grp3", (r) => replies.push(r)), true); await tick(); setAppThreadResolver(async () => "todoThread"); // restore for later tests assert.equal(sent.length, 1); assert.equal(sent[0].threadId, "grp3", "the event lands in the group thread"); assert.equal(sent[0].event.type, "todo.add"); assert.equal(sent[0].event.text, "buy milk"); // The success confirmation is suppressed — the folded action line is the record. assert.deepEqual(replies, []); resetThread("grp3"); }); await test("/todo typed in a chat with To-do ADDED targets the GROUP instance", async () => { resetThread("grp5"); foldEvent("grp5", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 }); const sent = []; const replies = []; setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); // Resolver poisoned: with the app added here, /todo must NOT open a personal // app-thread — the group's shared instance is the nearer scope. setAppThreadResolver(async () => { throw new Error("group-scoped commands never route to the personal thread"); }); assert.equal(runCommand("/todo group milk", "grp5", (r) => replies.push(r)), true); await tick(); assert.equal(sent.length, 1); assert.equal(sent[0].threadId, "grp5", "the event lands in the group thread"); assert.equal(sent[0].event.type, "todo.add"); assert.equal(sent[0].event.text, "group milk"); // In-thread the folded action line is the record — no separate confirmation. assert.deepEqual(replies, []); setAppThreadResolver(async () => "todoThread"); // restore for later tests // Without the app added (any other chat), /todo still routes personally. const sent2 = []; setEmitTransport((threadId, event) => (sent2.push({ threadId, event }), Promise.resolve(true))); assert.equal(runCommand("/todo personal milk", "chatX"), true); await tick(); assert.equal(sent2[0].threadId, "todoThread"); resetThread("grp5"); }); await test("parseHere: leading @here only, breaking on whitespace", () => { assert.deepEqual(parseHere("@here buy milk"), { rest: "buy milk" }); assert.deepEqual(parseHere("@here"), { rest: "" }); assert.deepEqual(parseHere(" @here buy milk "), { rest: "buy milk" }); assert.equal(parseHere("@heresy is text"), null); assert.equal(parseHere("buy milk @here"), null); assert.equal(parseHere(""), null); }); await test("parseMeFlag: leading @me only, breaking on whitespace", () => { assert.deepEqual(parseMeFlag("@me dentist"), { rest: "dentist" }); assert.deepEqual(parseMeFlag("@me"), { rest: "" }); assert.deepEqual(parseMeFlag(" @me dentist "), { rest: "dentist" }); assert.equal(parseMeFlag("@mention is text"), null); assert.equal(parseMeFlag("dentist @me"), null); assert.equal(parseMeFlag(""), null); }); await test("parseEveryone accepts @space as an alias", () => { assert.deepEqual(parseEveryone("@space tada"), { rest: "tada" }); assert.deepEqual(parseEveryone("@space"), { rest: "" }); assert.equal(parseEveryone("@spacecraft launch"), null); }); await test("/todo @me files personally even where the chat copy claims the bare verb", async () => { resetThread("grpMe"); setThreadIsGroup((id) => id === "grpMe"); setThreadOwner((id) => (id === "todoThread" ? "todo" : null)); foldEvent("grpMe", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 }); const sent = []; const replies = []; setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); // The bare verb goes to the chat copy… assert.equal(runCommand("/todo team snacks", "grpMe", (r) => replies.push(r)), true); await tick(); assert.equal(sent[0].threadId, "grpMe"); // …and @me overrides toward privacy. assert.equal(runCommand("/todo @me dentist", "grpMe", (r) => replies.push(r)), true); await tick(); assert.equal(sent[1].threadId, "todoThread", "@me reaches the personal copy past the chat claim"); assert.equal(sent[1].event.text, "dentist"); const mine = replies[replies.length - 1]; assert.equal(mine.place, "you", "the routed reply is stamped with the personal place"); assert.match(mine.text, /your to-do list/); setThreadOwner((id) => null); setThreadIsGroup(null); resetThread("grpMe"); }); await test("@me for an app with no personal copy explains instead of mis-filing", async () => { const sent = []; const replies = []; setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); assert.equal(runCommand("/poll @me free pizza?", "chatX", (r) => replies.push(r)), true); await tick(); assert.equal(sent.length, 0, "nothing emitted — the text is not silently filed anywhere"); assert.match(replies[0].text, /needs an audience/, "the app's own placeNote is the explanation"); }); await test("@here on a chat-only app is stripped, never folded into the command's content", async () => { resetThread("grpHere"); setThreadIsGroup((id) => id === "grpHere"); const sent = []; setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); assert.equal(runCommand("/poll @here Pizza?", "grpHere", () => {}), true); await tick(); // The poll.create carries "Pizza?", not "@here Pizza?" — the redundant flag // (poll auto-adopts anyway) is removed, not baked into the title. const create = sent.find((s) => s.event.type === "poll.create"); assert.ok(create, "the poll was created"); assert.equal(create.event.q, "Pizza?", "@here stripped from the poll question"); setThreadIsGroup(null); resetThread("grpHere"); }); // A synthetic all-places fixture (you+chat+space) for the flag/alternate and // space-routing machinery below: the SHIPPED apps are parked at you+chat // while the places UI is hidden (docs/space-apps.md § The pullback), so a // stand-in keeps the space paths covered — they must still work the day an // app adds "space" back to its places line. const triple = defineApp({ name: "triple", title: "Triple", glyph: "▲", places: ["you", "chat", "space"], initialState: () => ({ items: [] }), events: { "triple.add": (s, e) => (e.id && e.text ? { items: [...s.items, { id: e.id, text: e.text }] } : s), }, commands: { "/triple": { desc: "Add a triple", args: "", run: (args, ctx) => { if (!args) return ctx.reply({ text: "Usage: /triple " }); ctx.emit({ type: "triple.add", id: ctx.id(), text: args }); // Words the destination via ctx.place, like todo does in production — // the routed-reply test asserts both the app wording and the stamp. const dest = ctx.place === "space" ? "the space triple list" : ctx.place === "chat" ? "this chat's triple list" : "your triple list"; ctx.reply({ text: `Added to ${dest}.`, short: "added", actions: [{ label: "View Triple", run: ctx.open }] }); }, }, }, render() {}, }); await test("the / catalog offers flagged alternates when several copies are reachable", async () => { resetThread("grpAlt"); resetThread("spaceT"); setThreadIsGroup((id) => id === "grpAlt"); setSpaceThread(() => "spaceT"); foldEvent("grpAlt", { type: "groupapps.add", app: "triple", name: "Ana" }, { id: 1 }); foldEvent("spaceT", { type: "spaceapps.add", app: "triple", name: "Ana" }, { id: 1 }); const cat = commandCatalog("grpAlt"); const names = cat.map((c) => c.name); assert.ok(names.includes("/triple"), "the bare verb (the default route) leads"); assert.equal(cat.find((c) => c.name === "/triple").place, "chat"); const everyoneAlt = cat.find((c) => c.name === `/triple ${EVERYONE_FLAG}`); assert.ok(everyoneAlt && everyoneAlt.alt && everyoneAlt.place === "space", "the space copy rides as a flagged alternate"); assert.equal(everyoneAlt.desc, "Add a triple for this space", "an existing copy gets the addressing words — no install language"); const meAlt = cat.find((c) => c.name === `/triple ${ME_FLAG}`); assert.ok(meAlt && meAlt.alt && meAlt.place === "you", "the personal place rides as a flagged alternate"); assert.match(meAlt.desc, /just for you$/); // /help lists each verb once and teaches the flags in its closing sentence. const replies = []; assert.equal(runCommand("/help", "grpAlt", (r) => replies.push(r)), true); await tick(); // One list line per verb (alt rows filtered): the desc appears once even // though the closing sentence itself demos "/todo @me". assert.equal((replies[0].text.match(/Add a triple/g) || []).length, 1, "alt rows stay out of the /help list"); assert.ok(replies[0].text.includes(`${ME_FLAG}, ${HERE_FLAG}, or ${EVERYONE_FLAG}`), "/help teaches the canonical spellings"); setThreadIsGroup(null); setSpaceThread(null); resetThread("grpAlt"); resetThread("spaceT"); // With no nearer claim anywhere, no ADDRESSING alternates — the bare verb // already files personally, so @me would be a no-op row. (The fresh case // still offers the INSTALL rows — the next test.) assert.ok(!commandCatalog("chatX").some((c) => c.name === `/triple ${ME_FLAG}`), "no @me alternate without a nearer claim"); }); await test("the / catalog offers install rows for the fresh case — the whole flag family, not a suffix", () => { resetThread("grpF"); setThreadIsGroup((id) => id === "grpF"); const cat = commandCatalog("grpF"); const here = cat.find((c) => c.name === `/triple ${HERE_FLAG}`); assert.ok(here && here.alt && here.place === "chat", "@here rides as an install offer in a group"); assert.equal(here.desc, "Add a triple to a new Triple app for this group", "an install row names the new app it creates"); const everyone = cat.find((c) => c.name === `/triple ${EVERYONE_FLAG}`); assert.ok(everyone && everyone.alt && everyone.place === "space", "@everyone rides as an install offer"); assert.equal(everyone.desc, "Add a triple to a new Triple app for this space"); // sortPlace carries the BARE verb's place, so the typeahead's nearness sort // never ranks an install offer above the route the bare verb takes. assert.equal(here.sortPlace, "you"); assert.equal(everyone.sortPlace, "you"); // A parked (you+chat) app never offers @everyone — the pullback's whole // point: the flag rows follow the places declaration, no UI enforcement. assert.ok(!cat.some((c) => c.name === `/todo ${EVERYONE_FLAG}`), "no @everyone offer for a you+chat app"); assert.ok(cat.some((c) => c.name === `/todo ${HERE_FLAG}`), "the chat offer stays for a you+chat app"); // Outside a group @here would only refuse — but @everyone works from any chat. const dm = commandCatalog("chatX"); assert.ok(!dm.some((c) => c.name === `/triple ${HERE_FLAG}`), "@here only offered in a group"); assert.ok(dm.some((c) => c.name === `/triple ${EVERYONE_FLAG}`), "@everyone offered from any chat"); // A member the policy bars from space installs isn't offered a row that // would only refuse. setSpaceInstallPolicy(() => ({ adminsOnly: true, canInstall: false, adderOK: () => false })); assert.ok(!commandCatalog("grpF").some((c) => c.name === `/triple ${EVERYONE_FLAG}`), "no @everyone offer when installs are admins-only"); setSpaceInstallPolicy(null); setThreadIsGroup(null); resetThread("grpF"); }); await test("/todo @here installs the app into the group AND files the item there", async () => { resetThread("grp6"); setThreadIsGroup((id) => id === "grp6"); const sent = []; const replies = []; setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); setAppThreadResolver(async () => { throw new Error("@here never routes to the personal thread"); }); assert.equal(runCommand("/todo @here camp chairs", "grp6", (r) => replies.push(r)), true); await tick(); setAppThreadResolver(async () => "todoThread"); // restore for later tests assert.equal(sent.length, 2); assert.deepEqual([sent[0].threadId, sent[0].event.type, sent[0].event.app], ["grp6", "groupapps.add", "todo"]); assert.deepEqual([sent[1].threadId, sent[1].event.type, sent[1].event.text], ["grp6", "todo.add", "camp chairs"]); // In-thread the install + add action lines are the record — no extra reply. assert.deepEqual(replies, []); // A bare "/todo @here" is JUST the install; already-added → no duplicate add. foldEvent("grp6", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 }); const sent2 = []; setEmitTransport((threadId, event) => (sent2.push(event), Promise.resolve(true))); assert.equal(runCommand("/todo @here", "grp6", (r) => replies.push(r)), true); await tick(); assert.deepEqual(sent2, [], "already added + no text → nothing to emit"); setThreadIsGroup(null); resetThread("grp6"); }); await test("@here outside a group (or for an unshareable app) explains instead of mis-filing", async () => { setThreadIsGroup(() => false); const sent = []; const replies = []; setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); assert.equal(runCommand("/todo @here buy milk", "chatX", (r) => replies.push(r)), true); await tick(); assert.equal(sent.length, 0, "nothing emitted — the text is not silently filed anywhere"); assert.match(replies[0].text, /only works in a group chat/); setThreadIsGroup(null); }); await test("the / catalog says where a group-capable command lands", () => { resetThread("grp7"); setThreadIsGroup((id) => id === "grp7"); const desc = (list) => list.find((c) => c.name === "/todo").desc; // Fresh instance: the bare verb names its place plainly — the sharing hint // is no longer a suffix but the flagged install rows (tested below). assert.match(desc(commandCatalog("grp7")), /just for you$/); foldEvent("grp7", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 }); assert.match(desc(commandCatalog("grp7")), /for this group$/); // Outside a group too — every app command names where its data lands (the // places grammar), reading on from the desc as one sentence. assert.equal(desc(commandCatalog("chatX")), "Add a to-do just for you"); setThreadIsGroup(null); resetThread("grp7"); }); // ---- space-wide apps (spaceapps registry + space routing) ------------------- // docs/space-apps.md. A synthetic app stands in for the member-built case (a // sticker library): personal + groupCapable + spaceCapable, so the routing // precedence — local → this chat's instance → the space instance → personal — // is exercised end to end. const stickers = defineApp({ name: "stickers", title: "Stickers", glyph: "🖼️", personal: true, groupCapable: true, spaceCapable: true, initialState: () => ({ items: [] }), events: { "stickers.add": (s, e) => (e.id && e.text ? { items: addItem(s.items, { id: e.id, text: e.text }) } : s), }, commands: { "/sticker": { desc: "Post a sticker", args: "", run: (args, ctx) => { if (args) ctx.emit({ type: "stickers.add", id: ctx.id(), text: args }); }, }, }, defaultCommand: "/sticker", render() {}, }); const spaceReduce = spaceAppsApp.reduce; await test("spaceapps: adds fold idempotently, removes drop, malformed no-op", () => { let s = spaceReduce(undefined, { type: "spaceapps.add", app: "stickers", name: "Ana", uid: "uA", ts: 5 }); assert.deepEqual(s, { apps: [{ id: "stickers", by: "Ana", uid: "uA", ts: 5 }] }); // Re-installing (another member, a refold) is a no-op — the id IS the app name. const s2 = spaceReduce(s, { type: "spaceapps.add", app: "stickers", name: "Bob", ts: 9 }); assert.deepEqual(s2.apps, s.apps); // Unknown app names still fold (state converges across client versions). s = spaceReduce(s, { type: "spaceapps.add", app: "flarp", name: "Bob", ts: 9 }); assert.equal(s.apps.length, 2); s = spaceReduce(s, { type: "spaceapps.remove", app: "stickers" }); assert.deepEqual(s.apps.map((a) => a.id), ["flarp"]); // Hostile / malformed events are no-ops, never a crash (Invariant #8). assert.equal(spaceReduce(s, { type: "spaceapps.add" }), s); assert.equal(spaceReduce(s, { type: "spaceapps.add", app: 7 }), s); assert.deepEqual(spaceReduce(s, { type: "spaceapps.remove", app: "missing" }).apps, s.apps); }); await test("parseEveryone: leading @everyone only, breaking on whitespace", () => { assert.deepEqual(parseEveryone("@everyone tada"), { rest: "tada" }); assert.deepEqual(parseEveryone("@everyone"), { rest: "" }); assert.deepEqual(parseEveryone(" @everyone tada "), { rest: "tada" }); assert.equal(parseEveryone("@everyones party"), null); assert.equal(parseEveryone("tada @everyone"), null); assert.equal(parseEveryone(""), null); }); await test("spaceAppsFor resolves installs against the registry, filtering unknown and non-space-capable", () => { resetThread("spaceT"); setSpaceThread(() => "spaceT"); foldEvent("spaceT", { type: "spaceapps.add", app: "stickers", name: "Ana", ts: 5 }, { id: 1 }); foldEvent("spaceT", { type: "spaceapps.add", app: "flarp", name: "Ana" }, { id: 2 }); // unknown → inert foldEvent("spaceT", { type: "spaceapps.add", app: "connect", name: "Ana" }, { id: 3 }); // not spaceCapable → inert assert.deepEqual(spaceAppsFor().map((a) => a.name), ["stickers"]); assert.deepEqual(spaceAppInfo("stickers"), { by: "Ana", ts: 5 }); assert.equal(spaceAppInfo("connect"), null, "an inert install still isn't attributed as one"); // No space thread known (fresh boot, key not here yet) → nothing is installed. setSpaceThread(null); assert.deepEqual(spaceAppsFor(), []); setSpaceThread(() => "spaceT"); foldEvent("spaceT", { type: "spaceapps.remove", app: "stickers" }, { id: 4 }); assert.deepEqual(spaceAppsFor(), []); setSpaceThread(null); resetThread("spaceT"); }); await test("the space-capable roster: shipped apps parked at you+chat, the stand-ins keep it covered", () => { const names = spaceCapableApps().map((a) => a.name); assert.ok(names.includes("stickers"), "the synthetic member-app stand-in"); assert.ok(names.includes("triple"), "the synthetic all-places stand-in"); // Parked while the places UI is hidden (docs/space-apps.md § The pullback) — // each is one places-line edit away from returning. for (const n of ["todo", "remind", "bookmarks", "vault"]) assert.ok(!names.includes(n), n + " is parked at you+chat"); for (const n of ["connect", "dnsimple"]) assert.ok(!names.includes(n), n + " stays personal-only"); }); await test("a space-installed app's command targets the SPACE instance from any chat", async () => { resetThread("spaceT"); setSpaceThread(() => "spaceT"); foldEvent("spaceT", { type: "spaceapps.add", app: "stickers", name: "Ana" }, { id: 1 }); const sent = []; const replies = []; setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); // Resolver poisoned: a space-installed app's command must NOT open a // personal app-thread — the shared space instance claims the verb. setAppThreadResolver(async () => { throw new Error("space-scoped commands never route to the personal thread"); }); assert.equal(runCommand("/sticker high five", "chatX", (r) => replies.push(r)), true); await tick(); setAppThreadResolver(async () => "todoThread"); // restore for later tests assert.equal(sent.length, 1); assert.equal(sent[0].threadId, "spaceT", "the event lands in the space thread"); assert.equal(sent[0].event.type, "stickers.add"); assert.equal(sent[0].event.text, "high five"); setSpaceThread(null); resetThread("spaceT"); }); await test("a space install never steals the verb inside the app's OWN thread", async () => { resetThread("spaceT"); setSpaceThread(() => "spaceT"); foldEvent("spaceT", { type: "spaceapps.add", app: "stickers", name: "Ana" }, { id: 1 }); // Typed inside the stickers app-thread itself, /sticker addresses THAT // instance — the one you're looking at — not the space copy. setThreadOwner((id) => (id === "stickersThread" ? "stickers" : null)); const sent = []; setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); assert.equal(runCommand("/sticker mine", "stickersThread"), true); await tick(); assert.equal(sent[0].threadId, "stickersThread", "the own thread wins over the space instance"); setThreadOwner((id) => null); setSpaceThread(null); resetThread("spaceT"); }); await test("a group add is the NEARER scope: it beats the space install in that chat", async () => { resetThread("spaceT"); resetThread("grp8"); setSpaceThread(() => "spaceT"); foldEvent("spaceT", { type: "spaceapps.add", app: "stickers", name: "Ana" }, { id: 1 }); foldEvent("grp8", { type: "groupapps.add", app: "stickers", name: "Ana" }, { id: 1 }); const sent = []; setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); assert.equal(runCommand("/sticker in the group", "grp8"), true); await tick(); assert.equal(sent[0].threadId, "grp8", "the chat's own instance wins where it exists"); // …and elsewhere the space instance still claims the verb. assert.equal(runCommand("/sticker elsewhere", "chatX"), true); await tick(); assert.equal(sent[1].threadId, "spaceT"); setSpaceThread(null); resetThread("spaceT"); resetThread("grp8"); }); await test("/sticker @everyone installs for the space AND files the trailing text there", async () => { resetThread("spaceT"); setSpaceThread(() => "spaceT"); setSpaceThreadResolver(async () => "spaceT"); const sent = []; const replies = []; setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); setAppThreadResolver(async () => { throw new Error("@everyone never routes to the personal thread"); }); assert.equal(runCommand("/sticker @everyone tada", "chatX", (r) => replies.push(r)), true); await tick(); setAppThreadResolver(async () => "todoThread"); // restore for later tests assert.equal(sent.length, 2); assert.deepEqual([sent[0].threadId, sent[0].event.type, sent[0].event.app], ["spaceT", "spaceapps.add", "stickers"]); assert.deepEqual([sent[1].threadId, sent[1].event.type, sent[1].event.text], ["spaceT", "stickers.add", "tada"]); // A bare "@everyone" is JUST the install, confirmed privately (no chat // surface shows the space log, so the reply is the feedback). foldEvent("spaceT", { type: "spaceapps.add", app: "stickers", name: "Ana" }, { id: 1 }); const sent2 = []; setEmitTransport((threadId, event) => (sent2.push(event), Promise.resolve(true))); assert.equal(runCommand("/sticker @everyone", "chatX", (r) => replies.push(r)), true); await tick(); assert.deepEqual(sent2, [], "already installed + no text → nothing to emit"); assert.match(replies[replies.length - 1].text, /added for everyone/i); setSpaceThreadResolver(null); setSpaceThread(null); resetThread("spaceT"); }); await test("@everyone for an app that isn't space-capable explains instead of mis-filing", async () => { const sent = []; const replies = []; setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); // Poll is chat-place-only (its placeNote says why) — the refusal example. assert.equal(runCommand("/poll @everyone free pizza?", "chatX", (r) => replies.push(r)), true); await tick(); assert.equal(sent.length, 0, "nothing emitted — the text is not silently filed anywhere"); assert.match(replies[0].text, /can't be installed for the whole space/); }); await test("installSpaceApp answers false when the space thread can't be resolved (keys syncing)", async () => { setSpaceThread(null); setSpaceThreadResolver(async () => null); assert.equal(await installSpaceApp(stickers), false); setSpaceThreadResolver(null); }); await test("the / catalog labels a space-installed command in EVERY chat", () => { resetThread("spaceT"); setSpaceThread(() => "spaceT"); const desc = (list) => list.find((c) => c.name === "/sticker").desc; assert.equal(desc(commandCatalog("chatX")), "Post a sticker just for you"); foldEvent("spaceT", { type: "spaceapps.add", app: "stickers", name: "Ana" }, { id: 1 }); assert.match(desc(commandCatalog("chatX")), /for this space$/); // In a group that ALSO added it, the nearer scope's label wins. resetThread("grp9"); setThreadIsGroup((id) => id === "grp9"); foldEvent("grp9", { type: "groupapps.add", app: "stickers", name: "Ana" }, { id: 1 }); assert.match(desc(commandCatalog("grp9")), /for this group$/); setThreadIsGroup(null); setSpaceThread(null); resetThread("spaceT"); resetThread("grp9"); }); await test("placePhrase: the one vocabulary every surface shares", () => { assert.equal(placePhrase("you"), "just for you"); assert.equal(placePhrase("chat"), "shared in this chat"); assert.equal(placePhrase("space"), "for everyone in this space"); assert.equal(placePhrase("elsewhere"), ""); assert.equal(placePhrase(), ""); }); await test("placeRowPhrase: the flowing forms a typeahead row reads with", () => { assert.equal(placeRowPhrase("you"), "just for you"); assert.equal(placeRowPhrase("chat"), "for this group"); assert.equal(placeRowPhrase("space"), "for this space"); assert.equal(placeRowPhrase("elsewhere"), ""); assert.equal(placeRowPhrase(), ""); }); await test("installRowPhrase: a fresh-case row names the new app it would create", () => { assert.equal(installRowPhrase("To-do", "chat"), "to a new To-do app for this group"); assert.equal(installRowPhrase("To-do", "space"), "to a new To-do app for this space"); assert.equal(installRowPhrase("To-do", "you"), "", "nothing installs into the personal place"); assert.equal(installRowPhrase("To-do"), ""); }); await test("spaceRowTitle: a space copy is named after the space, never a twin of the personal row", () => { assert.equal(spaceRowTitle("To-do", "Lazyatom"), "Lazyatom To-do"); assert.equal(spaceRowTitle("To-do", " The Campbells "), "The Campbells To-do", "names are trimmed"); // Unset (the compiled-in default — callers pass "") and over-long names // fall back to the plain word, keeping the app title visible. assert.equal(spaceRowTitle("To-do", ""), "Space To-do"); assert.equal(spaceRowTitle("To-do", "The Extended Johnson Family Reunion"), "Space To-do"); assert.equal(spaceRowTitle("To-do"), "Space To-do"); }); await test("a routed reply is stamped with its place — and the app words the destination", async () => { resetThread("spaceT"); setSpaceThread(() => "spaceT"); foldEvent("spaceT", { type: "spaceapps.add", app: "triple", name: "Ana" }, { id: 1 }); const sent = []; const replies = []; setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); assert.equal(runCommand("/triple space milk", "chatX", (r) => replies.push(r)), true); await tick(); assert.equal(sent[0].threadId, "spaceT"); assert.equal(replies.length, 1); assert.equal(replies[0].place, "space", "the framework stamps the routed place — no app cooperation needed"); assert.match(replies[0].text, /the space triple list/, "the app names the destination via ctx.place"); // Routed personally (no installs anywhere): stamped "you", worded "your" — // todo is the shipped reference for the wording. ctx.place learns "you" // from the thread-owner map, so bind it here. resetThread("spaceT"); setSpaceThread(null); setThreadOwner((id) => (id === "todoThread" ? "todo" : null)); const replies2 = []; assert.equal(runCommand("/todo my milk", "chatX", (r) => replies2.push(r)), true); await tick(); assert.equal(replies2[0].place, "you"); assert.match(replies2[0].text, /your to-do list/); setThreadOwner((id) => null); // the ambient state the surrounding tests expect }); await test("ctx.open shows the INSTANCE through the host's opener — never a raw thread navigation", async () => { resetThread("spaceT"); setSpaceThread(() => "spaceT"); foldEvent("spaceT", { type: "spaceapps.add", app: "triple", name: "Ana" }, { id: 1 }); const opened = []; setInstanceOpener((appName, tid) => opened.push([appName, tid])); const replies = []; setEmitTransport(() => Promise.resolve(true)); assert.equal(runCommand("/triple view me", "chatX", (r) => replies.push(r)), true); await tick(); const view = (replies[0].actions || []).find((a) => a.label === "View Triple"); assert.ok(view, "the reply offers a View chip"); view.run(); assert.deepEqual(opened, [["triple", "spaceT"]], "the chip names the app + its instance; the host picks the surface"); setInstanceOpener(null); setSpaceThread(null); resetThread("spaceT"); }); await test("places: the one declaration — passed through, with listed derived", () => { const p = defineApp({ name: "ptest", title: "P", glyph: "▣", places: ["you", "space"], initialState: () => ({}), events: { "ptest.x": (s) => s } }); assert.deepEqual(p.places, ["you", "space"]); assert.equal(p.listed, true); // The shipped apps sit at you+chat while the places UI is hidden (§ The // pullback). assert.deepEqual(todoApp.places, ["you", "chat"]); assert.deepEqual(bookmarksApp.places, ["you", "chat"]); assert.deepEqual(emojiApp.places, [], "a utility app lives nowhere — host-mounted only"); }); await test("the catalog carries a machine-readable place for ranking", () => { resetThread("spaceT"); setSpaceThread(() => "spaceT"); foldEvent("spaceT", { type: "spaceapps.add", app: "stickers", name: "Ana" }, { id: 1 }); const by = (list, name) => list.find((c) => c.name === name); const cat = commandCatalog("chatX"); assert.equal(by(cat, "/sticker").place, "space"); assert.equal(by(cat, "/todo").place, "you"); assert.equal(by(cat, "/emoji").place, "", "a stateless utility command names no place"); resetThread("grp10"); foldEvent("grp10", { type: "groupapps.add", app: "stickers", name: "Ana" }, { id: 1 }); assert.equal(by(commandCatalog("grp10"), "/sticker").place, "chat", "the nearer copy wins the label too"); setSpaceThread(null); resetThread("spaceT"); resetThread("grp10"); }); await test("admins-only policy: non-admin adds refuse with honest copy; reads filter by adder", async () => { resetThread("spaceT"); setSpaceThread(() => "spaceT"); // Non-admin adds folded into the registry: uid-carrying items filter at // read time while the policy holds; pre-uid items are grandfathered. foldEvent("spaceT", { type: "spaceapps.add", app: "stickers", name: "Mal", uid: "uMal" }, { id: 1 }); foldEvent("spaceT", { type: "spaceapps.add", app: "triple", name: "Old" }, { id: 2 }); // pre-uid log setSpaceInstallPolicy(() => ({ adminsOnly: true, canInstall: false, adderOK: (uid) => uid === "uAdmin" })); assert.deepEqual(spaceAppsFor().map((a) => a.name), ["triple"], "non-admin install inert; grandfathered kept"); // …and @everyone from a non-admin explains instead of emitting. const sent = []; const replies = []; setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); assert.equal(runCommand("/sticker @everyone tada", "chatX", (r) => replies.push(r)), true); await tick(); assert.equal(sent.length, 0); assert.match(replies[0].text, /Only admins can add apps for everyone/); // Policy off again: the filtered install reappears — read-time is reversible. setSpaceInstallPolicy(null); assert.deepEqual(spaceAppsFor().map((a) => a.name).sort(), ["stickers", "triple"]); setSpaceThread(null); resetThread("spaceT"); }); await test("/help teaches the routing sentence in the same words the typeahead uses", async () => { const replies = []; assert.equal(runCommand("/help", "chatX", (r) => replies.push(r)), true); await tick(); assert.match(replies[0].text, /nearest copy of its app/, "the one routing sentence rides every /help"); assert.match(replies[0].text, /just for you/, "personal commands name their place in the list"); }); await test("@-mentioning a space-installed app works in any chat and targets the space instance", async () => { resetThread("spaceT"); setSpaceThread(() => "spaceT"); foldEvent("spaceT", { type: "spaceapps.add", app: "stickers", name: "Ana" }, { id: 1 }); const m = parseAppMention("@Stickers party time", "chatX"); assert.equal(m.app.name, "stickers"); assert.equal(m.args, "party time"); const sent = []; setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); assert.equal(runAppMention("@Stickers party time", "chatX", () => {}), true); await tick(); assert.equal(sent[0].threadId, "spaceT", "the mention acts on the space instance"); assert.equal(sent[0].event.type, "stickers.add"); setSpaceThread(null); assert.equal(parseAppMention("@Stickers party time", "chatX"), null, "no space thread → no space mentions"); resetThread("spaceT"); }); await test("runAppMention: a bare mention gets the usage reply; plain text never matches", async () => { resetThread("grp4"); foldEvent("grp4", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 }); const sent = []; const replies = []; setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); assert.equal(runAppMention("@To-do", "grp4", (r) => replies.push(r)), true); await tick(); assert.equal(sent.length, 0, "nothing to add"); assert.equal(replies.length, 1, "the usage line still shows (it's content, not a confirmation)"); assert.equal(replies[0].sender.name, "To-do"); assert.equal(replies[0].text, "Usage: @To-do ", "usage speaks in mention form, not a slash verb"); // Ordinary conversation sails past — an app must never eat a group's messages. assert.equal(runAppMention("who's around for lunch?", "grp4", (r) => replies.push(r)), false); assert.equal(runAppMention("@Ana can you look?", "grp4", (r) => replies.push(r)), false); resetThread("grp4"); }); // ---- command panels (ctx.panel) + the /emoji reference app ----------------- await test("searchEmoji filters by name substring and collapses aliases", () => { const cats = searchEmoji("cat"); assert.ok(cats.some((r) => r.ch === "🐱"), "cat matches"); assert.ok(cats.every((r) => r.name.includes("cat")), "only matching names"); // laughing/satisfied are aliases of one glyph — a bare search dedupes them. const all = searchEmoji(""); assert.equal(all.filter((r) => r.ch === "😆").length, 1, "aliases collapse to one tile"); assert.ok(all.length > 100, "a bare query browses the whole set"); assert.deepEqual(searchEmoji("zzzznope"), [], "no match → empty"); }); await test("ctx.panel opens a command panel in the thread you TYPED in", async () => { const opened = []; setCommandPanelHost((threadId, spec) => { opened.push({ threadId, spec }); return true; }); const replies = []; assert.equal(runCommand("/emoji cat", "chatX", (r) => replies.push(r)), true); await tick(); assert.equal(opened.length, 1); assert.equal(opened[0].threadId, "chatX", "the panel opens where you typed, not a routed app-thread"); assert.equal(typeof opened[0].spec.render, "function"); assert.match(opened[0].spec.title, /cat/); assert.equal(opened[0].spec.sender.name, "Emoji", "runCommand injects the app persona as the panel's sender"); assert.equal(replies.length, 0, "the panel IS the answer — no reply too"); }); await test("a command falls back to a reply when no chat surface can draw a panel", async () => { setCommandPanelHost(() => false); // the thread has no mounted chat pane const replies = []; assert.equal(runCommand("/emoji cat", "chatX", (r) => replies.push(r)), true); await tick(); assert.equal(replies.length, 1, "ctx.panel returned false → the command replied instead"); assert.match(replies[0].text, /🐱/); }); await test("openCommandPanel guards: no host / no render → false; close reaches the host", () => { const calls = []; setCommandPanelHost((threadId, spec) => (calls.push({ threadId, spec }), true)); assert.equal(openCommandPanel("t1", { title: "no render" }), false, "a panel must have render()"); assert.equal(calls.length, 0); assert.equal(openCommandPanel("t1", { render() {} }), true); // The spec passes through verbatim — animate/position are the host's to read. assert.equal(openCommandPanel("t1", { render() {}, animate: false }), true); assert.equal(calls[calls.length - 1].spec.animate, false); closeCommandPanel("t1"); assert.equal(calls.length, 3); // two opens + the close assert.equal(calls[2].spec, null, "close is a null spec to the same host"); setCommandPanelHost(null); assert.equal(openCommandPanel("t1", { render() {} }), false, "no host → false (caller falls back to a reply)"); }); await test("gif: pickRendition prefers downsized, skips oversize, tolerates missing sizes", () => { const images = { downsized: { url: "https://media0.giphy.com/a.gif", width: "320", height: "240", size: "900000" }, fixed_height: { url: "https://media0.giphy.com/b.gif", width: "356", height: "200", size: "1500000" }, original: { url: "https://media0.giphy.com/c.gif", width: "480", height: "360", size: "9000000" }, }; assert.equal(pickRendition(images).url, "https://media0.giphy.com/a.gif"); // downsized too big → the next acceptable rendition wins assert.equal(pickRendition({ ...images, downsized: { ...images.downsized, size: String(GIF_MAX_BYTES + 1) } }).url, "https://media0.giphy.com/b.gif"); // unknown size sails through — the byte fetch re-checks the real length assert.equal(pickRendition({ downsized: { url: "https://media0.giphy.com/d.gif", width: "1", height: "1" } }).url, "https://media0.giphy.com/d.gif"); // nothing acceptable → null assert.equal(pickRendition({ original: { url: "https://media0.giphy.com/c.gif", size: String(GIF_MAX_BYTES * 2) } }), null); assert.equal(pickRendition(undefined), null); // The post walks ALL acceptable renditions (fallback when one download // fails), deduped by url, best first. const cands = renditionCandidates(images); assert.deepEqual(cands.map((c) => c.url.split("/").pop()), ["a.gif", "b.gif"], "oversize original dropped; order kept"); assert.deepEqual(renditionCandidates(undefined), []); }); await test("gif: gifItems maps a Giphy page and drops unusable results", () => { const data = [ { id: "g1", title: "Cat", images: { fixed_height_small: { url: "https://media0.giphy.com/s.gif", width: "89", height: "100" }, downsized: { url: "https://media0.giphy.com/f.gif", width: "320", height: "240", size: "500000" }, } }, { id: "g2", title: "No preview", images: { downsized: { url: "https://media0.giphy.com/x.gif", size: "1" } } }, { id: "g3", title: "No postable rendition", images: { fixed_height_small: { url: "https://media0.giphy.com/y.gif" } } }, null, ]; const items = gifItems(data); assert.equal(items.length, 1); // g2 has no preview rendition, g3 nothing postable assert.equal(items[0].id, "g1"); assert.equal(items[0].preview.url, "https://media0.giphy.com/s.gif"); assert.deepEqual(gifItems(null), []); }); await test("gif: /gif opens a panel where you typed; no host doorway → a reply", async () => { resetThread("spaceT"); setSpaceThread(() => "spaceT"); setSpaceThreadResolver(async () => "spaceT"); const opened = []; setCommandPanelHost((threadId, spec) => (opened.push({ threadId, spec }), true)); setHostBridge({ gif: { search: async () => [], bytes: async () => null, post: async () => true } }); const replies = []; assert.equal(runCommand("/gif office party", "chatX", (r) => replies.push(r)), true); await tick(); assert.equal(opened.length, 1); // The SPLIT: ctx addresses the space (its key), but the panel opens in the // chat you typed in — the two threads a space-placed command app straddles. assert.equal(opened[0].threadId, "chatX"); assert.match(opened[0].spec.title, /office party/); assert.equal(replies.length, 0); // Without the host doorway (e.g. a stripped-down embed) it explains itself. setHostBridge(null); assert.equal(runCommand("/gif cats", "chatX", (r) => replies.push(r)), true); await tick(); assert.equal(replies.length, 1); assert.match(replies[0].text, /isn't available/); assert.equal(opened.length, 1, "no panel without the doorway"); setCommandPanelHost(null); setSpaceThreadResolver(null); setSpaceThread(null); resetThread("spaceT"); assert.ok(!gifApp.places.includes("you"), "gif keeps no personal copy"); }); await test("gif is implicitly space-placed: unlisted + spaceCapable routes to the space with no install", async () => { resetThread("spaceT"); // Deliberately NO spaceapps.add for gif: an unlisted app has no Add button, // so routing must not depend on an install event that can never arrive. setSpaceThread(() => "spaceT"); setSpaceThreadResolver(async () => "spaceT"); assert.deepEqual(gifApp.places, ["space"], "gif lives only in the space place"); assert.equal(gifApp.listed, false, "explicit listed:false survives normalizePlaces"); assert.equal( listedApps().some((a) => a.name === "gif"), false, "an unlisted app stays out of the Apps catalogue", ); assert.equal(!!gifApp.render, false, "no app-level render → off the widget bar and sidebar band for free"); assert.equal(await instanceThread(gifApp, "chatX"), "spaceT"); // The whole point of the rule: WITHOUT it this falls through to the chat and // the space's Giphy key folds into whatever conversation you typed in. assert.notEqual(await instanceThread(gifApp, "chatX"), "chatX"); setSpaceThread(null); setSpaceThreadResolver(null); resetThread("spaceT"); }); await test("gif: the space's key mints the space thread on first use, and folds last-writer-wins", async () => { resetThread("spaceT"); let minted = 0; setSpaceThread(() => null); // not loaded/created yet — the first /gif in a space setSpaceThreadResolver(async () => (minted++, "spaceT")); assert.equal(await instanceThread(gifApp, "chatX"), "spaceT"); assert.equal(minted, 1, "resolved (minted) rather than falling through to the chat"); setSpaceThread(() => "spaceT"); // The key is ordinary fold state on the space thread — every member reads it, // which is the point; the server only ever holds the ciphertext. foldEvent("spaceT", { type: "gif.key", key: " abc123 " }, { id: 1 }); assert.equal(appStateFor("spaceT", "gif").key, "abc123", "trimmed"); foldEvent("spaceT", { type: "gif.key", key: "xyz789" }, { id: 2 }); assert.equal(appStateFor("spaceT", "gif").key, "xyz789", "last writer wins — re-running the form just re-keys"); foldEvent("spaceT", { type: "gif.key" }, { id: 3 }); assert.equal(appStateFor("spaceT", "gif").key, "", "a malformed event folds to empty, never undefined"); setSpaceThread(null); setSpaceThreadResolver(null); resetThread("spaceT"); }); await test("an unroutable command explains itself instead of doing nothing", async () => { // gif with no space thread: a native embed (bootEmbed doesn't load it) or a // member whose instance key hasn't arrived. The old build would have asked for // a key and stashed it on the device; the key is the space's now, so the // honest answer is to say so — never to resurrect a silent per-device key, and // never (as this used to) to return without a word. setSpaceThread(() => null); setSpaceThreadResolver(null); const opened = []; setCommandPanelHost((threadId, spec) => (opened.push({ threadId, spec }), true)); setHostBridge({ gif: { search: async () => [], bytes: async () => null, post: async () => true } }); const replies = []; assert.equal(runCommand("/gif cats", "chatX", (r) => replies.push(r)), true); await tick(); assert.equal(opened.length, 0, "no panel with nowhere to read the key from"); assert.equal(replies.length, 1); assert.match(replies[0].text, /isn't available here yet/i); setCommandPanelHost(null); setHostBridge(null); setSpaceThread(null); }); await test("the declarative panel form: ctx.panel({data}) + a panel() hook synthesizes render", async () => { const seen = []; const fixture = defineApp({ name: "paneldemo", title: "Panel Demo", glyph: "▣", personal: false, listed: false, initialState: () => ({}), events: {}, commands: { "/paneldemo": (args, ctx) => ctx.panel({ title: "Demo", data: { args } }) }, panel(data, el, pctx) { seen.push({ data, el, keys: Object.keys(pctx).sort() }); }, }); const opened = []; setCommandPanelHost((threadId, spec) => (opened.push({ threadId, spec }), true)); assert.equal(runCommand("/paneldemo hello", "chatX"), true); await tick(); assert.equal(opened.length, 1); const spec = opened[0].spec; assert.equal(typeof spec.render, "function", "the hook synthesizes the host render"); // The host invokes render(el, handle); the hook gets (data, el, panelCtx) // with the handle's members plus the app ctx's emit/id/me/getState. const el = {}; spec.render(el, { close() {}, insertText() {}, refresh() {}, attachFiles() {}, threadId: "chatX" }); assert.equal(seen.length, 1); assert.deepEqual(seen[0].data, { args: "hello" }); assert.equal(seen[0].el, el); for (const k of ["close", "insertText", "emit", "id", "me", "getState"]) { assert.ok(seen[0].keys.includes(k), `panelCtx offers ${k}`); } assert.ok(fixture.panel, "the hook stays on the app object"); setCommandPanelHost(null); }); await test("a no-match /emoji answers with a reply, and the app stays command-only", async () => { setCommandPanelHost((threadId, spec) => true); const replies = []; assert.equal(runCommand("/emoji zzzznope", "chatX", (r) => replies.push(r)), true); await tick(); assert.equal(replies.length, 1); assert.match(replies[0].text, /No emoji match/); // Command-only shape: no personal thread to route to, nothing to instantiate. assert.deepEqual(emojiApp.places, [], "a command-only utility lives nowhere"); assert.equal(emojiApp.listed, false); setCommandPanelHost(null); }); // ---- quiet apps (issue #227) --------------------------------------------- await test("appIsQuiet reads the flag off the app registry", () => { assert.equal(appIsQuiet("dice"), true); assert.equal(appIsQuiet("todo"), false, "an ordinary personal app is not quiet"); assert.equal(appIsQuiet("nope-not-a-real-app"), false); assert.equal(appIsQuiet(undefined), false, "no app name (e.g. a non-app thread) → not quiet"); }); // quiet and groupCapable are orthogonal (like personal/groupCapable already // are) — quiet only ever governs a PERSONAL thread's visibility. Prove both // halves of that claim against a single app that's both. defineApp({ name: "questpin", title: "Quest Pin", glyph: "📌", personal: true, groupCapable: true, quiet: true, initialState: () => ({}), events: { "questpin.set": (s) => s }, commands: { "/questpin": (args, ctx) => ctx.emit({ type: "questpin.set", id: ctx.id() }) }, render() {}, // groupAppsFor requires a render to count as addable to a group }); await test("quiet + groupCapable: PERSONAL routing (not added here) still resolves the app-thread", async () => { let resolves = 0; setAppThreadResolver(async () => { resolves++; return "questpinThread"; }); const sent = []; setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); assert.equal(runCommand("/questpin", "chatX"), true); await tick(); setAppThreadResolver(async () => "todoThread"); // restore for later tests assert.equal(resolves, 1, "quiet doesn't change WHETHER the personal thread resolves, only its visibility"); assert.equal(sent[0].threadId, "questpinThread"); }); await test("quiet + groupCapable: ADDED to a group targets the group thread — quiet's create/hide path never runs", async () => { resetThread("questpinGrp"); foldEvent("questpinGrp", { type: "groupapps.add", app: "questpin", name: "Ana" }, { id: 1 }); // Resolver poisoned: the group's shared instance is the nearer scope, so // resolveAppThread (and therefore createAppThread's hide-on-create logic) // must never even be consulted — quiet has nothing to do here. setAppThreadResolver(async () => { throw new Error("group-scoped commands never route to the personal thread"); }); const sent = []; setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); assert.equal(runCommand("/questpin", "questpinGrp"), true); await tick(); setAppThreadResolver(async () => "todoThread"); // restore for later tests assert.equal(sent.length, 1); assert.equal(sent[0].threadId, "questpinGrp", "targets the GROUP thread, not a personal one"); }); await test("parseSides reads a die size, defaults to d6, rejects out-of-range/garbage", () => { assert.equal(parseSides(""), 6); assert.equal(parseSides(" "), 6); assert.equal(parseSides("20"), 20); assert.equal(parseSides("d20"), 20); assert.equal(parseSides("D20"), 20); assert.equal(parseSides("1"), null, "below the 2-side floor"); assert.equal(parseSides("5000"), null, "above the 1000-side ceiling"); assert.equal(parseSides("banana"), null); }); await test("dice.js's reducer folds a roll and ignores malformed events", () => { const reduce = diceApp.reduce; let s = reduce(undefined, { type: "dice.roll", id: "r1", sides: 20, result: 17, name: "Ana" }); assert.deepEqual(s.rolls, [{ id: "r1", sides: 20, result: 17, by: "Ana" }]); // A second, well-formed roll appends; a malformed one (missing result) no-ops. s = reduce(s, { type: "dice.roll", id: "r2", sides: 6, result: 3 }); s = reduce(s, { type: "dice.roll", id: "r3", sides: 6 }); assert.equal(s.rolls.length, 2); assert.equal(s.rolls[1].result, 3); }); await test("/roll emits a dice.roll event and replies in place with an Open action", async () => { setAppThreadResolver(async () => "diceThread"); const sent = []; setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); const replies = []; assert.equal(runCommand("/roll d20", "chatX", (r) => replies.push(r)), true); await tick(); setAppThreadResolver(async () => "todoThread"); // restore for later tests assert.equal(sent.length, 1); assert.equal(sent[0].threadId, "diceThread"); assert.equal(sent[0].event.type, "dice.roll"); assert.equal(sent[0].event.sides, 20); assert.ok(Number.isInteger(sent[0].event.result) && sent[0].event.result >= 1 && sent[0].event.result <= 20); assert.equal(replies.length, 1); assert.match(replies[0].text, /Rolled a \d+ on a d20/); assert.equal(replies[0].actions[0].label, "Roll history"); }); // ---- 🤖 the vault lock layer (docs/locked-vaults.md) -------------------------- // Well-formed keyring wraps for reuse below. `wrapped` is an opaque b64url blob // to the reducer — no crypto runs in a fold. W = a durable passkey wrap; M = a // `member` handoff (the vault key sealed to member `uid`'s roster key). const W = (id, extra) => ({ id, kind: "passkey", label: "Paul", credId: "cred-" + id, wrapped: "wrap-" + id, ...extra }); const M = (id, uid, label) => ({ id, kind: "member", uid, label: label || "", wrapped: "handoff-" + id }); const LOCK = (over = {}) => ({ type: "vault.lock", aid: "al", pub: "vaultpub", wrap: W("w1", { uid: "u1" }), sealed: {}, ...over }); const vreduce = vaultApp.reduce; await test("vault.lock installs the keyring, seals covered items, keeps uncovered plaintext", () => { let s = vreduce(undefined, { type: "vault.add", id: "s1", aid: "a1", label: "WiFi", value: "hunter2" }); s = vreduce(s, { type: "vault.add", id: "s2", aid: "a2", label: "Door", value: "0000" }); s = vreduce(s, LOCK({ sealed: { s1: "sealed-s1" } })); assert.equal(s.keyring.pub, "vaultpub"); assert.deepEqual(s.keyring.wraps.map((w) => w.id), ["w1"]); const s1 = s.items.find((i) => i.id === "s1"); assert.equal(s1.sealed, "sealed-s1"); assert.equal("value" in s1, false, "sealed item drops its plaintext"); assert.equal(s.items.find((i) => i.id === "s2").value, "0000", "uncovered item keeps plaintext (render flags it)"); assert.equal(s.log.at(-1).action, "lock"); }); await test("vault.lock is one-way and malformed locks fold to no-ops", () => { const locked = vreduce(undefined, LOCK()); const again = vreduce(locked, LOCK({ aid: "al2", pub: "otherpub", wrap: W("w9") })); assert.equal(again, locked, "a second lock never replaces the keyring"); for (const bad of [LOCK({ pub: "" }), LOCK({ wrap: null }), LOCK({ wrap: { id: "x" } }), LOCK({ aid: "" })]) { assert.deepEqual(vreduce(undefined, bad).keyring ?? null, null); } }); await test("vault.lock does not mutate the prior state (snapshot-safe)", () => { const s0 = vreduce(undefined, { type: "vault.add", id: "s1", aid: "a1", label: "WiFi", value: "hunter2" }); const s1 = vreduce(s0, LOCK({ sealed: { s1: "sealed-s1" } })); assert.equal(s0.items[0].value, "hunter2", "prior state untouched"); assert.equal(s1.items[0].sealed, "sealed-s1"); }); await test("vault.add accepts exactly one of value/sealed", () => { let s = vreduce(undefined, LOCK()); s = vreduce(s, { type: "vault.add", id: "s1", aid: "a1", label: "Token", sealed: "sealed-blob" }); assert.equal(s.items[0].sealed, "sealed-blob"); // both forms, neither form, empty sealed — all fold to no-ops for (const bad of [ { type: "vault.add", id: "x", aid: "ax", label: "B", value: "v", sealed: "s" }, { type: "vault.add", id: "y", aid: "ay", label: "B" }, { type: "vault.add", id: "z", aid: "az", label: "B", sealed: "" }, ]) { assert.equal(vreduce(s, bad).items.length, 1); } }); await test("vault.change carrying sealed replaces a plaintext value (the Seal path) and vice versa", () => { let s = vreduce(undefined, { type: "vault.add", id: "s1", aid: "a1", label: "WiFi", value: "hunter2" }); s = vreduce(s, LOCK()); // lock WITHOUT covering s1 — the stale-plaintext case assert.equal(s.items[0].value, "hunter2"); s = vreduce(s, { type: "vault.change", id: "s1", aid: "a2", sealed: "sealed-now" }); assert.equal(s.items[0].sealed, "sealed-now"); assert.equal(s.items[0].value, undefined, "sealing a change drops the plaintext"); }); await test("vault.lock folds member handoffs; vault.enroll turns one into a durable wrap, dropping it by uid", () => { // Lock hands James (u2) a member handoff alongside the locker's own wrap. let s = vreduce(undefined, LOCK({ handoffs: [M("h2", "u2", "James")] })); assert.deepEqual(s.keyring.wraps.map((w) => w.id), ["w1", "h2"]); assert.equal(s.keyring.wraps.find((w) => w.id === "h2").kind, "member"); // James self-enrols: his member handoff drops, his durable wrap goes in. s = vreduce(s, { type: "vault.enroll", aid: "a2", wrap: W("w2", { uid: "u2", label: "James" }), uid: "u2", name: "James" }); assert.deepEqual(s.keyring.wraps.map((w) => w.id), ["w1", "w2"]); assert.equal(s.keyring.wraps.some((w) => w.kind === "member"), false, "the handoff is gone once enrolled"); assert.equal(s.log.at(-1).action, "enroll"); // enrolling a member's wrap NEVER touches another member's handoff (drop is by uid) let two = vreduce(undefined, LOCK({ handoffs: [M("h2", "u2", "James"), M("h3", "u3", "Carol")] })); two = vreduce(two, { type: "vault.enroll", aid: "b1", wrap: W("w2", { uid: "u2" }), uid: "u2", name: "James" }); assert.deepEqual(two.keyring.wraps.map((w) => w.id), ["w1", "h3", "w2"], "only the enroller's handoff drops"); // duplicate wrap id dedupes; a `member`-kind enroll is rejected (must be durable) assert.equal(vreduce(s, { type: "vault.enroll", aid: "a3", wrap: W("w2", { uid: "u2" }) }).keyring.wraps.length, 2, "duplicate wrap id dedupes"); assert.equal(vreduce(s, { type: "vault.enroll", aid: "a4", wrap: M("w9", "u2") }).keyring.wraps.length, 2, "a member wrap can't enroll"); // no keyring → enroll no-ops assert.equal(vreduce(undefined, { type: "vault.enroll", aid: "a5", wrap: W("w9") }).keyring ?? null, null); }); await test("vault.give hands a member a handoff; junk gives fold to no-ops", () => { let s = vreduce(undefined, LOCK()); s = vreduce(s, { type: "vault.give", aid: "a1", wrap: M("h2", "u2", "James"), uid: "u1", name: "Paul" }); assert.deepEqual(s.keyring.wraps.map((w) => w.id), ["w1", "h2"]); assert.equal(s.log.at(-1).action, "give"); assert.equal(s.log.at(-1).name, "James", "the give logs the recipient"); // a give without a recipient uid, or of a non-member wrap, is rejected assert.equal(vreduce(s, { type: "vault.give", aid: "a2", wrap: { id: "g1", kind: "member", wrapped: "x" } }).keyring.wraps.length, 2, "give needs a uid"); assert.equal(vreduce(s, { type: "vault.give", aid: "a3", wrap: W("g2", { uid: "u3" }) }).keyring.wraps.length, 2, "a give must be a member wrap"); // no keyring → give no-ops assert.equal(vreduce(undefined, { type: "vault.give", aid: "a4", wrap: M("g", "u2") }).keyring ?? null, null); }); await test("vault.revoke drops a wrap but never the last one", () => { let s = vreduce(undefined, LOCK()); assert.equal(vreduce(s, { type: "vault.revoke", aid: "a1", wrapId: "w1" }), s, "the last wrap is unrevokable"); s = vreduce(s, { type: "vault.enroll", aid: "a2", wrap: W("w2") }); s = vreduce(s, { type: "vault.revoke", aid: "a3", wrapId: "w1" }); assert.deepEqual(s.keyring.wraps.map((w) => w.id), ["w2"]); assert.equal(s.log.at(-1).action, "revoke"); }); await test("vault: a legacy recovery wrap (#995, removed) never folds in", () => { let s = vreduce(undefined, LOCK()); // The passphrase-derived break-glass was removed 2026-07-25 — recovery is a // passkey, never a passphrase. wrapShape rejects the kind like any unknown one. s = vreduce(s, { type: "vault.enroll", aid: "a2", wrap: { id: "rec", kind: "recovery", label: "Recovery key (1Password)", wrapped: "recov-blob" } }); assert.deepEqual(s.keyring.wraps.map((w) => w.kind), ["passkey"], "recovery kind never folds in"); // a bogus kind is still rejected by wrapShape const bad = vreduce(vreduce(undefined, LOCK()), { type: "vault.enroll", aid: "a5", wrap: { id: "x", kind: "usb-fob", wrapped: "b" } }); assert.equal(bad.keyring.wraps.length, 1, "unknown wrap kind never folds in"); }); await test("vault: keyring wraps are sanitised — a stranger's spare fields never fold in", () => { const s = vreduce(undefined, LOCK({ wrap: { ...W("w1"), evil: "payload", credId: 42 } })); const w = s.keyring.wraps[0]; assert.equal("evil" in w, false); assert.equal("credId" in w, false, "non-string credId dropped"); assert.deepEqual(Object.keys(w).sort(), ["id", "kind", "label", "uid", "wrapped"]); }); await test("vault: lock surfaces in previews and the group feed projection; give/enroll stay quiet", () => { assert.equal(vaultApp.preview({ type: "vault.lock", uid: "u2", name: "Ciara" }), "🔒 Ciara locked the Vault"); // give/enroll are routine key housekeeping — never previewed, never in the feed. assert.equal(vaultApp.preview({ type: "vault.give", uid: "u1", name: "me" }), null); assert.equal(vaultApp.preview({ type: "vault.enroll", uid: "u2", name: "Ciara" }), null); // groupFeedMessage (not chatMessage) is the feed-only projection — the Vault // has no companion Chat view of its own (the hook split). const line = vaultApp.groupFeedMessage({ type: "vault.lock" }); assert.equal(line.action, true); assert.match(line.text, /locked the Vault/); assert.equal(vaultApp.groupFeedMessage({ type: "vault.enroll" }), null); assert.equal(vaultApp.groupFeedMessage({ type: "vault.give" }), null); }); await test("appGlyphInto clears a leftover photo background before painting the mark", () => { // The header/avatar elements are reused across thread switches: paintAvatar // leaves a photo as an inline background-image (e.g. Eleven's app icon), and // switching to a logo-app (Admin) must not leave it showing behind the mark. const el = { style: { backgroundImage: "url(eleven.png)" }, classList: { add() {} }, innerHTML: "", textContent: "" }; appGlyphInto(el, { logo: "" }); assert.equal(el.style.backgroundImage, "", "logo path must clear the leftover background"); assert.match(el.innerHTML, /wrench/); // The glyph fallback path clears it too. const g = { style: { backgroundImage: "url(eleven.png)" }, classList: { add() {} }, innerHTML: "", textContent: "" }; appGlyphInto(g, { glyph: "🛠️" }); assert.equal(g.style.backgroundImage, "", "glyph path must clear the leftover background"); assert.equal(g.textContent, "🛠️"); }); await test("newlyDirtyMentionSids: matches, cap, and the already-dirty gate (the boot-freeze regression)", () => { const msgs = [ { sid: 1, text: "hello @Ana" }, { sid: 2, text: "no mention here" }, { sid: 3 }, // deleted/attachment-only row: no text { sid: 4, text: "ping @Bob and @Ana" }, ]; // Only rows whose text references a given name are marked. assert.deepEqual(newlyDirtyMentionSids(msgs, ["Ana"], new Set()), [1, 4]); assert.deepEqual(newlyDirtyMentionSids(msgs, ["Bob"], new Set()), [4]); assert.deepEqual(newlyDirtyMentionSids(msgs, ["Zoe"], new Set()), []); // Already-dirty rows are never re-reported: a repeat call over the same feed // finds nothing, so repaintMentionsOf triggers no re-render — the guarantee // (with the mountedCtx snapshot) that the repaint loop always terminates. // (Nothing is lost: a dirty row's rev is the fixed "|mention" tag, so a // second resolving name couldn't rebuild it anyway.) const dirty = new Set(newlyDirtyMentionSids(msgs, ["Ana"], new Set())); assert.deepEqual(newlyDirtyMentionSids(msgs, ["Ana"], dirty), []); // …even when a DIFFERENT name resolves in an already-dirty row (sid 4 has both). assert.deepEqual(newlyDirtyMentionSids(msgs, ["Bob"], dirty), []); // Only the most recent 100 messages are ever considered. const long = Array.from({ length: 250 }, (_, i) => ({ sid: i + 1, text: "@Ana" })); const marked = newlyDirtyMentionSids(long, ["Ana"], new Set()); assert.equal(marked.length, 100); assert.equal(marked[0], 151); // the cap keeps the newest, drops the oldest assert.equal(marked[99], 250); }); console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); process.exit(failures === 0 ? 0 : 1);