| 1 | // Tests for the mini-app framework + the /todo app. node test/apps.test.mjs |
| 2 | import assert from "node:assert/strict"; |
| 3 | import { |
| 4 | parseCommand, |
| 5 | parseMeCommand, |
| 6 | runCommand, |
| 7 | commandCatalog, |
| 8 | isAppType, |
| 9 | foldEvent, |
| 10 | appStateFor, |
| 11 | resetThread, |
| 12 | defineApp, |
| 13 | namespace, |
| 14 | setEmitTransport, |
| 15 | setScheduleTransport, |
| 16 | setEphemeralTransport, |
| 17 | setAppThreadResolver, |
| 18 | setHostBridge, |
| 19 | setCurrentUser, |
| 20 | setThreadHasBot, |
| 21 | setThreadOwner, |
| 22 | defaultCommandFor, |
| 23 | setToast, |
| 24 | messageActionsFor, |
| 25 | notifyLive, |
| 26 | deliverEphemeral, |
| 27 | appPreview, |
| 28 | appUnread, |
| 29 | foldHostEvent, |
| 30 | seedThread, |
| 31 | takeSnapshot, |
| 32 | addItem, |
| 33 | updateItem, |
| 34 | removeItem, |
| 35 | partitionByGrace, |
| 36 | DONE_GRACE_MS, |
| 37 | keyedList, |
| 38 | appWantsChatView, |
| 39 | appChatOnly, |
| 40 | appDefaultView, |
| 41 | noticeParts, |
| 42 | noticeIsCompact, |
| 43 | noticeCalmsDown, |
| 44 | NOTICE_COMPACT_AFTER_MS, |
| 45 | awaitAnswer, |
| 46 | cancelAnswer, |
| 47 | setAnswerCancelledHook, |
| 48 | deliverAnswer, |
| 49 | groupAppsApp, |
| 50 | groupAppsFor, |
| 51 | groupCapableApps, |
| 52 | appGlyphInto, |
| 53 | installGroupApp, |
| 54 | spaceAppsApp, |
| 55 | spaceAppsFor, |
| 56 | spaceCapableApps, |
| 57 | spaceAppInfo, |
| 58 | installSpaceApp, |
| 59 | setSpaceInstallPolicy, |
| 60 | setSpaceThread, |
| 61 | setSpaceThreadResolver, |
| 62 | listedApps, |
| 63 | instanceThread, |
| 64 | parseAppMention, |
| 65 | parseHere, |
| 66 | parseEveryone, |
| 67 | parseMeFlag, |
| 68 | HERE_FLAG, |
| 69 | ME_FLAG, |
| 70 | EVERYONE_FLAG, |
| 71 | placePhrase, |
| 72 | placeRowPhrase, |
| 73 | installRowPhrase, |
| 74 | spaceRowTitle, |
| 75 | setInstanceOpener, |
| 76 | runAppMention, |
| 77 | setThreadIsGroup, |
| 78 | setCommandPanelHost, |
| 79 | openCommandPanel, |
| 80 | closeCommandPanel, |
| 81 | appIsQuiet, |
| 82 | commandTakenBy, |
| 83 | } from "../web/static/apps.js"; |
| 84 | import { app as todoApp } from "../web/static/apps/todo.js"; // registers the /todo app |
| 85 | import { app as pollApp, pollSummary } from "../web/static/apps/poll.js"; |
| 86 | import { app as remindApp, parseReminder } from "../web/static/apps/remind.js"; |
| 87 | import { app as bookmarksApp } from "../web/static/apps/bookmarks.js"; // registers "Save link" |
| 88 | import { app as boardApp, sanitizePoints } from "../web/static/apps/board.js"; |
| 89 | import { app as dnsimpleApp } from "../web/static/apps/dnsimple.js"; // registers the DNSimple companion app |
| 90 | import { app as setupApp } from "../web/static/apps/setup.js"; // the first-run setup conversation's app |
| 91 | import { app as welcomeApp } from "../web/static/apps/welcome.js"; // the joiner welcome conversation's app |
| 92 | import { app as adminApp } from "../web/static/apps/admin.js"; // the admin panel (registers /admin) |
| 93 | import { app as chatApp, viewItems, threadItems, newlyDirtyMentionSids } from "../web/static/apps/chat.js"; // the chat app |
| 94 | import { app as vaultApp } from "../web/static/apps/vault.js"; // the Vault app (registers /vault) |
| 95 | import { app as emojiApp, searchEmoji } from "../web/static/apps/emoji.js"; // registers /emoji (the command-panel reference app) |
| 96 | import { app as gifApp, pickRendition, renditionCandidates, gifItems, GIF_MAX_BYTES } from "../web/static/apps/gif.js"; // registers /gif |
| 97 | import { app as diceApp, parseSides } from "../web/static/disabled-apps/dice.js"; // DISABLED quiet-app POC (issue #227) — not wired into shell.js |
| 98 | |
| 99 | // Each app's fold is synthesised from its `events` map by defineApp — the same |
| 100 | // (state, event) → state reducer the framework folds with. Test it directly. |
| 101 | const reduce = todoApp.reduce; |
| 102 | const pollReduce = pollApp.reduce; |
| 103 | const remindReduce = remindApp.reduce; |
| 104 | const bookmarksReduce = bookmarksApp.reduce; |
| 105 | const boardReduce = boardApp.reduce; |
| 106 | |
| 107 | // todo is a "personal" app: its command routes to the user's app-thread. In tests |
| 108 | // we stub the resolver so it lands in a known thread, and give ctx a user id. |
| 109 | setCurrentUser("u1"); |
| 110 | setAppThreadResolver(async () => "todoThread"); |
| 111 | const tick = () => new Promise((r) => setTimeout(r, 0)); // let runCommand's async emit flush |
| 112 | |
| 113 | let failures = 0; |
| 114 | async function test(name, fn) { |
| 115 | try { |
| 116 | await fn(); |
| 117 | console.log("PASS " + name); |
| 118 | } catch (e) { |
| 119 | console.log("FAIL " + name + " — " + (e && e.message)); |
| 120 | failures++; |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | // ---- namespace helper --------------------------------------------------- |
| 125 | |
| 126 | await test("namespace() resolves event types with '.' and CSS classes with '-'", () => { |
| 127 | const N = namespace("todo"); |
| 128 | assert.equal(N.ns, "todo"); |
| 129 | assert.equal(N.ev("add"), "todo.add"); |
| 130 | assert.equal(N.cls("item"), "todo-item"); |
| 131 | assert.equal(N.cls("list--done"), "todo-list--done"); // modifier suffix rides along |
| 132 | // A name already carrying a "." passes through untouched — a full type or |
| 133 | // another app's type still resolves, so migration is a safe find-and-replace. |
| 134 | assert.equal(N.ev("chat.msg"), "chat.msg"); |
| 135 | // Tolerant of a missing/empty base and a nullish name (never throws). |
| 136 | assert.equal(namespace("").ev("x"), ".x"); |
| 137 | assert.equal(N.ev(null), "todo.null"); |
| 138 | }); |
| 139 | |
| 140 | await test("defineApp defaults namespace to name and exposes app.ns", () => { |
| 141 | // todo declared no explicit namespace → it defaults to its name, and the |
| 142 | // synthesised types match the wire contract the event vectors pin. |
| 143 | assert.equal(todoApp.namespace, "todo"); |
| 144 | assert.equal(todoApp.ns.ev("add"), "todo.add"); |
| 145 | assert.deepEqual([...todoApp.types].sort(), ["todo.add", "todo.done", "todo.move", "todo.remove", "todo.reopen"].sort()); |
| 146 | // An explicit namespace overrides the name (built-in apps only; member apps |
| 147 | // are name-prefix validated by the sandbox). |
| 148 | const custom = defineApp({ name: "nstest-app", namespace: "ns", initialState: () => ({}), events: { "ns.e": (s) => s } }); |
| 149 | assert.equal(custom.namespace, "ns"); |
| 150 | assert.equal(custom.ns.cls("box"), "ns-box"); |
| 151 | }); |
| 152 | |
| 153 | // ---- command parsing ---------------------------------------------------- |
| 154 | |
| 155 | await test("parseCommand splits a registered command and its args", () => { |
| 156 | const c = parseCommand("/todo buy milk"); |
| 157 | assert.equal(c.name, "/todo"); |
| 158 | assert.equal(c.args, "buy milk"); |
| 159 | assert.ok(c.app, "a registered command resolves its owning app"); |
| 160 | }); |
| 161 | |
| 162 | await test("parseCommand trims surrounding arg whitespace", () => { |
| 163 | const c = parseCommand("/todo buy milk "); |
| 164 | assert.equal(c.name, "/todo"); |
| 165 | assert.equal(c.args, "buy milk"); |
| 166 | }); |
| 167 | |
| 168 | await test("parseCommand allows a bare command with no args", () => { |
| 169 | const c = parseCommand("/todo"); |
| 170 | assert.equal(c.name, "/todo"); |
| 171 | assert.equal(c.args, ""); |
| 172 | assert.ok(c.app); |
| 173 | }); |
| 174 | |
| 175 | await test("parseCommand flags an unknown /command (app:null) but ignores plain text", () => { |
| 176 | assert.equal(parseCommand("hello"), null); // no leading "/" → not a command attempt |
| 177 | assert.equal(parseCommand(""), null); |
| 178 | assert.equal(parseCommand("/ hi"), null); // a lone "/" is punctuation, sent as chat |
| 179 | const c = parseCommand("/nope do a thing"); |
| 180 | assert.equal(c.name, "/nope"); |
| 181 | assert.equal(c.args, "do a thing"); |
| 182 | assert.equal(c.app, null); // unowned verb → a failed command, not chat (caller replies) |
| 183 | }); |
| 184 | |
| 185 | await test("parseMeCommand pulls the action out of a /me line", () => { |
| 186 | assert.equal(parseMeCommand("/me waves"), "waves"); |
| 187 | assert.equal(parseMeCommand("/me waves hello "), "waves hello"); |
| 188 | assert.equal(parseMeCommand("/ME waves"), "waves"); // case-insensitive, like other commands |
| 189 | }); |
| 190 | |
| 191 | await test("parseMeCommand allows a bare /me (composer treats it as nothing to say)", () => { |
| 192 | assert.equal(parseMeCommand("/me"), ""); |
| 193 | assert.equal(parseMeCommand("/me "), ""); |
| 194 | }); |
| 195 | |
| 196 | await test("parseMeCommand ignores non-/me text, including other commands", () => { |
| 197 | assert.equal(parseMeCommand("hello"), null); |
| 198 | assert.equal(parseMeCommand(""), null); |
| 199 | assert.equal(parseMeCommand("/todo buy milk"), null); |
| 200 | assert.equal(parseMeCommand("/mean thing"), null); // "/me" must be its own token, not a prefix |
| 201 | }); |
| 202 | |
| 203 | // ---- command execution → emitted event ---------------------------------- |
| 204 | |
| 205 | await test("runCommand routes a personal /todo into the app-thread and emits", async () => { |
| 206 | const sent = []; |
| 207 | setEmitTransport((threadId, event) => { |
| 208 | sent.push({ threadId, event }); |
| 209 | return Promise.resolve(true); |
| 210 | }); |
| 211 | // typed from some chat "chatX" — it must land in the resolved app-thread, not chatX |
| 212 | assert.equal(runCommand("/todo buy milk", "chatX"), true); |
| 213 | await tick(); |
| 214 | assert.equal(sent.length, 1); |
| 215 | assert.equal(sent[0].threadId, "todoThread"); // routed to the app-thread, not "chatX" |
| 216 | assert.equal(sent[0].event.type, "todo.add"); |
| 217 | assert.equal(sent[0].event.text, "buy milk"); |
| 218 | assert.ok(sent[0].event.id, "add carries a generated id"); |
| 219 | }); |
| 220 | |
| 221 | await test("runCommand returns false (and emits nothing) for non-commands", async () => { |
| 222 | const sent = []; |
| 223 | setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); |
| 224 | assert.equal(runCommand("just a message", "chatX"), false); |
| 225 | await tick(); |
| 226 | assert.equal(sent.length, 0); |
| 227 | }); |
| 228 | |
| 229 | await test("an empty /todo emits nothing", async () => { |
| 230 | const sent = []; |
| 231 | setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); |
| 232 | assert.equal(runCommand("/todo ", "chatX"), true); // handled (consumed), but no event |
| 233 | await tick(); |
| 234 | assert.equal(sent.length, 0); |
| 235 | }); |
| 236 | |
| 237 | // AUDIT (quiet apps, issue #227): runCommand resolves — and so creates — a |
| 238 | // personal app's own thread BEFORE the handler runs, no matter what the |
| 239 | // handler ends up doing. A reply-only turn (no ctx.emit at all) still pays |
| 240 | // for a thread. That's BY DESIGN (the thread is where any future ctx.emit |
| 241 | // from this app would land, so it has to exist) — `quiet` never changes this |
| 242 | // resolution. What `quiet` changes is entirely about the thread's VISIBILITY |
| 243 | // afterward (see shell.js's createAppThread + the "msg" WS handler, and |
| 244 | // apps/README.md § Quiet apps) — this test just pins the resolution itself so |
| 245 | // that distinction stays deliberate, not accidental. |
| 246 | await test("a reply-only personal command still resolves (creates) the app-thread — quiet only hides it", async () => { |
| 247 | let resolves = 0; |
| 248 | setAppThreadResolver(async () => { |
| 249 | resolves++; |
| 250 | return "remindThread"; |
| 251 | }); |
| 252 | const sent = []; |
| 253 | setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); |
| 254 | const replies = []; |
| 255 | // Unparseable time → remind.js only ctx.reply()s a clarifying question; it |
| 256 | // never calls addReminder/ctx.emit. Typed from an unrelated chat. |
| 257 | assert.equal(runCommand("/remind call mum", "chatX", (r) => replies.push(r)), true); |
| 258 | await tick(); |
| 259 | setAppThreadResolver(async () => "todoThread"); // restore for later tests |
| 260 | assert.equal(sent.length, 0, "the handler never emitted anything"); |
| 261 | assert.equal(replies.length, 1, "it only answered in place"); |
| 262 | // resolveAppThread ran regardless — the thread exists whether or not this |
| 263 | // particular turn emitted anything. Whether it's SHOWN is a separate |
| 264 | // decision, made in shell.js (createAppThread hides it on creation for a |
| 265 | // quiet app; the WS handler keeps it hidden through later activity too). |
| 266 | assert.equal(resolves, 1); |
| 267 | }); |
| 268 | |
| 269 | await test("/help is answered privately (client-side); other bot commands pass through", () => { |
| 270 | const replies = []; |
| 271 | const respond = (r) => replies.push(r); |
| 272 | // /help is caught + answered right here with a private notice — no bot needed. |
| 273 | assert.equal(runCommand("/help", "chatX", respond), true); |
| 274 | assert.equal(replies.length, 1); |
| 275 | assert.match(replies[0].text, /Commands you can use/); |
| 276 | assert.match(replies[0].text, /\/help/); |
| 277 | assert.match(replies[0].text, /\/todo/); // an app command, listed everywhere |
| 278 | // No bot injected → /help omits the bot's commands, exactly like the typeahead. |
| 279 | assert.doesNotMatch(replies[0].text, /\/theme/); |
| 280 | // /theme is still Eleven's — the client SENDS it (returns false so the composer |
| 281 | // posts it, and the bot answers), never caught locally. |
| 282 | assert.equal(runCommand("/theme lagoon", "chatX", respond), false); |
| 283 | assert.equal(replies.length, 1, "a bot command isn't answered locally"); |
| 284 | // A truly unknown "/word" is handled (not broadcast) and answered privately. |
| 285 | assert.equal(runCommand("/blahblah", "chatX", respond), true); |
| 286 | assert.equal(replies.length, 2); |
| 287 | assert.match(replies[1].text, /isn't a command/); |
| 288 | // The catalog flags /help non-bot (typeahead lists it everywhere); /theme stays |
| 289 | // bot-gated; app commands are non-bot. |
| 290 | const cat = commandCatalog(); |
| 291 | assert.ok(cat.some((c) => c.name === "/help" && c.bot === false)); |
| 292 | assert.ok(cat.some((c) => c.name === "/theme" && c.bot === true)); |
| 293 | assert.ok(cat.some((c) => c.name === "/todo" && c.bot === false)); |
| 294 | // /me is listed for discoverability, but it's never routed through runCommand — |
| 295 | // the composer (chat.js) handles it directly via parseMeCommand, since (unlike |
| 296 | // every other command) it broadcasts a real message rather than replying privately. |
| 297 | assert.ok(cat.some((c) => c.name === "/me" && c.bot === false)); |
| 298 | }); |
| 299 | |
| 300 | await test("commandTakenBy names the owner of a taken slash command, '' when free", () => { |
| 301 | // An app command → the owning app's name (the member-app gates reject these). |
| 302 | assert.equal(commandTakenBy("/todo"), "todo"); |
| 303 | // The host set — bot verbs, /help, /me — reads as "host". |
| 304 | assert.equal(commandTakenBy("/theme"), "host"); |
| 305 | assert.equal(commandTakenBy("/help"), "host"); |
| 306 | assert.equal(commandTakenBy("/me"), "host"); |
| 307 | // A free name is takeable. |
| 308 | assert.equal(commandTakenBy("/neverregistered"), ""); |
| 309 | }); |
| 310 | |
| 311 | // ---- local commands + the default command -------------------------------- |
| 312 | |
| 313 | // todo's /add is `local: true` — it exists only inside the To-do thread. Stub the |
| 314 | // thread-owner lookup the way shell.js injects its threadApp map. |
| 315 | setThreadOwner((id) => (id === "todoThread" ? "todo" : id === "remindThread" ? "remind" : null)); |
| 316 | |
| 317 | await test("a local command resolves only in its app's own thread", () => { |
| 318 | const inThread = parseCommand("/add buy milk", "todoThread"); |
| 319 | assert.equal(inThread.name, "/add"); |
| 320 | assert.equal(inThread.args, "buy milk"); |
| 321 | assert.equal(inThread.app, todoApp); |
| 322 | assert.equal(inThread.local, true); |
| 323 | // Elsewhere (or with no thread context) /add is just an unknown verb. |
| 324 | assert.equal(parseCommand("/add buy milk", "chatX").app, null); |
| 325 | assert.equal(parseCommand("/add buy milk").app, null); |
| 326 | }); |
| 327 | |
| 328 | await test("a local command is never in the global catalog, and leads its thread's", () => { |
| 329 | const everywhere = commandCatalog(); |
| 330 | assert.ok(!everywhere.some((c) => c.name === "/add"), "/add stays out of other threads"); |
| 331 | assert.ok(everywhere.some((c) => c.name === "/todo")); |
| 332 | const here = commandCatalog("todoThread"); |
| 333 | assert.equal(here[0].name, "/add", "local commands lead in the owning thread"); |
| 334 | // The app's global routing alias is redundant noise in its own thread. |
| 335 | assert.ok(!here.some((c) => c.name === "/todo")); |
| 336 | // Other apps' global commands still work from anywhere, so they stay listed. |
| 337 | assert.ok(here.some((c) => c.name === "/remind")); |
| 338 | }); |
| 339 | |
| 340 | await test("runCommand runs a local command in place — no routing, no echo reply", async () => { |
| 341 | const sent = []; |
| 342 | const replies = []; |
| 343 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 344 | // Resolver poisoned: a local command must NOT consult it (it targets where you are). |
| 345 | setAppThreadResolver(async () => { |
| 346 | throw new Error("local commands never route"); |
| 347 | }); |
| 348 | assert.equal(runCommand("/add buy milk", "todoThread", (r) => replies.push(r)), true); |
| 349 | await tick(); |
| 350 | setAppThreadResolver(async () => "todoThread"); // restore for later tests |
| 351 | assert.equal(sent.length, 1); |
| 352 | assert.equal(sent[0].threadId, "todoThread"); |
| 353 | assert.equal(sent[0].event.type, "todo.add"); |
| 354 | assert.equal(sent[0].event.text, "buy milk"); |
| 355 | // In the app's own thread the event lands as a visible action line, so the |
| 356 | // private confirmation is suppressed (same rule as /todo typed in-thread). |
| 357 | assert.equal(replies.length, 0); |
| 358 | }); |
| 359 | |
| 360 | await test("an unknown command in an app-thread is answered by the app persona", () => { |
| 361 | const replies = []; |
| 362 | assert.equal(runCommand("/blahblah", "todoThread", (r) => replies.push(r)), true); |
| 363 | assert.equal(replies.length, 1); |
| 364 | assert.equal(replies[0].sender.name, "To-do"); |
| 365 | assert.match(replies[0].text, /isn't a command/); |
| 366 | }); |
| 367 | |
| 368 | await test("defaultCommandFor: To-do's thread runs /add; other threads have none", () => { |
| 369 | const dc = defaultCommandFor("todoThread"); |
| 370 | assert.ok(dc); |
| 371 | assert.equal(dc.name, "/add"); |
| 372 | assert.equal(dc.app, todoApp); |
| 373 | assert.ok(dc.app.composerPlaceholder, "the composer must advertise the routing"); |
| 374 | assert.equal(defaultCommandFor("chatX"), null); |
| 375 | }); |
| 376 | |
| 377 | await test("defaultCommandFor steps aside when a bot member is present", () => { |
| 378 | // With an LLM in the thread, plain text is a real message for it to answer. |
| 379 | setThreadHasBot((id) => id === "todoThread"); |
| 380 | assert.equal(defaultCommandFor("todoThread"), null); |
| 381 | setThreadHasBot(null); |
| 382 | assert.ok(defaultCommandFor("todoThread")); |
| 383 | }); |
| 384 | |
| 385 | await test("plain text through the default command becomes a to-do (the composer flow)", async () => { |
| 386 | const sent = []; |
| 387 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 388 | // What chat.js does for non-slash text in a default-command thread: |
| 389 | const dc = defaultCommandFor("todoThread"); |
| 390 | assert.equal(runCommand(dc.name + " " + "buy milk", "todoThread"), true); |
| 391 | await tick(); |
| 392 | assert.equal(sent.length, 1); |
| 393 | assert.equal(sent[0].event.type, "todo.add"); |
| 394 | assert.equal(sent[0].event.text, "buy milk"); |
| 395 | }); |
| 396 | |
| 397 | // ---- Reminders: a GLOBAL verb as the default command ---------------------- |
| 398 | |
| 399 | await test("Reminders' default is its global /remind (no local twin needed)", () => { |
| 400 | const dc = defaultCommandFor("remindThread"); |
| 401 | assert.ok(dc); |
| 402 | assert.equal(dc.name, "/remind"); |
| 403 | assert.equal(dc.app, remindApp); |
| 404 | assert.ok(dc.app.composerPlaceholder, "the composer must advertise the routing"); |
| 405 | }); |
| 406 | |
| 407 | await test("an app with no locals leads its own thread's catalog with its globals", () => { |
| 408 | const here = commandCatalog("remindThread"); |
| 409 | assert.equal(here[0].name, "/remind", "the global verb IS the in-thread verb — it leads"); |
| 410 | assert.equal(here.filter((c) => c.name === "/remind").length, 1, "listed once, not repeated"); |
| 411 | assert.ok(here.some((c) => c.name === "/todo"), "other apps' globals stay"); |
| 412 | assert.ok(!here.some((c) => c.name === "/add"), "another app's LOCAL never leaks in"); |
| 413 | }); |
| 414 | |
| 415 | await test("plain text with a time becomes a reminder; timeless text gets asked 'when?'", async () => { |
| 416 | const sent = []; |
| 417 | const replies = []; |
| 418 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 419 | setAppThreadResolver(async (name) => (name === "remind" ? "remindThread" : "todoThread")); |
| 420 | const dc = defaultCommandFor("remindThread"); |
| 421 | // "call mum in 10 minutes" → remind.add in this thread, and the confirmation |
| 422 | // (a `short`-carrying reply) is suppressed in-thread — the action line suffices. |
| 423 | assert.equal(runCommand(dc.name + " " + "call mum in 10 minutes", "remindThread", (r) => replies.push(r)), true); |
| 424 | await tick(); |
| 425 | assert.equal(sent.filter((e) => e.event.type === "remind.add").length, 1); |
| 426 | assert.equal(sent.find((e) => e.event.type === "remind.add").event.text, "call mum"); |
| 427 | assert.equal(replies.length, 0, "in-thread confirmation is suppressed"); |
| 428 | // No parseable time → the command pushes back, and THAT reply (no `short`) |
| 429 | // renders even in-thread: an error is the command's only feedback. |
| 430 | assert.equal(runCommand(dc.name + " " + "call mum", "remindThread", (r) => replies.push(r)), true); |
| 431 | await tick(); |
| 432 | assert.equal(sent.filter((e) => e.event.type === "remind.add").length, 1, "nothing added"); |
| 433 | assert.equal(replies.length, 1); |
| 434 | assert.match(replies[0].text, /When should I remind you/); |
| 435 | assert.equal(replies[0].sender.name, "Reminders"); |
| 436 | setAppThreadResolver(async () => "todoThread"); // restore for later tests |
| 437 | }); |
| 438 | |
| 439 | await test("bare /add in the To-do thread shows its usage line (error replies render in-thread)", async () => { |
| 440 | const replies = []; |
| 441 | assert.equal(runCommand("/add", "todoThread", (r) => replies.push(r)), true); |
| 442 | await tick(); // replies land after instanceThread's (uniform) await |
| 443 | assert.equal(replies.length, 1); |
| 444 | assert.match(replies[0].text, /Usage: \/add/); |
| 445 | }); |
| 446 | |
| 447 | // ---- type ownership (derived from each app's events map) ---------------- |
| 448 | |
| 449 | await test("isAppType recognises todo events, not chat", () => { |
| 450 | assert.equal(isAppType("todo.add"), true); |
| 451 | assert.equal(isAppType("todo.done"), true); |
| 452 | assert.equal(isAppType("chat"), false); |
| 453 | assert.equal(isAppType(null), false); |
| 454 | assert.equal(isAppType(undefined), false); |
| 455 | }); |
| 456 | |
| 457 | await test("an app's owned types are the keys of its events map", () => { |
| 458 | // defineApp derives app.types from Object.keys(events) — one source of truth, |
| 459 | // so a handled event can never be missing from the owned-type set. |
| 460 | assert.deepEqual(todoApp.types, ["todo.add", "todo.done", "todo.reopen", "todo.remove", "todo.move"]); |
| 461 | assert.deepEqual(pollApp.types, ["poll.create", "poll.vote", "poll.close"]); |
| 462 | }); |
| 463 | |
| 464 | await test("appWantsChatView: chatMessage opts into companion; its absence is takeover", () => { |
| 465 | // The presence of a chatMessage hook is the ONE gate between the two |
| 466 | // presentations — companion (chat + interface) vs takeover (full surface). |
| 467 | assert.equal(typeof todoApp.chatMessage, "function"); |
| 468 | assert.equal(appWantsChatView("todo"), true); // To-do projects events → companion |
| 469 | assert.equal(appWantsChatView("remind"), true); // Reminders too |
| 470 | // A takeover app defines no chatMessage — register one and confirm it's takeover. |
| 471 | defineApp({ name: "takeover-fixture", title: "Fixture", scope: "personal", events: {}, render() {} }); |
| 472 | assert.equal(appWantsChatView("takeover-fixture"), false); |
| 473 | assert.equal(appWantsChatView("no-such-app"), false); // unknown name → not companion |
| 474 | }); |
| 475 | |
| 476 | await test("appWantsChatView: a groupFeedMessage-only app stays a personal takeover", () => { |
| 477 | // Vault projects add/remove into a GROUP's chat feed via groupFeedMessage |
| 478 | // (see below) and deliberately has NO chatMessage, so its OWN thread stays |
| 479 | // a pure takeover: no Chat view, no header toggle, ever. The hook shape IS |
| 480 | // the declaration — the old groupChatOnly flag is gone. |
| 481 | assert.equal(vaultApp.chatMessage, undefined); |
| 482 | assert.equal(typeof vaultApp.groupFeedMessage, "function"); |
| 483 | assert.equal(appWantsChatView("vault"), false); |
| 484 | // A chatMessage app is a companion, with no flag able to say otherwise. |
| 485 | assert.equal(appWantsChatView("todo"), true); |
| 486 | }); |
| 487 | |
| 488 | await test("vault: groupFeedMessage projects add/remove into a group's feed, never naming the secret", () => { |
| 489 | const add = { type: "vault.add", id: "s1", aid: "a1", label: "GITHUB_TOKEN", value: "hunter2" }; |
| 490 | const remove = { type: "vault.remove", id: "s1", aid: "a2" }; |
| 491 | const change = { type: "vault.change", id: "s1", aid: "a3", value: "newvalue" }; |
| 492 | // The host only calls this hook when folding a group/DM feed |
| 493 | // (foldGroupAppEventBubble) — with no chatMessage, personal-thread events |
| 494 | // have no projection path at all. |
| 495 | const groupAdd = vaultApp.groupFeedMessage(add); |
| 496 | assert.equal(groupAdd.action, true); |
| 497 | assert.equal(groupAdd.text, "added a secret to the Vault"); |
| 498 | assert.doesNotMatch(groupAdd.text, /GITHUB_TOKEN|hunter2/); |
| 499 | const groupRemove = vaultApp.groupFeedMessage(remove); |
| 500 | assert.equal(groupRemove.action, true); |
| 501 | assert.equal(groupRemove.text, "removed a secret from the Vault"); |
| 502 | // …but a value CHANGE (nothing new to notice, same name) stays silent. |
| 503 | assert.equal(vaultApp.groupFeedMessage(change), null); |
| 504 | }); |
| 505 | |
| 506 | await test("vault: preview names the actor ('You' for the reader's own event), never the secret", () => { |
| 507 | // setCurrentUser("u1") above makes u1 "the reader" for the rest of this file. |
| 508 | assert.equal(vaultApp.preview({ type: "vault.add", uid: "u1", name: "James" }), "🔒 You added a secret to the Vault"); |
| 509 | assert.equal(vaultApp.preview({ type: "vault.remove", uid: "u2", name: "Ciara" }), "🔒 Ciara removed a secret from the Vault"); |
| 510 | assert.equal(vaultApp.preview({ type: "vault.change", uid: "u1", name: "James" }), "🔒 You updated a secret in the Vault"); |
| 511 | // No uid (a legacy/malformed event) falls back to the sender's name, not "You". |
| 512 | assert.equal(vaultApp.preview({ type: "vault.add", name: "James" }), "🔒 James added a secret to the Vault"); |
| 513 | assert.equal(vaultApp.preview({ type: "vault.other" }), null); |
| 514 | }); |
| 515 | |
| 516 | await test("defineApp normalizes every legacy generation into places, then drops the old fields (#405)", () => { |
| 517 | const mk = (spec) => defineApp({ title: "x", glyph: "y", events: {}, initialState: () => ({}), render() {}, ...spec }); |
| 518 | const shape = (a) => [a.places, a.listed]; |
| 519 | // Legacy scope declarations (stored member-app manifests still carry them)… |
| 520 | assert.deepEqual(shape(mk({ name: "flag-legacy-personal", scope: "personal" })), [["you"], true]); |
| 521 | assert.deepEqual(shape(mk({ name: "flag-legacy-system", scope: "system" })), [[], false]); |
| 522 | // …the default archetype (nothing declared) is a personal, listed app… |
| 523 | assert.deepEqual(shape(mk({ name: "flag-default" })), [["you"], true]); |
| 524 | // …and the capability flags normalize the same way. |
| 525 | assert.deepEqual(shape(mk({ name: "flag-first-both", personal: true, groupCapable: true })), [["you", "chat"], true]); |
| 526 | assert.deepEqual(shape(mk({ name: "flag-first-grouponly", personal: false, groupCapable: true })), [["chat"], true]); |
| 527 | assert.deepEqual(shape(mk({ name: "flag-first-hostonly", listed: false })), [[], false]); |
| 528 | // The legacy fields are INPUT-ONLY: normalized in, then deleted, so a stale |
| 529 | // flag can never disagree with places — nothing downstream can read one. |
| 530 | const legacy = mk({ name: "flag-dropped", personal: true, groupCapable: true, spaceCapable: true, scope: "personal" }); |
| 531 | assert.deepEqual(shape(legacy), [["you", "chat", "space"], true]); |
| 532 | for (const f of ["personal", "groupCapable", "spaceCapable", "scope"]) assert.ok(!(f in legacy), f + " is dropped after normalization"); |
| 533 | // The real apps keep their classification (behaviour-neutral migration). |
| 534 | assert.deepEqual(shape(todoApp), [["you", "chat"], true]); |
| 535 | assert.deepEqual(shape(pollApp), [["chat"], true]); |
| 536 | }); |
| 537 | |
| 538 | // ---- collection helpers (the immutable list ops reducers build on) ------- |
| 539 | |
| 540 | await test("addItem appends, dedupes by id, and never mutates the input", () => { |
| 541 | const a = [{ id: "1", v: 1 }]; |
| 542 | const b = addItem(a, { id: "2", v: 2 }); |
| 543 | assert.deepEqual(b.map((i) => i.id), ["1", "2"]); |
| 544 | assert.equal(a.length, 1, "input array untouched"); |
| 545 | const c = addItem(b, { id: "2", v: 99 }); // duplicate id → no-op add |
| 546 | assert.deepEqual(c.map((i) => i.id), ["1", "2"]); |
| 547 | assert.equal(c.find((i) => i.id === "2").v, 2, "existing item wins, not overwritten"); |
| 548 | assert.notEqual(c, b, "still returns a fresh array (safe to sort in place)"); |
| 549 | }); |
| 550 | |
| 551 | await test("updateItem patches one item into a fresh array; removeItem drops it", () => { |
| 552 | const a = [{ id: "1", done: false }, { id: "2", done: false }]; |
| 553 | const b = updateItem(a, "2", { done: true }); |
| 554 | assert.equal(b.find((i) => i.id === "2").done, true); |
| 555 | assert.equal(a.find((i) => i.id === "2").done, false, "input untouched"); |
| 556 | assert.notEqual(b, a); |
| 557 | const c = removeItem(b, "1"); |
| 558 | assert.deepEqual(c.map((i) => i.id), ["2"]); |
| 559 | assert.equal(b.length, 2, "input untouched"); |
| 560 | }); |
| 561 | |
| 562 | // ---- the reducer (pure) ------------------------------------------------- |
| 563 | |
| 564 | await test("reduce adds, completes, reopens, and removes items", () => { |
| 565 | let s = reduce(undefined, { type: "todo.add", id: "a", text: "apples", name: "Jo" }); |
| 566 | assert.deepEqual(s.items, [{ id: "a", text: "apples", done: false, doneAt: 0, by: "Jo" }]); |
| 567 | |
| 568 | s = reduce(s, { type: "todo.add", id: "b", text: "bread" }); |
| 569 | assert.equal(s.items.length, 2); |
| 570 | |
| 571 | // done records WHEN (the event's send time) so the grace→archive split converges. |
| 572 | s = reduce(s, { type: "todo.done", id: "a", ts: 1700 }); |
| 573 | assert.equal(s.items.find((i) => i.id === "a").done, true); |
| 574 | assert.equal(s.items.find((i) => i.id === "a").doneAt, 1700); |
| 575 | |
| 576 | s = reduce(s, { type: "todo.reopen", id: "a" }); |
| 577 | assert.equal(s.items.find((i) => i.id === "a").done, false); |
| 578 | assert.equal(s.items.find((i) => i.id === "a").doneAt, 0, "reopen clears doneAt"); |
| 579 | |
| 580 | s = reduce(s, { type: "todo.remove", id: "a" }); |
| 581 | assert.deepEqual(s.items.map((i) => i.id), ["b"]); |
| 582 | }); |
| 583 | |
| 584 | await test("partitionByGrace: active / recently-done (grace) / archived by doneAt", () => { |
| 585 | const now = 100000; |
| 586 | const items = [ |
| 587 | { id: "a", doneAt: 0 }, // active (not done) |
| 588 | { id: "b", doneAt: now - 1000 }, // done 1s ago → grace (< DONE_GRACE_MS) |
| 589 | { id: "c", doneAt: now - (DONE_GRACE_MS + 5000) }, // done long ago → archived |
| 590 | { id: "d", doneAt: now - (DONE_GRACE_MS - 1) }, // just inside the window → grace |
| 591 | { id: "e", doneAt: now - DONE_GRACE_MS }, // exactly at the edge → archived |
| 592 | ]; |
| 593 | const { active, grace, archived } = partitionByGrace(items, (i) => i.doneAt, now); |
| 594 | assert.deepEqual(active.map((i) => i.id), ["a"]); |
| 595 | assert.deepEqual(grace.map((i) => i.id), ["b", "d"]); |
| 596 | assert.deepEqual(archived.map((i) => i.id), ["c", "e"]); |
| 597 | // A custom grace window is honoured. |
| 598 | assert.deepEqual( |
| 599 | partitionByGrace(items, (i) => i.doneAt, now, 500).grace.map((i) => i.id), |
| 600 | [], |
| 601 | "nothing is within a 500ms window", |
| 602 | ); |
| 603 | }); |
| 604 | |
| 605 | await test("reduce ignores a malformed add (missing text) and unknown events", () => { |
| 606 | let s = reduce(undefined, { type: "todo.add", id: "a" }); // no text |
| 607 | assert.deepEqual(s.items, []); |
| 608 | s = reduce(s, { type: "todo.done", id: "ghost" }); // unknown id — no-op |
| 609 | assert.deepEqual(s.items, []); |
| 610 | s = reduce(s, { type: "something.else" }); // not ours |
| 611 | assert.deepEqual(s.items, []); |
| 612 | }); |
| 613 | |
| 614 | await test("reduce does not mutate the previous state (snapshot-safe)", () => { |
| 615 | const s0 = reduce(undefined, { type: "todo.add", id: "a", text: "apples" }); |
| 616 | const s1 = reduce(s0, { type: "todo.done", id: "a" }); |
| 617 | assert.equal(s0.items[0].done, false, "prior state untouched"); |
| 618 | assert.equal(s1.items[0].done, true); |
| 619 | assert.notEqual(s0, s1); |
| 620 | }); |
| 621 | |
| 622 | // ---- folding a stream into per-thread state ----------------------------- |
| 623 | |
| 624 | await test("foldEvent accumulates a thread's app state and isolates threads", () => { |
| 625 | resetThread("t1"); |
| 626 | resetThread("t2"); |
| 627 | const app = foldEvent("t1", { type: "todo.add", id: "a", text: "apples" }, {}); |
| 628 | assert.equal(app.name, "todo"); |
| 629 | foldEvent("t1", { type: "todo.add", id: "b", text: "bread" }, {}); |
| 630 | foldEvent("t2", { type: "todo.add", id: "c", text: "cheese" }, {}); |
| 631 | foldEvent("t1", { type: "todo.done", id: "a" }, {}); |
| 632 | |
| 633 | const s1 = appStateFor("t1", "todo"); |
| 634 | assert.deepEqual(s1.items.map((i) => i.id), ["a", "b"]); |
| 635 | assert.equal(s1.items.find((i) => i.id === "a").done, true); |
| 636 | assert.deepEqual(appStateFor("t2", "todo").items.map((i) => i.id), ["c"]); |
| 637 | }); |
| 638 | |
| 639 | await test("foldEvent returns null for a type no app owns", () => { |
| 640 | assert.equal(foldEvent("t1", { type: "chat", text: "hi" }, {}), null); |
| 641 | }); |
| 642 | |
| 643 | await test("resetThread clears a thread's folded state", () => { |
| 644 | foldEvent("t3", { type: "todo.add", id: "x", text: "x" }, {}); |
| 645 | assert.ok(appStateFor("t3", "todo")); |
| 646 | resetThread("t3"); |
| 647 | assert.equal(appStateFor("t3", "todo"), undefined); |
| 648 | }); |
| 649 | |
| 650 | await test("foldEvent is idempotent per server message id (no dup on re-fold)", () => { |
| 651 | resetThread("t4"); |
| 652 | // The same message (msg id 42) folded twice — as happens when catch-up re-reads |
| 653 | // the window on every thread re-open — must add the item only once. |
| 654 | const first = foldEvent("t4", { type: "todo.add", id: "a", text: "apples" }, { id: 42 }); |
| 655 | const again = foldEvent("t4", { type: "todo.add", id: "a", text: "apples" }, { id: 42 }); |
| 656 | assert.equal(first.name, "todo"); // first fold applied, returns the app |
| 657 | assert.equal(again, null); // duplicate message → skipped, returns null (no line) |
| 658 | assert.deepEqual(appStateFor("t4", "todo").items.map((i) => i.id), ["a"]); |
| 659 | // A genuinely different message still folds. |
| 660 | foldEvent("t4", { type: "todo.add", id: "b", text: "bread" }, { id: 43 }); |
| 661 | assert.deepEqual(appStateFor("t4", "todo").items.map((i) => i.id), ["a", "b"]); |
| 662 | // After reset, the same message id may fold again (fresh window rebuild). |
| 663 | resetThread("t4"); |
| 664 | const afterReset = foldEvent("t4", { type: "todo.add", id: "a", text: "apples" }, { id: 42 }); |
| 665 | assert.equal(afterReset.name, "todo"); |
| 666 | assert.deepEqual(appStateFor("t4", "todo").items.map((i) => i.id), ["a"]); |
| 667 | }); |
| 668 | |
| 669 | // ---- the other apps' reducers (pure) ------------------------------------ |
| 670 | |
| 671 | await test("poll: create then votes tally, last-writer-wins per user", () => { |
| 672 | let s = pollReduce(undefined, { type: "poll.create", id: "p1", q: "Lunch?", options: ["Pizza", "Sushi"] }); |
| 673 | assert.equal(s.polls.length, 1); |
| 674 | s = pollReduce(s, { type: "poll.create", id: "p1", q: "Lunch?", options: ["Pizza", "Sushi"] }); // dup id |
| 675 | assert.equal(s.polls.length, 1, "poll.create is deduped by id"); |
| 676 | s = pollReduce(s, { type: "poll.vote", pollId: "p1", choice: 0, uid: "a" }); |
| 677 | s = pollReduce(s, { type: "poll.vote", pollId: "p1", choice: 1, uid: "b" }); |
| 678 | s = pollReduce(s, { type: "poll.vote", pollId: "p1", choice: 1, uid: "a" }); // a changes vote |
| 679 | const votes = s.polls[0].votes; |
| 680 | assert.equal(votes["a"], 1, "last vote wins"); |
| 681 | assert.equal(votes["b"], 1); |
| 682 | s = pollReduce(s, { type: "poll.vote", pollId: "ghost", choice: 0, uid: "a" }); // unknown poll |
| 683 | assert.equal(Object.keys(s.polls[0].votes).length, 2); |
| 684 | }); |
| 685 | |
| 686 | await test("poll: close is creator-only in the fold; legacy polls accept anyone", () => { |
| 687 | let s = pollReduce(undefined, { type: "poll.create", id: "p1", q: "Lunch?", options: ["Pizza", "Sushi"], uid: "alice" }); |
| 688 | s = pollReduce(s, { type: "poll.vote", pollId: "p1", choice: 0, uid: "bob" }); |
| 689 | // A non-creator's close folds to a no-op — the poll stays. |
| 690 | s = pollReduce(s, { type: "poll.close", pollId: "p1", uid: "bob" }); |
| 691 | assert.equal(s.polls.length, 1, "only the creator may close"); |
| 692 | // The creator's close removes it from the panel. |
| 693 | s = pollReduce(s, { type: "poll.close", pollId: "p1", uid: "alice" }); |
| 694 | assert.deepEqual(s.polls, []); |
| 695 | // A pre-`by` poll (older event log) can be closed by anyone. |
| 696 | let legacy = pollReduce(undefined, { type: "poll.create", id: "p2", q: "Q?", options: ["a", "b"] }); |
| 697 | legacy = pollReduce(legacy, { type: "poll.close", pollId: "p2", uid: "whoever" }); |
| 698 | assert.deepEqual(legacy.polls, []); |
| 699 | }); |
| 700 | |
| 701 | await test("poll: pollSummary reports counts and the verdict (win / tie / no votes)", () => { |
| 702 | const base = { q: "Lunch?", options: ["Pizza", "Sushi"] }; |
| 703 | assert.equal( |
| 704 | pollSummary({ ...base, votes: { a: 0, b: 0, c: 1 } }), |
| 705 | "📊 Poll closed: Lunch?\n• Pizza — 2\n• Sushi — 1\nPizza wins (3 votes)", |
| 706 | ); |
| 707 | assert.equal( |
| 708 | pollSummary({ ...base, votes: { a: 0, b: 1 } }), |
| 709 | "📊 Poll closed: Lunch?\n• Pizza — 1\n• Sushi — 1\nTied: Pizza & Sushi (2 votes)", |
| 710 | ); |
| 711 | assert.equal(pollSummary({ ...base, votes: {} }), "📊 Poll closed: Lunch?\n• Pizza — 0\n• Sushi — 0\nNo votes were cast."); |
| 712 | }); |
| 713 | |
| 714 | // ---- board (shared whiteboard) ------------------------------------------- |
| 715 | |
| 716 | await test("board: sanitizePoints filters bad entries, clamps to canvas, requires 2+", () => { |
| 717 | assert.equal(sanitizePoints("nope"), null, "not an array"); |
| 718 | assert.equal(sanitizePoints([[1, 2]]), null, "fewer than 2 points"); |
| 719 | assert.equal( |
| 720 | sanitizePoints([ |
| 721 | [1, 2], |
| 722 | ["x", 2], |
| 723 | [3, 4], |
| 724 | ]).length, |
| 725 | 2, |
| 726 | "non-numeric entries are dropped", |
| 727 | ); |
| 728 | assert.deepEqual(sanitizePoints([[-5, -5], [9999, 9999]]), [ |
| 729 | [0, 0], |
| 730 | [640, 320], |
| 731 | ]); |
| 732 | }); |
| 733 | |
| 734 | await test("board: open is idempotent; stroke is dropped without valid points or id", () => { |
| 735 | let s = boardReduce(undefined, { type: "board.open" }); |
| 736 | assert.equal(s.open, true); |
| 737 | const same = boardReduce(s, { type: "board.open" }); |
| 738 | assert.equal(same, s, "re-opening returns the same state object"); |
| 739 | |
| 740 | s = boardReduce(s, { type: "board.stroke", id: "s1", points: [[0, 0]] }); // 1 point |
| 741 | assert.deepEqual(s.strokes, [], "a single-point stroke is not a line"); |
| 742 | s = boardReduce(s, { type: "board.stroke", points: [[0, 0], [1, 1]] }); // no id |
| 743 | assert.deepEqual(s.strokes, [], "no id, no stroke"); |
| 744 | }); |
| 745 | |
| 746 | await test("board: stroke folds with sane color/width fallbacks; add is deduped by id", () => { |
| 747 | let s = boardReduce(undefined, { |
| 748 | type: "board.stroke", |
| 749 | id: "s1", |
| 750 | points: [[0, 0], [10, 10]], |
| 751 | color: "not-a-color", |
| 752 | width: 999, |
| 753 | }); |
| 754 | assert.equal(s.strokes.length, 1); |
| 755 | assert.equal(s.strokes[0].color, "#2a211b", "unknown color falls back to the default"); |
| 756 | assert.equal(s.strokes[0].width, 6, "unknown width falls back to the medium brush"); |
| 757 | |
| 758 | s = boardReduce(s, { type: "board.stroke", id: "s1", points: [[5, 5], [6, 6]], color: "#c2410c" }); |
| 759 | assert.equal(s.strokes.length, 1, "duplicate id is a no-op add"); |
| 760 | assert.equal(s.strokes[0].color, "#2a211b", "the original stroke wins, not overwritten"); |
| 761 | }); |
| 762 | |
| 763 | await test("board: clear empties strokes but leaves the panel open", () => { |
| 764 | let s = boardReduce(undefined, { type: "board.open" }); |
| 765 | s = boardReduce(s, { type: "board.stroke", id: "s1", points: [[0, 0], [1, 1]] }); |
| 766 | s = boardReduce(s, { type: "board.clear" }); |
| 767 | assert.deepEqual(s.strokes, []); |
| 768 | assert.equal(s.open, true, "clearing the drawing doesn't hide the panel"); |
| 769 | }); |
| 770 | |
| 771 | await test("board: close hides the panel without touching strokes; idempotent", () => { |
| 772 | let s = boardReduce(undefined, { type: "board.open" }); |
| 773 | s = boardReduce(s, { type: "board.stroke", id: "s1", points: [[0, 0], [1, 1]] }); |
| 774 | s = boardReduce(s, { type: "board.close" }); |
| 775 | assert.equal(s.open, false); |
| 776 | assert.equal(s.strokes.length, 1, "closing preserves the drawing for next time"); |
| 777 | const same = boardReduce(s, { type: "board.close" }); |
| 778 | assert.equal(same, s, "re-closing returns the same state object"); |
| 779 | |
| 780 | // Reopening picks up right where it left off. |
| 781 | s = boardReduce(s, { type: "board.open" }); |
| 782 | assert.equal(s.open, true); |
| 783 | assert.equal(s.strokes.length, 1); |
| 784 | }); |
| 785 | |
| 786 | await test("installGroupApp adds the app and, for openEvent apps (board), opens it too", async () => { |
| 787 | resetThread("installGroupChat1"); |
| 788 | const sent = []; |
| 789 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 790 | assert.equal(await installGroupApp("installGroupChat1", boardApp), true); |
| 791 | assert.deepEqual(sent.map((s) => s.event.type), ["groupapps.add", "board.open"]); |
| 792 | assert.ok(sent.every((s) => s.threadId === "installGroupChat1")); |
| 793 | }); |
| 794 | |
| 795 | await test("installGroupApp is a no-op when the app is already added — no re-add, no re-open", async () => { |
| 796 | resetThread("installGroupChat2"); |
| 797 | foldEvent("installGroupChat2", { type: "groupapps.add", app: "board", name: "Ana" }, { id: 1 }); |
| 798 | assert.ok(groupAppsFor("installGroupChat2").some((a) => a.name === "board")); |
| 799 | const sent = []; |
| 800 | setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); |
| 801 | assert.equal(await installGroupApp("installGroupChat2", boardApp), true); |
| 802 | assert.equal(sent.length, 0, "already added — nothing re-emitted, including no re-open"); |
| 803 | }); |
| 804 | |
| 805 | await test("installGroupApp doesn't fire an openEvent for apps that don't declare one (poll)", async () => { |
| 806 | resetThread("installGroupChat3"); |
| 807 | const sent = []; |
| 808 | setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); |
| 809 | assert.equal(await installGroupApp("installGroupChat3", pollApp), true); |
| 810 | assert.deepEqual(sent.map((e) => e.type), ["groupapps.add"]); |
| 811 | }); |
| 812 | |
| 813 | await test("board: owned types are exactly open/close/stroke/clear", () => { |
| 814 | assert.deepEqual(boardApp.types, ["board.open", "board.close", "board.stroke", "board.clear"]); |
| 815 | }); |
| 816 | |
| 817 | await test("remind: parseReminder handles relative, clock, and explicit times", () => { |
| 818 | // Relative: text before "in <n><unit>", when = now + offset. |
| 819 | assert.deepEqual(parseReminder("Call mom in 2h", 0), { text: "Call mom", when: 2 * 3600000 }); |
| 820 | assert.deepEqual(parseReminder("stretch in 45m", 1000), { text: "stretch", when: 1000 + 45 * 60000 }); |
| 821 | // Clock time: next occurrence of 9:00, always in the future, within a day. |
| 822 | const at = parseReminder("Standup at 9:00", 0); |
| 823 | assert.equal(at.text, "Standup"); |
| 824 | assert.ok(at.when > 0 && at.when <= 24 * 3600000, "next 9:00 is within a day"); |
| 825 | // Explicit date after "|" (local midnight → 09:00 default), text preserved. |
| 826 | const abs = parseReminder("Renew passport | 2026-08-01", 0); |
| 827 | assert.equal(abs.text, "Renew passport"); |
| 828 | const d = new Date(abs.when); |
| 829 | assert.equal(d.getFullYear(), 2026); |
| 830 | assert.equal(d.getMonth(), 7); // August (0-based) |
| 831 | assert.equal(d.getDate(), 1); |
| 832 | }); |
| 833 | |
| 834 | await test("remind: parseReminder accepts Slack phrasing (me / front-loaded time / to)", () => { |
| 835 | // "/remind me in 10 seconds to do a thing" — leading "me" dropped, time leads. |
| 836 | assert.deepEqual(parseReminder("me in 10 seconds to do a thing", 0), { text: "do a thing", when: 10000 }); |
| 837 | assert.deepEqual(parseReminder("in 5 minutes to stretch", 1000), { text: "stretch", when: 1000 + 5 * 60000 }); |
| 838 | assert.deepEqual(parseReminder("me to water plants in 1 h", 0), { text: "water plants", when: 3600000 }); |
| 839 | // "meeting" must NOT be read as the "me" keyword. |
| 840 | assert.deepEqual(parseReminder("meeting in 10m", 0), { text: "meeting", when: 10 * 60000 }); |
| 841 | }); |
| 842 | |
| 843 | await test("remind: parseReminder rejects text with no time or a bad unit", () => { |
| 844 | assert.equal(parseReminder("just a note", 0), null); |
| 845 | assert.equal(parseReminder("", 0), null); |
| 846 | assert.equal(parseReminder("do it in 3x", 0), null); // unknown unit |
| 847 | assert.equal(parseReminder("in 2h", 0), null); // no text before the time |
| 848 | // Explicit dates/times must be rejected, not rolled over, when out of range. |
| 849 | assert.equal(parseReminder("bad | 2026-06-31", 0), null); // June has 30 days |
| 850 | assert.equal(parseReminder("bad | 2026-02-29", 0), null); // 2026 isn't a leap year |
| 851 | assert.equal(parseReminder("bad | 2026-13-01", 0), null); // month > 12 |
| 852 | assert.equal(parseReminder("bad | 2026-08-01 24:00", 0), null); // hour > 23 |
| 853 | assert.equal(parseReminder("bad | 2026-08-01 12:60", 0), null); // minute > 59 |
| 854 | }); |
| 855 | |
| 856 | await test("remind: reduce adds (soonest first), fires, and dismisses", () => { |
| 857 | let s = remindReduce(undefined, { type: "remind.add", id: "r1", text: "later", when: 300, ts: 1 }); |
| 858 | s = remindReduce(s, { type: "remind.add", id: "r1", text: "later", when: 300, ts: 1 }); // dup id |
| 859 | assert.equal(s.items.length, 1); |
| 860 | s = remindReduce(s, { type: "remind.add", id: "r2", text: "sooner", when: 100, ts: 2 }); |
| 861 | assert.equal(s.items[0].id, "r2", "sorted soonest-first"); |
| 862 | // fire records firedAt (the event's send time) so it can tuck into Previous. |
| 863 | s = remindReduce(s, { type: "remind.fire", id: "r2", ts: 555 }); |
| 864 | assert.equal(s.items.find((i) => i.id === "r2").fired, true); |
| 865 | assert.equal(s.items.find((i) => i.id === "r2").firedAt, 555); |
| 866 | s = remindReduce(s, { type: "remind.done", id: "r2" }); |
| 867 | assert.deepEqual(s.items.map((i) => i.id), ["r1"]); |
| 868 | }); |
| 869 | |
| 870 | await test("bookmarks: adds dedupe by id, newest first, carry `from`, removable", () => { |
| 871 | let s = bookmarksReduce(undefined, { type: "bookmark.add", id: "b1", url: "https://a.com", note: "", ts: 1 }); |
| 872 | s = bookmarksReduce(s, { type: "bookmark.add", id: "b1", url: "https://a.com", note: "", ts: 1 }); // dup |
| 873 | assert.equal(s.items.length, 1); |
| 874 | s = bookmarksReduce(s, { type: "bookmark.add", id: "b2", url: "https://b.com", from: "Bob", ts: 2 }); |
| 875 | assert.equal(s.items[0].id, "b2", "newest first"); |
| 876 | assert.equal(s.items[0].from, "Bob", "keeps who shared it"); |
| 877 | s = bookmarksReduce(s, { type: "bookmark.remove", id: "b1" }); |
| 878 | assert.deepEqual(s.items.map((i) => i.id), ["b2"]); |
| 879 | }); |
| 880 | |
| 881 | // ---- snapshots: state that outlives the message window ------------------- |
| 882 | |
| 883 | await test("seedThread + foldEvent: snapshot state, skip covered ids, fold newer", () => { |
| 884 | resetThread("s1"); |
| 885 | // Seed a snapshot covering message ids <= 100 with one existing item. |
| 886 | seedThread("s1", { todo: { items: [{ id: "old", text: "from snapshot", done: false, by: "" }] } }, 100); |
| 887 | assert.deepEqual(appStateFor("s1", "todo").items.map((i) => i.id), ["old"]); |
| 888 | // An event AT or BELOW the horizon is recognised (returns the app — its inline |
| 889 | // line may render) but not re-reduced: the snapshot already contains it. |
| 890 | const covered = foldEvent("s1", { type: "todo.add", id: "old", text: "from snapshot" }, { id: 100 }); |
| 891 | assert.equal(covered && covered.name, "todo"); |
| 892 | assert.equal(appStateFor("s1", "todo").items.length, 1, "covered event not re-folded"); |
| 893 | // An event past the horizon folds normally on top of the seeded state. |
| 894 | foldEvent("s1", { type: "todo.add", id: "new", text: "after snapshot" }, { id: 101 }); |
| 895 | assert.deepEqual(appStateFor("s1", "todo").items.map((i) => i.id), ["old", "new"]); |
| 896 | }); |
| 897 | |
| 898 | await test("seedThread reinstalls the snapshot as the authoritative base, superseding a stale live fold", () => { |
| 899 | resetThread("s2"); |
| 900 | // A live event folded before the snapshot loaded (the openThread race). Its id (5) |
| 901 | // is BELOW the snapshot horizon (99), so the snapshot at 99 already accounts for it |
| 902 | // — the snapshot is the authoritative floor and supersedes the partial live fold. |
| 903 | // (An event ABOVE the horizon is instead restored by the caller's catch-up; see the |
| 904 | // race test below.) The old guard kept the live fold and dropped the snapshot, which |
| 905 | // lost every item the interim events don't re-cover. |
| 906 | foldEvent("s2", { type: "todo.add", id: "a", text: "live" }, { id: 5 }); |
| 907 | seedThread("s2", { todo: { items: [] } }, 99); |
| 908 | assert.deepEqual(appStateFor("s2", "todo").items.map((i) => i.id), []); |
| 909 | }); |
| 910 | |
| 911 | await test("seedThread survives a live event that raced the snapshot load (no lost history)", () => { |
| 912 | resetThread("race"); |
| 913 | // The data-loss race: openThread resets, then awaits the snapshot; a live app event |
| 914 | // for the thread folds FIRST (id 150, above the eventual horizon), creating the store. |
| 915 | foldEvent("race", { type: "todo.add", id: "live", text: "raced in" }, { id: 150 }); |
| 916 | // The snapshot (older history, horizon 100) then loads and installs. It must NOT be |
| 917 | // skipped just because interim state exists — the older item has to survive. |
| 918 | seedThread("race", { todo: { items: [{ id: "old", text: "from snapshot", done: false, by: "" }] } }, 100); |
| 919 | // The caller's catch-up re-reads everything after the horizon and re-folds the interim |
| 920 | // event onto the base — seedThread cleared its dedupe mark, so this isn't skipped. |
| 921 | foldEvent("race", { type: "todo.add", id: "live", text: "raced in" }, { id: 150 }); |
| 922 | assert.deepEqual( |
| 923 | appStateFor("race", "todo").items.map((i) => i.id), |
| 924 | ["old", "live"], |
| 925 | "snapshot history survived AND the raced event was restored", |
| 926 | ); |
| 927 | // And the persisted snapshot is NOT truncated at an advanced horizon: it covers both, |
| 928 | // anchored at the highest folded id (150), so the next open won't skip the old history. |
| 929 | const snap = takeSnapshot("race"); |
| 930 | assert.equal(snap.upTo, 150); |
| 931 | assert.deepEqual(snap.states.todo.items.map((i) => i.id), ["old", "live"]); |
| 932 | }); |
| 933 | |
| 934 | await test("takeSnapshot returns new state once, anchored at the highest folded id", () => { |
| 935 | resetThread("s3"); |
| 936 | assert.equal(takeSnapshot("s3"), null, "nothing folded → nothing to save"); |
| 937 | foldEvent("s3", { type: "todo.add", id: "a", text: "apples" }, { id: 7 }); |
| 938 | foldEvent("s3", { type: "todo.add", id: "b", text: "bread" }, { id: 9 }); |
| 939 | const snap = takeSnapshot("s3"); |
| 940 | assert.equal(snap.upTo, 9); |
| 941 | assert.deepEqual(snap.states.todo.items.map((i) => i.id), ["a", "b"]); |
| 942 | assert.equal(takeSnapshot("s3"), null, "clean after taking — no re-save loop"); |
| 943 | foldEvent("s3", { type: "todo.done", id: "a" }, { id: 10 }); |
| 944 | const snap2 = takeSnapshot("s3"); |
| 945 | assert.equal(snap2.upTo, 10, "re-dirtied by the next fold"); |
| 946 | }); |
| 947 | |
| 948 | // ---- scheduling: ctx.schedule / ctx.unschedule ---------------------------- |
| 949 | |
| 950 | await test("/remind schedules a server-delivered fire, then records the sid", async () => { |
| 951 | const sent = []; |
| 952 | const scheduled = []; |
| 953 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 954 | setScheduleTransport({ |
| 955 | schedule: async (threadId, event, whenMs) => { |
| 956 | scheduled.push({ threadId, event, whenMs }); |
| 957 | return "sid-1"; |
| 958 | }, |
| 959 | cancel: async () => true, |
| 960 | }); |
| 961 | assert.equal(runCommand("/remind Call mom in 2h", "chatX"), true); |
| 962 | await tick(); |
| 963 | // The future remind.fire went to the scheduler, carrying its own notice |
| 964 | // (what the service worker shows when the app is closed). |
| 965 | assert.equal(scheduled.length, 1); |
| 966 | assert.equal(scheduled[0].threadId, "todoThread"); // the resolved app-thread |
| 967 | assert.equal(scheduled[0].event.type, "remind.fire"); |
| 968 | assert.equal(scheduled[0].event.notice.title, "⏰ Reminder"); |
| 969 | assert.equal(scheduled[0].event.notice.body, "Call mom"); |
| 970 | // The recorded reminder carries the schedule id, so ANY device can cancel it. |
| 971 | assert.equal(sent.length, 1); |
| 972 | assert.equal(sent[0].event.type, "remind.add"); |
| 973 | assert.equal(sent[0].event.sid, "sid-1"); |
| 974 | assert.equal(sent[0].event.id, scheduled[0].event.id, "fire targets the same reminder id"); |
| 975 | }); |
| 976 | |
| 977 | await test("a failed schedule still records the reminder, sid-less (client fallback)", async () => { |
| 978 | const sent = []; |
| 979 | setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); |
| 980 | setScheduleTransport({ schedule: async () => null, cancel: async () => false }); |
| 981 | runCommand("/remind stretch in 5m", "chatX"); |
| 982 | await tick(); |
| 983 | assert.equal(sent.length, 1); |
| 984 | assert.equal(sent[0].sid, "", "no sid — the render ticker owns firing"); |
| 985 | }); |
| 986 | |
| 987 | // ---- spontaneous app → user communication --------------------------------- |
| 988 | |
| 989 | await test("notifyLive: app events never floating-toast; a hidden page gets a system notification", () => { |
| 990 | // A hook-less app: the notice riding the event is what speaks. |
| 991 | defineApp({ |
| 992 | name: "notix", |
| 993 | title: "Notix", |
| 994 | glyph: "×", |
| 995 | scope: "system", |
| 996 | initialState: () => ({}), |
| 997 | events: { "notix.ping": (s) => s }, |
| 998 | }); |
| 999 | const toasts = []; |
| 1000 | setToast((m) => toasts.push(m)); |
| 1001 | resetThread("n1"); |
| 1002 | |
| 1003 | // Stand in for the browser: a hidden page with notifications granted. |
| 1004 | const notes = []; |
| 1005 | const priorDoc = globalThis.document; |
| 1006 | const priorNote = globalThis.Notification; |
| 1007 | globalThis.document = { visibilityState: "hidden" }; |
| 1008 | globalThis.Notification = function (title, opts) { |
| 1009 | notes.push({ title, body: opts && opts.body }); |
| 1010 | }; |
| 1011 | globalThis.Notification.permission = "granted"; |
| 1012 | try { |
| 1013 | notifyLive("n1", { type: "notix.ping", notice: { title: "⏰ Reminder", body: "Call mom" } }); |
| 1014 | // No floating toast — a live app event announces itself via its thread (which |
| 1015 | // floats up the chat list with an unread dot), never a pop-up over your reading. |
| 1016 | assert.deepEqual(toasts, []); |
| 1017 | // But a hidden page still gets the background system notification — the |
| 1018 | // in-session equivalent of the push a fully-closed app would receive. |
| 1019 | assert.deepEqual(notes, [{ title: "⏰ Reminder", body: "Call mom" }]); |
| 1020 | |
| 1021 | // A VISIBLE page raises no system notification (you're already here) — and still no toast. |
| 1022 | globalThis.document.visibilityState = "visible"; |
| 1023 | notifyLive("n1", { type: "notix.ping", notice: { title: "⏰ Reminder", body: "again" } }); |
| 1024 | assert.equal(notes.length, 1); |
| 1025 | assert.equal(toasts.length, 0); |
| 1026 | |
| 1027 | // An event with no notice (and no notify hook) stays silent; so does a type no app owns. |
| 1028 | globalThis.document.visibilityState = "hidden"; |
| 1029 | notifyLive("n1", { type: "notix.ping" }); |
| 1030 | notifyLive("n1", { type: "mystery.event", notice: { title: "spoof" } }); |
| 1031 | assert.equal(notes.length, 1); |
| 1032 | } finally { |
| 1033 | globalThis.document = priorDoc; |
| 1034 | globalThis.Notification = priorNote; |
| 1035 | } |
| 1036 | }); |
| 1037 | |
| 1038 | await test("command reply: only confirmations (those with a `short`) calm down", () => { |
| 1039 | // A confirmation supplies a terse `short` → it counts down and shrinks. |
| 1040 | assert.equal(noticeCalmsDown({ text: "Added “milk” to your to-do list.", short: "added “milk”" }), true); |
| 1041 | // A genuine content reply like /help gives no `short` → it stays a real message. |
| 1042 | assert.equal(noticeCalmsDown({ sender: { name: "Eleven" }, text: "Commands you can use…" }), false); |
| 1043 | assert.equal(noticeCalmsDown({ short: " " }), false); // whitespace-only doesn't count |
| 1044 | assert.equal(noticeCalmsDown({}), false); |
| 1045 | assert.equal(noticeCalmsDown(), false); |
| 1046 | }); |
| 1047 | |
| 1048 | await test("command reply: compact one-liner parts + the loud→calm age threshold", () => { |
| 1049 | // The calmed-down one-liner prefers the reply's own terse `short`, else its text. |
| 1050 | assert.deepEqual( |
| 1051 | noticeParts({ sender: { name: "Reminders" }, text: "I'll remind you: “fart”.", short: "added “fart”" }), |
| 1052 | { from: "Reminders", what: "added “fart”" }, |
| 1053 | ); |
| 1054 | assert.deepEqual( |
| 1055 | noticeParts({ sender: { name: "Eleven" }, text: "Commands you can use…" }), |
| 1056 | { from: "Eleven", what: "Commands you can use…" }, |
| 1057 | ); |
| 1058 | assert.deepEqual(noticeParts({}), { from: "", what: "" }); |
| 1059 | |
| 1060 | // A fresh reply is loud; once aged past the threshold it renders compact. A reply |
| 1061 | // with no timestamp never auto-collapses (stays as first built). |
| 1062 | const now = 1_000_000; |
| 1063 | assert.equal(noticeIsCompact(now, now), false); |
| 1064 | assert.equal(noticeIsCompact(now - NOTICE_COMPACT_AFTER_MS + 1, now), false); |
| 1065 | assert.equal(noticeIsCompact(now - NOTICE_COMPACT_AFTER_MS, now), true); |
| 1066 | assert.equal(noticeIsCompact(0, now), false); |
| 1067 | }); |
| 1068 | |
| 1069 | await test("remind: chatMessage — fire is a bot message, setting is an action line", () => { |
| 1070 | // A fired reminder is a message from the bot (a string → a bubble), not a toast. |
| 1071 | assert.equal( |
| 1072 | remindApp.chatMessage({ type: "remind.fire", id: "r1", notice: { title: "⏰ Reminder", body: "Call mom" } }), |
| 1073 | "You asked me to remind you: “Call mom”", |
| 1074 | ); |
| 1075 | // Setting one is an action you took → a /me-style line, not a bubble. |
| 1076 | const add = remindApp.chatMessage({ type: "remind.add", id: "r1", text: "Call mom", when: 99 }); |
| 1077 | assert.equal(add.action, true); |
| 1078 | assert.match(add.text, /^set a reminder: “Call mom”/); |
| 1079 | // Housekeeping events produce no line. |
| 1080 | assert.equal(remindApp.chatMessage({ type: "remind.done", id: "r1" }), null); |
| 1081 | }); |
| 1082 | |
| 1083 | await test("todo: chatMessage renders an add as an action line, others silent", () => { |
| 1084 | const add = todoApp.chatMessage({ type: "todo.add", id: "t1", text: "buy milk" }); |
| 1085 | assert.equal(add.action, true); |
| 1086 | assert.match(add.text, /added “buy milk”/); |
| 1087 | assert.equal(todoApp.chatMessage({ type: "todo.done", id: "t1" }), null); |
| 1088 | }); |
| 1089 | |
| 1090 | await test("one message folds into TWO apps (owner state + chat action bubble)", () => { |
| 1091 | resetThread("dual"); |
| 1092 | // The SAME server message id (70) folds into the owning app (state) AND the chat |
| 1093 | // app (an action-line bubble). De-dupe must be per-app, or the chat fold is |
| 1094 | // silently dropped (the bug: the Interface updated but no action line appeared). |
| 1095 | foldEvent("dual", { type: "todo.add", id: "x1", text: "milk" }, { id: 70 }); |
| 1096 | foldHostEvent("dual", "chat", { type: "chat.msg", name: "To-do", text: "added milk", uid: "app:todo", action: true }, { id: 70 }); |
| 1097 | assert.equal(appStateFor("dual", "todo").items.length, 1, "owner app folded the event"); |
| 1098 | const chat = appStateFor("dual", "chat"); |
| 1099 | assert.equal(chat.msgs.length, 1, "chat app also folded the same message id"); |
| 1100 | assert.equal(chat.msgs[0].action, true, "and it's flagged as an action line"); |
| 1101 | }); |
| 1102 | |
| 1103 | await test("chat: an action line's raw payload keeps action:true (so editing it doesn't turn it into a bubble)", () => { |
| 1104 | const reduce = chatApp.reduce; |
| 1105 | const s = reduce(undefined, { type: "chat.msg", text: "Alice waves", name: "Alice", uid: "u-al", action: true }, { id: 90 }); |
| 1106 | assert.equal(s.msgs[0].raw.action, true, "{ ...raw, text } re-sends action:true on edit"); |
| 1107 | }); |
| 1108 | |
| 1109 | // ---- chat as an app (the lab) -------------------------------------------- |
| 1110 | // The chat app consumes the EXISTING protocol: original-shaped messages fold as host |
| 1111 | // events keyed by server message id, and reactions/edits/deletes are host-only |
| 1112 | // translations of the server's own frames. |
| 1113 | |
| 1114 | await test("chat: folds original-shaped messages (text, attachments, replies), dedupes by server id", () => { |
| 1115 | const reduce = chatApp.reduce; |
| 1116 | let s = reduce(undefined, { type: "chat.msg", text: "hi", name: "Al", uid: "u-al", ts: 1 }, { id: 10 }); |
| 1117 | s = reduce(s, { type: "chat.msg", text: "hi", name: "Al", uid: "u-al", ts: 1 }, { id: 10 }); // re-fold |
| 1118 | assert.equal(s.msgs.length, 1); |
| 1119 | assert.equal(s.msgs[0].id, "m10", "keyed by the server message id — the original identity"); |
| 1120 | // Attachment-only messages (a photo with no caption) still fold, with the RAW |
| 1121 | // metadata preserved (blob id, dimensions — needed to rebuild an edit payload). |
| 1122 | const att = { id: "blob1", name: "cat.jpg", type: "image/jpeg", size: 1024, w: 640, h: 480 }; |
| 1123 | s = reduce(s, { type: "chat.msg", attachments: [att], name: "Bo" }, { id: 11 }); |
| 1124 | assert.deepEqual(s.msgs[1].attachments, [att]); |
| 1125 | assert.deepEqual(s.msgs[1].raw.attachments, [att], "raw payload kept for { ...orig, text } edits"); |
| 1126 | // A reply carries its parent link + snippet through (the id feeds tallies). |
| 1127 | s = reduce(s, { type: "chat.msg", text: "yes!", replyTo: { id: 10, name: "Al", snippet: "hi" } }, { id: 12 }); |
| 1128 | assert.deepEqual(s.msgs[2].reply, { id: 10, name: "Al", snippet: "hi" }); |
| 1129 | // The parent's viewItems row tallies it. The tally rides the SOFT rev (srev), |
| 1130 | // so it patches the reply-count in place — it must NOT be in the hard vrev, |
| 1131 | // which would rebuild the whole bubble (and reload a photo). |
| 1132 | const v = viewItems(s.msgs); |
| 1133 | assert.equal(v[0].replyCount, 1, "parent shows one reply"); |
| 1134 | assert.ok(v[0].srev.endsWith("|1"), "the tally rides the soft rev (patched in place)"); |
| 1135 | assert.equal(v[0].vrev, "0|D|0", "the tally is NOT in the hard rev — no bubble rebuild"); |
| 1136 | s = reduce(s, { type: "chat.msg", text: " " }, { id: 13 }); // nothing to say |
| 1137 | assert.equal(s.msgs.length, 3); |
| 1138 | }); |
| 1139 | |
| 1140 | await test("chat: a scroll-back page folded later still sorts before newer messages", () => { |
| 1141 | const reduce = chatApp.reduce; |
| 1142 | let s = reduce(undefined, { type: "chat.msg", text: "newer", ts: 5 }, { id: 30 }); |
| 1143 | s = reduce(s, { type: "chat.msg", text: "older", ts: 1 }, { id: 10 }); // paged in afterwards |
| 1144 | assert.deepEqual(s.msgs.map((m) => m.sid), [10, 30], "sorted by server id, not fold order"); |
| 1145 | }); |
| 1146 | |
| 1147 | await test("chat: a legacy row with no payload ts dates from the fold meta (created_at), not 1970", () => { |
| 1148 | const reduce = chatApp.reduce; |
| 1149 | // Rows predating ts-stamping carry no inline ts; chatAppFoldMessage passes the |
| 1150 | // server's created_at (ms) through as meta.ts so they date correctly, not Jan 1970. |
| 1151 | const s = reduce(undefined, { type: "chat.msg", text: "legacy" }, { id: 40, ts: 1720000000000 }); |
| 1152 | assert.equal(s.msgs[0].ts, 1720000000000, "falls back to meta.ts, not 0"); |
| 1153 | }); |
| 1154 | |
| 1155 | await test("chat: viewItems derives time separators and sender runs (original feed rules)", () => { |
| 1156 | const DAY = 24 * 3600 * 1000; |
| 1157 | const base = Date.UTC(2026, 6, 1, 12, 0, 0); |
| 1158 | const msgs = [ |
| 1159 | { id: "m1", sid: 1, name: "Al", ts: base, rev: 0 }, |
| 1160 | { id: "m2", sid: 2, name: "Al", ts: base + 60 * 1000, rev: 0 }, // same run: 1 min later |
| 1161 | { id: "m3", sid: 3, name: "Bo", ts: base + 2 * 60 * 1000, rev: 0 }, // new sender |
| 1162 | { id: "m4", sid: 4, name: "Bo", ts: base + 20 * 60 * 1000, rev: 0 }, // >15 min gap, same day |
| 1163 | { id: "m5", sid: 5, name: "Bo", ts: base + DAY, rev: 0 }, // next day |
| 1164 | ]; |
| 1165 | const v = viewItems(msgs); |
| 1166 | assert.deepEqual(v[0].timeSep, { withDay: true }, "the oldest message opens with a dated separator"); |
| 1167 | assert.equal(v[0].cont, false); |
| 1168 | assert.equal(v[1].timeSep, null); |
| 1169 | assert.equal(v[1].cont, true, "same sender, no separator — a continuation"); |
| 1170 | assert.equal(v[2].cont, false, "sender changed"); |
| 1171 | assert.deepEqual(v[3].timeSep, { withDay: false }, ">15 min gap gets a time-only separator"); |
| 1172 | assert.equal(v[3].cont, false, "a separator restarts the run even for the same sender"); |
| 1173 | assert.deepEqual(v[4].timeSep, { withDay: true }, "a calendar-day change gets a dated separator"); |
| 1174 | // Presentation rides the rev, so a neighbour change re-renders the right bubble. |
| 1175 | assert.notEqual(v[1].vrev, v[2].vrev); |
| 1176 | }); |
| 1177 | |
| 1178 | await test("chat: viewItems groups runs by uid — a rename doesn't split, nameless senders don't merge", () => { |
| 1179 | const base = Date.UTC(2026, 6, 1, 12, 0, 0); |
| 1180 | const v = viewItems([ |
| 1181 | { id: "m1", sid: 1, uid: "u1", name: "Dermo", ts: base, rev: 0 }, |
| 1182 | { id: "m2", sid: 2, uid: "u1", name: "Dermot", ts: base + 1000, rev: 0 }, |
| 1183 | { id: "m3", sid: 3, uid: "u2", name: "", ts: base + 2000, rev: 0 }, |
| 1184 | { id: "m4", sid: 4, uid: "u3", name: "", ts: base + 3000, rev: 0 }, |
| 1185 | ]); |
| 1186 | assert.equal(v[1].cont, true, "same uid continues the run across a rename"); |
| 1187 | assert.equal(v[2].cont, false); |
| 1188 | assert.equal(v[3].cont, false, "different uids never group, even both nameless"); |
| 1189 | }); |
| 1190 | |
| 1191 | await test("chat: threadItems selects a root's whole thread — legacy chains resolve into it", () => { |
| 1192 | const msgs = [ |
| 1193 | { id: "m1", sid: 1, name: "Al", text: "root", ts: 1, reply: null, rev: 0 }, |
| 1194 | { id: "m2", sid: 2, name: "Bo", text: "aside", ts: 2, reply: null, rev: 0 }, |
| 1195 | { id: "m3", sid: 3, name: "Cy", text: "re", ts: 3, reply: { id: 1, name: "Al", snippet: "root" }, rev: 0 }, |
| 1196 | // A legacy chain: this reply quotes the REPLY (m3), not the root — it must |
| 1197 | // still resolve transitively into m1's thread. |
| 1198 | { id: "m4", sid: 4, name: "Al", text: "re-re", ts: 4, reply: { id: 3, name: "Cy", snippet: "re" }, rev: 0 }, |
| 1199 | { id: "m5", sid: 5, name: "Bo", text: "other thread", ts: 5, reply: { id: 2, name: "Bo", snippet: "aside" }, rev: 0 }, |
| 1200 | ]; |
| 1201 | assert.deepEqual( |
| 1202 | threadItems(msgs, 1).map((m) => m.sid), |
| 1203 | [1, 3, 4], |
| 1204 | "root first, then its thread in server order; the reply-to-a-reply resolves in; asides and other threads stay out", |
| 1205 | ); |
| 1206 | assert.deepEqual(threadItems(msgs, 2).map((m) => m.sid), [2, 5], "the other thread folds on its own root"); |
| 1207 | // Root scrolled out of the loaded window: the thread still folds from the |
| 1208 | // replies we can see (the view renders the quoted snapshot as its header — |
| 1209 | // the chain resolves to the same unloaded sid either way). |
| 1210 | assert.deepEqual(threadItems(msgs.slice(2), 1).map((m) => m.sid), [3, 4], "unloaded root — replies only, chain intact"); |
| 1211 | assert.deepEqual(threadItems(msgs, 99), [], "an unknown root has no thread"); |
| 1212 | }); |
| 1213 | |
| 1214 | await test("chat: reactions, edits, deletes fold as host translations of server frames", () => { |
| 1215 | const reduce = chatApp.reduce; |
| 1216 | let s = reduce(undefined, { type: "chat.msg", text: "hello", name: "Al", ts: 1, uid: "alice" }, { id: 20 }); |
| 1217 | const rev0 = s.msgs[0].rev; |
| 1218 | s = reduce(s, { type: "chat.reaction", targetSid: 20, emoji: "👍", uid: "bob", on: true, ts: 2 }, {}, { me: "alice" }); |
| 1219 | assert.deepEqual(s.msgs[0].reactions, [{ emoji: "👍", count: 1, mine: false }]); |
| 1220 | assert.equal(s.msgs[0].rev, rev0 + 1, "a reaction bumps the soft rev"); |
| 1221 | s = reduce(s, { type: "chat.reaction", targetSid: 20, emoji: "👍", uid: "charlie", on: true, ts: 3 }, {}, { me: "alice" }); |
| 1222 | assert.deepEqual(s.msgs[0].reactions, [{ emoji: "👍", count: 2, mine: false }]); |
| 1223 | const revBeforeEdit = s.msgs[0].rev; |
| 1224 | s = reduce(s, { type: "chat.edit", msgId: 20, text: "hello again" }); |
| 1225 | assert.equal(s.msgs[0].text, "hello again"); |
| 1226 | assert.equal(s.msgs[0].edited, true); |
| 1227 | assert.ok(s.msgs[0].rev > revBeforeEdit, "an edit DOES bump the hard rev — the bubble re-renders its text"); |
| 1228 | s = reduce(s, { type: "chat.edit", msgId: 999, text: "ghost" }); // unknown target — no-op |
| 1229 | assert.equal(s.msgs.length, 1); |
| 1230 | s = reduce(s, { type: "chat.delete", msgId: 20 }); |
| 1231 | assert.deepEqual(s.msgs, []); |
| 1232 | }); |
| 1233 | |
| 1234 | await test("chat mutations are host-only: wire payloads can't spoof them", () => { |
| 1235 | resetThread("c0"); |
| 1236 | // A member posting {type:"chat.delete"} as a message must fold NOTHING — the |
| 1237 | // type is not wire-owned (deletes are server-verified, author-only). Same for |
| 1238 | // chat.reaction and chat.edit — only the host (shell.js) can emit those. |
| 1239 | assert.equal(foldEvent("c0", { type: "chat.delete", msgId: 1 }, { id: 2 }), null); |
| 1240 | assert.equal(foldEvent("c0", { type: "chat.reaction", targetSid: 1, emoji: "👍", uid: "x", on: true }, { id: 3 }), null); |
| 1241 | assert.equal(foldEvent("c0", { type: "chat.edit", msgId: 1, text: "edited" }, { id: 4 }), null); |
| 1242 | assert.equal(foldEvent("c0", { type: "chat.msg", text: "spoof" }, { id: 5 }), null); |
| 1243 | // The host doorway reaches them. |
| 1244 | const app = foldHostEvent("c0", "chat", { type: "chat.msg", text: "real" }, { id: 6 }); |
| 1245 | assert.equal(app && app.name, "chat"); |
| 1246 | assert.equal(appStateFor("c0", "chat").msgs.length, 1); |
| 1247 | }); |
| 1248 | |
| 1249 | await test("chat: opts out of snapshots — its state IS the log", () => { |
| 1250 | resetThread("c1"); |
| 1251 | foldHostEvent("c1", "chat", { type: "chat.msg", text: "hi" }, { id: 5 }); |
| 1252 | assert.equal(appStateFor("c1", "chat").msgs.length, 1); |
| 1253 | assert.equal(takeSnapshot("c1"), null, "chat folds never dirty the snapshot"); |
| 1254 | // A snapshotting app in the SAME thread still snapshots — without chat state. |
| 1255 | foldEvent("c1", { type: "poll.create", id: "p", q: "Q?", options: ["a", "b"] }, { id: 6 }); |
| 1256 | const snap = takeSnapshot("c1"); |
| 1257 | assert.ok(snap.states.poll, "poll state saved"); |
| 1258 | assert.equal(snap.states.chat, undefined, "chat state excluded"); |
| 1259 | }); |
| 1260 | |
| 1261 | await test("another app's snapshot horizon never blocks chat folds", () => { |
| 1262 | resetThread("c2"); |
| 1263 | // A poll snapshot covers message ids <= 100 — chat isn't in any snapshot, so |
| 1264 | // an untyped chat message at id 50 must still fold into the chat feed. |
| 1265 | seedThread("c2", { poll: { polls: [] } }, 100); |
| 1266 | foldHostEvent("c2", "chat", { type: "chat.msg", text: "history" }, { id: 50 }); |
| 1267 | assert.equal(appStateFor("c2", "chat").msgs.length, 1, "folded despite the horizon"); |
| 1268 | }); |
| 1269 | |
| 1270 | // ---- sidebar presence hooks (preview / unread) ----------------------------- |
| 1271 | |
| 1272 | await test("apps opt into sidebar presence: poll previews + marks unread on create", () => { |
| 1273 | assert.equal(appPreview({ type: "poll.create", q: "Lunch?", options: ["a", "b"] }), "📊 Lunch?"); |
| 1274 | assert.equal(appUnread({ type: "poll.create", q: "Lunch?" }), true); |
| 1275 | // Votes stay silent — the tally updates live anyway. |
| 1276 | assert.equal(appPreview({ type: "poll.vote", pollId: "p", choice: 0 }), null); |
| 1277 | assert.equal(appUnread({ type: "poll.vote", pollId: "p" }), false); |
| 1278 | // To-do opts in for adds (how a group notices shared-list activity); its |
| 1279 | // quiet events (done/reopen/remove) stay silent. |
| 1280 | assert.equal(appPreview({ type: "todo.add", id: "a", text: "milk", name: "Ana" }), "✓ Ana added “milk” to the to-dos"); |
| 1281 | assert.equal(appUnread({ type: "todo.add", id: "a" }), true); |
| 1282 | assert.equal(appPreview({ type: "todo.done", id: "a" }), null); |
| 1283 | assert.equal(appUnread({ type: "todo.done", id: "a" }), false); |
| 1284 | // Unowned types are silent too. |
| 1285 | assert.equal(appPreview({ type: "mystery.event" }), null); |
| 1286 | }); |
| 1287 | |
| 1288 | await test("board previews + marks unread on open; strokes stay silent", () => { |
| 1289 | assert.equal(appPreview({ type: "board.open" }), "🎨 opened the whiteboard"); |
| 1290 | assert.equal(appUnread({ type: "board.open" }), true); |
| 1291 | assert.equal(appPreview({ type: "board.stroke", id: "s1" }), null); |
| 1292 | assert.equal(appUnread({ type: "board.stroke", id: "s1" }), false); |
| 1293 | }); |
| 1294 | |
| 1295 | // ---- ephemeral events (relay-only, never folded) --------------------------- |
| 1296 | |
| 1297 | await test("deliverEphemeral routes to the owning app's hook; fold state untouched", () => { |
| 1298 | const pings = []; |
| 1299 | defineApp({ |
| 1300 | name: "probe", |
| 1301 | title: "Probe", |
| 1302 | glyph: "×", |
| 1303 | scope: "system", |
| 1304 | initialState: () => ({ n: 0 }), |
| 1305 | events: { "probe.ping": (s) => s }, // owned via a no-op fold, like chat.typing |
| 1306 | ephemeral(e, ctx) { |
| 1307 | pings.push({ from: e.name, thread: ctx.threadId }); |
| 1308 | }, |
| 1309 | }); |
| 1310 | resetThread("e1"); |
| 1311 | deliverEphemeral("e1", { type: "probe.ping", name: "Al" }); |
| 1312 | assert.deepEqual(pings, [{ from: "Al", thread: "e1" }]); |
| 1313 | deliverEphemeral("e1", { type: "unowned.type", name: "Al" }); // silently ignored |
| 1314 | assert.equal(pings.length, 1); |
| 1315 | assert.equal(takeSnapshot("e1"), null, "nothing folded, nothing to save"); |
| 1316 | }); |
| 1317 | |
| 1318 | await test("ctx.emitEphemeral goes through the ephemeral transport, not emit", async () => { |
| 1319 | const stored = []; |
| 1320 | const relayed = []; |
| 1321 | setEmitTransport((tid, e) => (stored.push(e), Promise.resolve(true))); |
| 1322 | setEphemeralTransport((tid, e) => (relayed.push({ tid, e }), Promise.resolve(true))); |
| 1323 | // The probe app registered above owns "probe.ping"; drive its hook via a |
| 1324 | // second app? No — reach ctx directly through a command on a fresh app. |
| 1325 | defineApp({ |
| 1326 | name: "probe2", |
| 1327 | title: "Probe2", |
| 1328 | glyph: "×", |
| 1329 | scope: "shared", |
| 1330 | initialState: () => ({}), |
| 1331 | events: { "probe2.beat": (s) => s }, |
| 1332 | commands: { "/beat": (args, ctx) => ctx.emitEphemeral({ type: "probe2.beat" }) }, |
| 1333 | }); |
| 1334 | runCommand("/beat", "chatY"); |
| 1335 | await tick(); |
| 1336 | assert.equal(stored.length, 0, "nothing persisted"); |
| 1337 | assert.deepEqual(relayed[0].tid, "chatY"); |
| 1338 | assert.equal(relayed[0].e.type, "probe2.beat"); |
| 1339 | }); |
| 1340 | |
| 1341 | // ---- keyedList (incremental list reconciliation) --------------------------- |
| 1342 | |
| 1343 | // A minimal DOM stand-in: just the surface keyedList touches (children, |
| 1344 | // firstChild, insertBefore, replaceWith, remove, dataset, nextSibling). |
| 1345 | function fakeContainer() { |
| 1346 | const el = { |
| 1347 | children: [], |
| 1348 | get firstChild() { |
| 1349 | return el.children[0] || null; |
| 1350 | }, |
| 1351 | insertBefore(node, ref) { |
| 1352 | const cur = el.children.indexOf(node); |
| 1353 | if (cur !== -1) el.children.splice(cur, 1); |
| 1354 | const i = ref ? el.children.indexOf(ref) : el.children.length; |
| 1355 | el.children.splice(i === -1 ? el.children.length : i, 0, node); |
| 1356 | node.parent = el; |
| 1357 | }, |
| 1358 | }; |
| 1359 | return el; |
| 1360 | } |
| 1361 | function fakeNode(label) { |
| 1362 | return { |
| 1363 | label, |
| 1364 | dataset: {}, |
| 1365 | parent: null, |
| 1366 | get nextSibling() { |
| 1367 | const sib = this.parent ? this.parent.children : []; |
| 1368 | const i = sib.indexOf(this); |
| 1369 | return i === -1 ? null : sib[i + 1] || null; |
| 1370 | }, |
| 1371 | replaceWith(other) { |
| 1372 | const sib = this.parent.children; |
| 1373 | sib[sib.indexOf(this)] = other; |
| 1374 | other.parent = this.parent; |
| 1375 | this.parent = null; |
| 1376 | }, |
| 1377 | remove() { |
| 1378 | if (!this.parent) return; |
| 1379 | const sib = this.parent.children; |
| 1380 | const i = sib.indexOf(this); |
| 1381 | if (i !== -1) sib.splice(i, 1); |
| 1382 | this.parent = null; |
| 1383 | }, |
| 1384 | }; |
| 1385 | } |
| 1386 | |
| 1387 | await test("keyedList appends without rebuilding, re-renders on rev, removes gone", () => { |
| 1388 | const el = fakeContainer(); |
| 1389 | let built = 0; |
| 1390 | const draw = (items) => |
| 1391 | keyedList(el, items, { |
| 1392 | key: (i) => i.id, |
| 1393 | rev: (i) => i.rev || 0, |
| 1394 | render: (i) => (built++, fakeNode(i.id + ":" + (i.rev || 0))), |
| 1395 | }); |
| 1396 | |
| 1397 | draw([{ id: "a" }, { id: "b" }]); |
| 1398 | assert.deepEqual(el.children.map((n) => n.label), ["a:0", "b:0"]); |
| 1399 | assert.equal(built, 2); |
| 1400 | const [nodeA, nodeB] = el.children; |
| 1401 | |
| 1402 | // Append: existing nodes are reused untouched (scroll/focus/images survive). |
| 1403 | draw([{ id: "a" }, { id: "b" }, { id: "c" }]); |
| 1404 | assert.equal(el.children[0], nodeA, "a reused"); |
| 1405 | assert.equal(el.children[1], nodeB, "b reused"); |
| 1406 | assert.equal(built, 3, "only c was built"); |
| 1407 | |
| 1408 | // A rev change re-renders just that item, in place. |
| 1409 | draw([{ id: "a" }, { id: "b", rev: 1 }, { id: "c" }]); |
| 1410 | assert.equal(el.children[0], nodeA, "a still reused"); |
| 1411 | assert.equal(el.children[1].label, "b:1", "b re-rendered"); |
| 1412 | assert.equal(built, 4); |
| 1413 | |
| 1414 | // Removal drops the node; order holds. |
| 1415 | draw([{ id: "a" }, { id: "c" }]); |
| 1416 | assert.deepEqual(el.children.map((n) => n.label), ["a:0", "c:0"]); |
| 1417 | }); |
| 1418 | |
| 1419 | await test("keyedList patches a retained node in place when only its soft rev changed", () => { |
| 1420 | const el = fakeContainer(); |
| 1421 | let built = 0; |
| 1422 | const patched = []; |
| 1423 | const draw = (items) => |
| 1424 | keyedList(el, items, { |
| 1425 | key: (i) => i.id, |
| 1426 | rev: (i) => i.rev || 0, |
| 1427 | srev: (i) => i.srev || 0, |
| 1428 | render: (i) => (built++, fakeNode(i.id)), |
| 1429 | update: (node, i) => patched.push([node.label, i.srev]), |
| 1430 | }); |
| 1431 | |
| 1432 | draw([{ id: "a", srev: 0 }]); |
| 1433 | const nodeA = el.children[0]; |
| 1434 | assert.equal(built, 1); |
| 1435 | |
| 1436 | // A soft-rev change patches the SAME node — no rebuild (a loaded image survives). |
| 1437 | draw([{ id: "a", srev: 1 }]); |
| 1438 | assert.equal(el.children[0], nodeA, "node reused, not replaced"); |
| 1439 | assert.equal(built, 1, "nothing rebuilt"); |
| 1440 | assert.deepEqual(patched, [["a", 1]], "update() ran once with the new item"); |
| 1441 | |
| 1442 | // A hard-rev change still rebuilds — and does NOT also fire update(). |
| 1443 | draw([{ id: "a", rev: 1, srev: 2 }]); |
| 1444 | assert.notEqual(el.children[0], nodeA, "rebuilt on the hard rev"); |
| 1445 | assert.equal(built, 2); |
| 1446 | assert.deepEqual(patched, [["a", 1]], "update() not called on a rebuild"); |
| 1447 | |
| 1448 | // No change at all → neither a rebuild nor a patch. |
| 1449 | draw([{ id: "a", rev: 1, srev: 2 }]); |
| 1450 | assert.equal(built, 2); |
| 1451 | assert.deepEqual(patched, [["a", 1]]); |
| 1452 | }); |
| 1453 | |
| 1454 | // ---- app-contributed message actions ------------------------------------ |
| 1455 | |
| 1456 | await test("messageActionsFor offers Save link only on messages with a link", () => { |
| 1457 | assert.deepEqual(messageActionsFor({ text: "no links here", from: "Bob" }), []); |
| 1458 | const acts = messageActionsFor({ text: "look at https://x.com now", from: "Bob" }); |
| 1459 | assert.equal(acts.length, 1); |
| 1460 | assert.equal(acts[0].label, "Save link"); |
| 1461 | }); |
| 1462 | |
| 1463 | await test("Save link routes into the bookmarks app-thread with attribution", async () => { |
| 1464 | const sent = []; |
| 1465 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 1466 | const acts = messageActionsFor({ text: "read https://x.com/post today", from: "Bob" }); |
| 1467 | await acts[0].run(); // resolves the app-thread (stubbed → "todoThread") and emits |
| 1468 | assert.equal(sent.length, 1); |
| 1469 | assert.equal(sent[0].event.type, "bookmark.add"); |
| 1470 | assert.equal(sent[0].event.url, "https://x.com/post"); |
| 1471 | assert.equal(sent[0].event.from, "Bob", "records who posted the link"); |
| 1472 | }); |
| 1473 | |
| 1474 | // ---- DNSimple companion app --------------------------------------------- |
| 1475 | |
| 1476 | const dnsimpleReduce = dnsimpleApp.reduce; |
| 1477 | |
| 1478 | await test("dnsimple: a fetched list replaces state; normalises + drops malformed rows", () => { |
| 1479 | const s0 = dnsimpleApp.initialState(); |
| 1480 | assert.deepEqual(s0.domains, []); |
| 1481 | // Structured rows (name + table fields) are stored as-is. |
| 1482 | const rows = [ |
| 1483 | { name: "a.com", expires_on: "2027-01-01", auto_renew: true }, |
| 1484 | { name: "b.net", expires_on: "", auto_renew: false }, |
| 1485 | ]; |
| 1486 | const s1 = dnsimpleReduce(s0, { type: "dnsimple.domains", domains: rows, ts: 100 }); |
| 1487 | assert.deepEqual(s1.domains.map((d) => d.name), ["a.com", "b.net"]); |
| 1488 | assert.equal(s1.domains[0].auto_renew, true, "table fields survive the fold"); |
| 1489 | assert.equal(s1.fetchedAt, 100); |
| 1490 | // The latest refresh WINS (replace, not append) — dropped domains disappear. |
| 1491 | const s2 = dnsimpleReduce(s1, { type: "dnsimple.domains", domains: [{ name: "a.com" }], ts: 200 }); |
| 1492 | assert.deepEqual(s2.domains.map((d) => d.name), ["a.com"]); |
| 1493 | assert.equal(s2.fetchedAt, 200); |
| 1494 | // Old string form is normalised to objects (back-compat with pre-table events). |
| 1495 | const s3 = dnsimpleReduce(s2, { type: "dnsimple.domains", domains: ["x.com", "y.io"], ts: 300 }); |
| 1496 | assert.deepEqual(s3.domains.map((d) => d.name), ["x.com", "y.io"]); |
| 1497 | // Hostile / malformed inputs fold to a no-op or drop bad entries (never crash). |
| 1498 | assert.equal(dnsimpleReduce(s3, { type: "dnsimple.domains" }), s3, "missing domains → unchanged"); |
| 1499 | assert.equal(dnsimpleReduce(s3, { type: "dnsimple.domains", domains: "nope", ts: 400 }), s3, "non-array → unchanged"); |
| 1500 | const s4 = dnsimpleReduce(s3, { type: "dnsimple.domains", domains: [{ name: "ok.com" }, 42, null, {}], ts: 500 }); |
| 1501 | assert.deepEqual(s4.domains.map((d) => d.name), ["ok.com"], "entries without a string name are dropped"); |
| 1502 | }); |
| 1503 | |
| 1504 | await test("dnsimple: takeover app — the table is the whole thread, no chat echo", () => { |
| 1505 | // No chatMessage → takeover (Interface only), so a fetched list never echoes as |
| 1506 | // a chat bubble. The domain list lives in the table, nowhere else. |
| 1507 | assert.equal(dnsimpleApp.chatMessage, undefined, "no chatMessage hook (takeover, not companion)"); |
| 1508 | assert.equal(appWantsChatView("dnsimple"), false, "no chatMessage → not a companion (takeover)"); |
| 1509 | assert.equal(dnsimpleApp.requiresService, "dnsimple", "gated on the connected service"); |
| 1510 | }); |
| 1511 | |
| 1512 | await test("dnsimple: registers no slash command (the Interface IS the list)", () => { |
| 1513 | // No `/list` (or any) command — the domains live in the app's own view, so a |
| 1514 | // verb would be redundant and only risks routing through (and waking) the bot. |
| 1515 | assert.deepEqual(Object.keys(dnsimpleApp.commands || {}), []); |
| 1516 | assert.equal(parseCommand("/list").app, null, "nothing owns /list"); |
| 1517 | }); |
| 1518 | |
| 1519 | await test("setup: steps fold idempotently and hostile events are no-ops", () => { |
| 1520 | const r = setupApp.reduce; |
| 1521 | let s = r(undefined, { type: "setup.step", step: "group", label: "named this space “C”", value: "C" }); |
| 1522 | assert.equal(s.steps.group.done, true); |
| 1523 | assert.equal(s.steps.group.value, "C"); |
| 1524 | const again = r(s, { type: "setup.step", step: "group", label: "named this space “C”", value: "C" }); |
| 1525 | assert.equal(again.steps.group.done, true, "refold converges"); |
| 1526 | assert.equal(r(s, { type: "setup.step" }), s, "missing step → unchanged"); |
| 1527 | const two = r(s, { type: "setup.step", step: "keys", label: "skipped the key backup", value: "skipped" }); |
| 1528 | assert.equal(two.steps.group.done, true, "earlier steps survive"); |
| 1529 | assert.equal(two.steps.keys.value, "skipped"); |
| 1530 | }); |
| 1531 | |
| 1532 | await test("setup: chat-only projection — completed steps become action lines", () => { |
| 1533 | assert.equal(appWantsChatView("setup"), true, "chatMessage → the chat view is the surface"); |
| 1534 | assert.equal(appChatOnly("setup"), true, "no Interface view or toggle — the conversation is the thread"); |
| 1535 | assert.equal(setupApp.render, undefined, "the progress card is gone (the Admin panel superseded it)"); |
| 1536 | const line = setupApp.chatMessage({ type: "setup.step", step: "name", label: "noted: you're Paul" }); |
| 1537 | assert.deepEqual(line, { text: "noted: you're Paul", action: true }); |
| 1538 | assert.equal(setupApp.chatMessage({ type: "setup.step", step: "x" }), null, "no label → silent"); |
| 1539 | assert.equal(setupApp.chatMessage({ type: "other" }), null); |
| 1540 | assert.equal(setupApp.listed, false, "never offered in the Apps menu"); |
| 1541 | }); |
| 1542 | |
| 1543 | await test("admin: interface-first companion with a read-only update stream", () => { |
| 1544 | assert.equal(adminApp.listed, false, "never offered in the Apps menu"); |
| 1545 | // The panel leads: a companion (chatMessage → Chat view exists) that opens in |
| 1546 | // its Interface, with the update stream one toggle away — and read-only there. |
| 1547 | assert.equal(appWantsChatView("admin"), true, "chatMessage → companion (the update stream is the Chat view)"); |
| 1548 | assert.equal(appChatOnly("admin"), false, "the Interface (settings panel) is a real view"); |
| 1549 | assert.equal(appDefaultView("admin"), "interface", "opens as the app, not the split"); |
| 1550 | assert.equal(appDefaultView("todo"), "split", "companions without a preference keep the split default"); |
| 1551 | assert.equal(adminApp.chatReadOnly, true, "the app posts into the stream; the admin doesn't"); |
| 1552 | assert.equal(adminApp.senderKind, undefined, "Admin reads as an APP; only setup overrides to BOT"); |
| 1553 | assert.equal(setupApp.senderKind, "bot", "the setup thread presents as Eleven (BOT), not APP"); |
| 1554 | }); |
| 1555 | |
| 1556 | await test("admin: release notes fold as a seen-watermark and project as Admin bubbles", () => { |
| 1557 | const r = adminApp.reduce; |
| 1558 | // A posted note records its id; re-folding the same id is a no-op (two devices |
| 1559 | // may race the same entry). |
| 1560 | let s = r(undefined, { type: "admin.note", id: "2026-07-08-read-sync", text: "Unread dots now sync." }); |
| 1561 | assert.deepEqual(s.seen, ["2026-07-08-read-sync"]); |
| 1562 | assert.equal(r(s, { type: "admin.note", id: "2026-07-08-read-sync", text: "dup" }), s, "duplicate id → unchanged"); |
| 1563 | assert.equal(r(s, { type: "admin.note", text: "no id" }), s, "id-less note → unchanged"); |
| 1564 | // First-run baseline: everything current marked seen, and merges are a union. |
| 1565 | s = r(undefined, { type: "admin.baseline", ids: ["a", "b"] }); |
| 1566 | assert.deepEqual(s.seen, ["a", "b"]); |
| 1567 | assert.deepEqual(r(s, { type: "admin.baseline", ids: ["b", "c"] }).seen, ["a", "b", "c"], "second baseline merges"); |
| 1568 | // Projections: a note is a bubble from Admin (a string, not an action line); |
| 1569 | // the baseline is silent housekeeping. |
| 1570 | assert.equal(adminApp.chatMessage({ type: "admin.note", id: "x", text: "Shipped a thing." }), "Shipped a thing."); |
| 1571 | assert.equal(adminApp.chatMessage({ type: "admin.note", id: "x" }), null, "text-less note → silent"); |
| 1572 | assert.equal(adminApp.chatMessage({ type: "admin.baseline", ids: ["a"] }), null); |
| 1573 | }); |
| 1574 | |
| 1575 | await test("welcome: the joiner conversation mirrors setup's shape", () => { |
| 1576 | const r = welcomeApp.reduce; |
| 1577 | const s = r(undefined, { type: "welcome.step", step: "name", label: "noted: you're Ada", value: "Ada" }); |
| 1578 | assert.equal(s.steps.name.done, true); |
| 1579 | assert.equal(r(s, { type: "welcome.step" }), s, "missing step → unchanged"); |
| 1580 | assert.equal(appWantsChatView("welcome"), true); |
| 1581 | assert.equal(appChatOnly("welcome"), true, "conversation-only, no Interface"); |
| 1582 | assert.equal(welcomeApp.senderKind, "bot", "presents as Eleven (BOT) in the sidebar"); |
| 1583 | assert.equal(welcomeApp.listed, false, "never offered in the Apps menu"); |
| 1584 | const line = welcomeApp.chatMessage({ type: "welcome.step", step: "name", label: "noted: you're Ada" }); |
| 1585 | assert.deepEqual(line, { text: "noted: you're Ada", action: true }); |
| 1586 | }); |
| 1587 | |
| 1588 | await test("conversational answers: one waiter per thread, consumed on delivery", () => { |
| 1589 | const got = []; |
| 1590 | awaitAnswer("t1", (text) => got.push(text)); |
| 1591 | assert.equal(deliverAnswer("t1", "Paul", () => {}), true, "waiter consumes the answer"); |
| 1592 | assert.deepEqual(got, ["Paul"]); |
| 1593 | assert.equal(deliverAnswer("t1", "again", () => {}), false, "consumed — nothing listening"); |
| 1594 | awaitAnswer("t2", () => {}); |
| 1595 | cancelAnswer("t2"); |
| 1596 | assert.equal(deliverAnswer("t2", "x", () => {}), false, "cancelled waiter never fires"); |
| 1597 | }); |
| 1598 | |
| 1599 | await test("conversational answers: every cancel fires the cancelled hook (#1375)", () => { |
| 1600 | // chat.js registers this to clear the labelled composer — the dismiss X, a |
| 1601 | // chip's cancelInput, and the folded re-derive all ride the same path. |
| 1602 | const cleared = []; |
| 1603 | setAnswerCancelledHook((threadId) => cleared.push(threadId)); |
| 1604 | awaitAnswer("t9", () => {}); |
| 1605 | cancelAnswer("t9"); |
| 1606 | cancelAnswer("t-idle"); // no waiter parked — the label still clears |
| 1607 | assert.deepEqual(cleared, ["t9", "t-idle"]); |
| 1608 | setAnswerCancelledHook(null); |
| 1609 | }); |
| 1610 | |
| 1611 | await test("conversational answers: attachments ride the payload (the photo step)", () => { |
| 1612 | const got = []; |
| 1613 | awaitAnswer("t3", (text, payload) => got.push([text, ((payload && payload.attachments) || []).length])); |
| 1614 | deliverAnswer("t3", { text: "", attachments: [{ id: "b1", type: "image/jpeg" }] }, () => {}); |
| 1615 | assert.deepEqual(got, [["", 1]], "an attachment-only send still answers the waiter"); |
| 1616 | }); |
| 1617 | |
| 1618 | // ---- group-scoped apps (groupapps registry + @-mention routing) ------------ |
| 1619 | |
| 1620 | const groupAppsReduce = groupAppsApp.reduce; |
| 1621 | |
| 1622 | await test("groupapps: adds fold idempotently, removes drop, malformed no-op", () => { |
| 1623 | let s = groupAppsReduce(undefined, { type: "groupapps.add", app: "todo", name: "Ana", ts: 5 }); |
| 1624 | assert.deepEqual(s, { apps: [{ id: "todo", by: "Ana", ts: 5 }] }); |
| 1625 | // Re-adding (another member, a refold) is a no-op — the item id IS the app name. |
| 1626 | const s2 = groupAppsReduce(s, { type: "groupapps.add", app: "todo", name: "Bob", ts: 9 }); |
| 1627 | assert.deepEqual(s2.apps, s.apps); |
| 1628 | // Unknown app names still fold (state converges across client versions). |
| 1629 | s = groupAppsReduce(s, { type: "groupapps.add", app: "flarp", name: "Bob", ts: 9 }); |
| 1630 | assert.equal(s.apps.length, 2); |
| 1631 | s = groupAppsReduce(s, { type: "groupapps.remove", app: "todo" }); |
| 1632 | assert.deepEqual(s.apps.map((a) => a.id), ["flarp"]); |
| 1633 | // Hostile / malformed events are no-ops, never a crash (Invariant #8). |
| 1634 | assert.equal(groupAppsReduce(s, { type: "groupapps.add" }), s); |
| 1635 | assert.equal(groupAppsReduce(s, { type: "groupapps.add", app: 7 }), s); |
| 1636 | assert.deepEqual(groupAppsReduce(s, { type: "groupapps.remove", app: "missing" }).apps, s.apps); |
| 1637 | }); |
| 1638 | |
| 1639 | await test("groupapps: add/remove project as action lines; previews name the actor", () => { |
| 1640 | // groupFeedMessage, not chatMessage — the registry narrates group feeds |
| 1641 | // and must never read as wanting a Chat view of its own. |
| 1642 | assert.deepEqual(groupAppsApp.groupFeedMessage({ type: "groupapps.add", app: "todo" }), { text: "added To-do to this chat", action: true }); |
| 1643 | assert.deepEqual(groupAppsApp.groupFeedMessage({ type: "groupapps.remove", app: "todo" }), { text: "removed To-do from this chat", action: true }); |
| 1644 | // An app this client doesn't know still reads sensibly. |
| 1645 | assert.equal(groupAppsApp.groupFeedMessage({ type: "groupapps.add", app: "flarp" }).text, "added flarp to this chat"); |
| 1646 | assert.equal(appPreview({ type: "groupapps.add", app: "todo", name: "Ana" }), "Ana added To-do"); |
| 1647 | assert.equal(appPreview({ type: "groupapps.remove", app: "todo", name: "Ana" }), "Ana removed To-do"); |
| 1648 | }); |
| 1649 | |
| 1650 | await test("groupAppsFor resolves added apps, filtering unknown and non-group-capable", () => { |
| 1651 | resetThread("grp1"); |
| 1652 | foldEvent("grp1", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 }); |
| 1653 | foldEvent("grp1", { type: "groupapps.add", app: "flarp", name: "Ana" }, { id: 2 }); // unknown → inert |
| 1654 | foldEvent("grp1", { type: "groupapps.add", app: "connect", name: "Ana" }, { id: 3 }); // not groupCapable → inert |
| 1655 | assert.deepEqual(groupAppsFor("grp1").map((a) => a.name), ["todo"]); |
| 1656 | foldEvent("grp1", { type: "groupapps.remove", app: "todo" }, { id: 4 }); |
| 1657 | assert.deepEqual(groupAppsFor("grp1"), []); |
| 1658 | resetThread("grp1"); |
| 1659 | }); |
| 1660 | |
| 1661 | await test("the group-capable roster: shared-list apps in, credential-backed apps out", () => { |
| 1662 | const names = groupCapableApps().map((a) => a.name); |
| 1663 | for (const n of ["todo", "remind", "bookmarks"]) assert.ok(names.includes(n), n + " is addable to groups"); |
| 1664 | // Their state derives from one member's private service credentials — sharing |
| 1665 | // those needs its own design, so they stay personal-only on purpose. |
| 1666 | for (const n of ["connect", "dnsimple"]) assert.ok(!names.includes(n), n + " stays personal-only"); |
| 1667 | // Poll/Whiteboard are chat-place apps since the ambient panel retired (#405). |
| 1668 | for (const n of ["poll", "board"]) assert.ok(names.includes(n), n + " lives in chats now"); |
| 1669 | }); |
| 1670 | |
| 1671 | await test("Reminders' fire bubble drops 'you asked me' in a group", () => { |
| 1672 | const e = { type: "remind.fire", id: "r1", notice: { title: "⏰ Reminder", body: "call mum" } }; |
| 1673 | assert.equal(remindApp.chatMessage(e), "You asked me to remind you: “call mum”"); |
| 1674 | assert.equal(remindApp.chatMessage(e, { group: true }), "Reminder: “call mum”"); |
| 1675 | // The add action line is host-prefixed with the actor either way. |
| 1676 | const add = remindApp.chatMessage({ type: "remind.add", id: "r1", text: "call mum", when: 0 }, { group: true }); |
| 1677 | assert.equal(add.action, true); |
| 1678 | }); |
| 1679 | |
| 1680 | await test("parseAppMention matches an added app by title or name, leading-@ only", () => { |
| 1681 | resetThread("grp2"); |
| 1682 | foldEvent("grp2", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 }); |
| 1683 | let m = parseAppMention("@To-do buy milk", "grp2"); |
| 1684 | assert.equal(m.app.name, "todo"); |
| 1685 | assert.equal(m.args, "buy milk"); |
| 1686 | m = parseAppMention("@to-DO buy milk", "grp2"); // case-insensitive |
| 1687 | assert.equal(m.args, "buy milk"); |
| 1688 | m = parseAppMention("@todo buy milk", "grp2"); // the app name works too |
| 1689 | assert.equal(m.args, "buy milk"); |
| 1690 | assert.equal(parseAppMention("@To-do", "grp2").args, "", "a bare mention parses with empty args"); |
| 1691 | // Must break on whitespace — "@To-dos…" is conversation, not an address. |
| 1692 | assert.equal(parseAppMention("@To-dos are great", "grp2"), null); |
| 1693 | assert.equal(parseAppMention("hey @To-do buy milk", "grp2"), null, "leading-@ only"); |
| 1694 | assert.equal(parseAppMention("@To-do buy milk", "grpEmpty"), null, "no apps added → no match"); |
| 1695 | resetThread("grp2"); |
| 1696 | }); |
| 1697 | |
| 1698 | await test("runAppMention emits the default command's event into the GROUP thread", async () => { |
| 1699 | resetThread("grp3"); |
| 1700 | foldEvent("grp3", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 }); |
| 1701 | const sent = []; |
| 1702 | const replies = []; |
| 1703 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 1704 | // Resolver poisoned: a group mention must NOT route to the personal app-thread. |
| 1705 | setAppThreadResolver(async () => { |
| 1706 | throw new Error("group mentions never route"); |
| 1707 | }); |
| 1708 | assert.equal(runAppMention("@To-do buy milk", "grp3", (r) => replies.push(r)), true); |
| 1709 | await tick(); |
| 1710 | setAppThreadResolver(async () => "todoThread"); // restore for later tests |
| 1711 | assert.equal(sent.length, 1); |
| 1712 | assert.equal(sent[0].threadId, "grp3", "the event lands in the group thread"); |
| 1713 | assert.equal(sent[0].event.type, "todo.add"); |
| 1714 | assert.equal(sent[0].event.text, "buy milk"); |
| 1715 | // The success confirmation is suppressed — the folded action line is the record. |
| 1716 | assert.deepEqual(replies, []); |
| 1717 | resetThread("grp3"); |
| 1718 | }); |
| 1719 | |
| 1720 | await test("/todo typed in a chat with To-do ADDED targets the GROUP instance", async () => { |
| 1721 | resetThread("grp5"); |
| 1722 | foldEvent("grp5", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 }); |
| 1723 | const sent = []; |
| 1724 | const replies = []; |
| 1725 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 1726 | // Resolver poisoned: with the app added here, /todo must NOT open a personal |
| 1727 | // app-thread — the group's shared instance is the nearer scope. |
| 1728 | setAppThreadResolver(async () => { |
| 1729 | throw new Error("group-scoped commands never route to the personal thread"); |
| 1730 | }); |
| 1731 | assert.equal(runCommand("/todo group milk", "grp5", (r) => replies.push(r)), true); |
| 1732 | await tick(); |
| 1733 | assert.equal(sent.length, 1); |
| 1734 | assert.equal(sent[0].threadId, "grp5", "the event lands in the group thread"); |
| 1735 | assert.equal(sent[0].event.type, "todo.add"); |
| 1736 | assert.equal(sent[0].event.text, "group milk"); |
| 1737 | // In-thread the folded action line is the record — no separate confirmation. |
| 1738 | assert.deepEqual(replies, []); |
| 1739 | setAppThreadResolver(async () => "todoThread"); // restore for later tests |
| 1740 | // Without the app added (any other chat), /todo still routes personally. |
| 1741 | const sent2 = []; |
| 1742 | setEmitTransport((threadId, event) => (sent2.push({ threadId, event }), Promise.resolve(true))); |
| 1743 | assert.equal(runCommand("/todo personal milk", "chatX"), true); |
| 1744 | await tick(); |
| 1745 | assert.equal(sent2[0].threadId, "todoThread"); |
| 1746 | resetThread("grp5"); |
| 1747 | }); |
| 1748 | |
| 1749 | await test("parseHere: leading @here only, breaking on whitespace", () => { |
| 1750 | assert.deepEqual(parseHere("@here buy milk"), { rest: "buy milk" }); |
| 1751 | assert.deepEqual(parseHere("@here"), { rest: "" }); |
| 1752 | assert.deepEqual(parseHere(" @here buy milk "), { rest: "buy milk" }); |
| 1753 | assert.equal(parseHere("@heresy is text"), null); |
| 1754 | assert.equal(parseHere("buy milk @here"), null); |
| 1755 | assert.equal(parseHere(""), null); |
| 1756 | }); |
| 1757 | |
| 1758 | await test("parseMeFlag: leading @me only, breaking on whitespace", () => { |
| 1759 | assert.deepEqual(parseMeFlag("@me dentist"), { rest: "dentist" }); |
| 1760 | assert.deepEqual(parseMeFlag("@me"), { rest: "" }); |
| 1761 | assert.deepEqual(parseMeFlag(" @me dentist "), { rest: "dentist" }); |
| 1762 | assert.equal(parseMeFlag("@mention is text"), null); |
| 1763 | assert.equal(parseMeFlag("dentist @me"), null); |
| 1764 | assert.equal(parseMeFlag(""), null); |
| 1765 | }); |
| 1766 | |
| 1767 | await test("parseEveryone accepts @space as an alias", () => { |
| 1768 | assert.deepEqual(parseEveryone("@space tada"), { rest: "tada" }); |
| 1769 | assert.deepEqual(parseEveryone("@space"), { rest: "" }); |
| 1770 | assert.equal(parseEveryone("@spacecraft launch"), null); |
| 1771 | }); |
| 1772 | |
| 1773 | await test("/todo @me files personally even where the chat copy claims the bare verb", async () => { |
| 1774 | resetThread("grpMe"); |
| 1775 | setThreadIsGroup((id) => id === "grpMe"); |
| 1776 | setThreadOwner((id) => (id === "todoThread" ? "todo" : null)); |
| 1777 | foldEvent("grpMe", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 }); |
| 1778 | const sent = []; |
| 1779 | const replies = []; |
| 1780 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 1781 | // The bare verb goes to the chat copy… |
| 1782 | assert.equal(runCommand("/todo team snacks", "grpMe", (r) => replies.push(r)), true); |
| 1783 | await tick(); |
| 1784 | assert.equal(sent[0].threadId, "grpMe"); |
| 1785 | // …and @me overrides toward privacy. |
| 1786 | assert.equal(runCommand("/todo @me dentist", "grpMe", (r) => replies.push(r)), true); |
| 1787 | await tick(); |
| 1788 | assert.equal(sent[1].threadId, "todoThread", "@me reaches the personal copy past the chat claim"); |
| 1789 | assert.equal(sent[1].event.text, "dentist"); |
| 1790 | const mine = replies[replies.length - 1]; |
| 1791 | assert.equal(mine.place, "you", "the routed reply is stamped with the personal place"); |
| 1792 | assert.match(mine.text, /your to-do list/); |
| 1793 | setThreadOwner((id) => null); |
| 1794 | setThreadIsGroup(null); |
| 1795 | resetThread("grpMe"); |
| 1796 | }); |
| 1797 | |
| 1798 | await test("@me for an app with no personal copy explains instead of mis-filing", async () => { |
| 1799 | const sent = []; |
| 1800 | const replies = []; |
| 1801 | setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); |
| 1802 | assert.equal(runCommand("/poll @me free pizza?", "chatX", (r) => replies.push(r)), true); |
| 1803 | await tick(); |
| 1804 | assert.equal(sent.length, 0, "nothing emitted — the text is not silently filed anywhere"); |
| 1805 | assert.match(replies[0].text, /needs an audience/, "the app's own placeNote is the explanation"); |
| 1806 | }); |
| 1807 | |
| 1808 | await test("@here on a chat-only app is stripped, never folded into the command's content", async () => { |
| 1809 | resetThread("grpHere"); |
| 1810 | setThreadIsGroup((id) => id === "grpHere"); |
| 1811 | const sent = []; |
| 1812 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 1813 | assert.equal(runCommand("/poll @here Pizza?", "grpHere", () => {}), true); |
| 1814 | await tick(); |
| 1815 | // The poll.create carries "Pizza?", not "@here Pizza?" — the redundant flag |
| 1816 | // (poll auto-adopts anyway) is removed, not baked into the title. |
| 1817 | const create = sent.find((s) => s.event.type === "poll.create"); |
| 1818 | assert.ok(create, "the poll was created"); |
| 1819 | assert.equal(create.event.q, "Pizza?", "@here stripped from the poll question"); |
| 1820 | setThreadIsGroup(null); |
| 1821 | resetThread("grpHere"); |
| 1822 | }); |
| 1823 | |
| 1824 | // A synthetic all-places fixture (you+chat+space) for the flag/alternate and |
| 1825 | // space-routing machinery below: the SHIPPED apps are parked at you+chat |
| 1826 | // while the places UI is hidden (docs/space-apps.md § The pullback), so a |
| 1827 | // stand-in keeps the space paths covered — they must still work the day an |
| 1828 | // app adds "space" back to its places line. |
| 1829 | const triple = defineApp({ |
| 1830 | name: "triple", |
| 1831 | title: "Triple", |
| 1832 | glyph: "▲", |
| 1833 | places: ["you", "chat", "space"], |
| 1834 | initialState: () => ({ items: [] }), |
| 1835 | events: { |
| 1836 | "triple.add": (s, e) => (e.id && e.text ? { items: [...s.items, { id: e.id, text: e.text }] } : s), |
| 1837 | }, |
| 1838 | commands: { |
| 1839 | "/triple": { |
| 1840 | desc: "Add a triple", |
| 1841 | args: "<text>", |
| 1842 | run: (args, ctx) => { |
| 1843 | if (!args) return ctx.reply({ text: "Usage: /triple <text>" }); |
| 1844 | ctx.emit({ type: "triple.add", id: ctx.id(), text: args }); |
| 1845 | // Words the destination via ctx.place, like todo does in production — |
| 1846 | // the routed-reply test asserts both the app wording and the stamp. |
| 1847 | const dest = ctx.place === "space" ? "the space triple list" : ctx.place === "chat" ? "this chat's triple list" : "your triple list"; |
| 1848 | ctx.reply({ text: `Added to ${dest}.`, short: "added", actions: [{ label: "View Triple", run: ctx.open }] }); |
| 1849 | }, |
| 1850 | }, |
| 1851 | }, |
| 1852 | render() {}, |
| 1853 | }); |
| 1854 | |
| 1855 | await test("the / catalog offers flagged alternates when several copies are reachable", async () => { |
| 1856 | resetThread("grpAlt"); |
| 1857 | resetThread("spaceT"); |
| 1858 | setThreadIsGroup((id) => id === "grpAlt"); |
| 1859 | setSpaceThread(() => "spaceT"); |
| 1860 | foldEvent("grpAlt", { type: "groupapps.add", app: "triple", name: "Ana" }, { id: 1 }); |
| 1861 | foldEvent("spaceT", { type: "spaceapps.add", app: "triple", name: "Ana" }, { id: 1 }); |
| 1862 | const cat = commandCatalog("grpAlt"); |
| 1863 | const names = cat.map((c) => c.name); |
| 1864 | assert.ok(names.includes("/triple"), "the bare verb (the default route) leads"); |
| 1865 | assert.equal(cat.find((c) => c.name === "/triple").place, "chat"); |
| 1866 | const everyoneAlt = cat.find((c) => c.name === `/triple ${EVERYONE_FLAG}`); |
| 1867 | assert.ok(everyoneAlt && everyoneAlt.alt && everyoneAlt.place === "space", "the space copy rides as a flagged alternate"); |
| 1868 | assert.equal(everyoneAlt.desc, "Add a triple for this space", "an existing copy gets the addressing words — no install language"); |
| 1869 | const meAlt = cat.find((c) => c.name === `/triple ${ME_FLAG}`); |
| 1870 | assert.ok(meAlt && meAlt.alt && meAlt.place === "you", "the personal place rides as a flagged alternate"); |
| 1871 | assert.match(meAlt.desc, /just for you$/); |
| 1872 | // /help lists each verb once and teaches the flags in its closing sentence. |
| 1873 | const replies = []; |
| 1874 | assert.equal(runCommand("/help", "grpAlt", (r) => replies.push(r)), true); |
| 1875 | await tick(); |
| 1876 | // One list line per verb (alt rows filtered): the desc appears once even |
| 1877 | // though the closing sentence itself demos "/todo @me". |
| 1878 | assert.equal((replies[0].text.match(/Add a triple/g) || []).length, 1, "alt rows stay out of the /help list"); |
| 1879 | assert.ok(replies[0].text.includes(`${ME_FLAG}, ${HERE_FLAG}, or ${EVERYONE_FLAG}`), "/help teaches the canonical spellings"); |
| 1880 | setThreadIsGroup(null); |
| 1881 | setSpaceThread(null); |
| 1882 | resetThread("grpAlt"); |
| 1883 | resetThread("spaceT"); |
| 1884 | // With no nearer claim anywhere, no ADDRESSING alternates — the bare verb |
| 1885 | // already files personally, so @me would be a no-op row. (The fresh case |
| 1886 | // still offers the INSTALL rows — the next test.) |
| 1887 | assert.ok(!commandCatalog("chatX").some((c) => c.name === `/triple ${ME_FLAG}`), "no @me alternate without a nearer claim"); |
| 1888 | }); |
| 1889 | |
| 1890 | await test("the / catalog offers install rows for the fresh case — the whole flag family, not a suffix", () => { |
| 1891 | resetThread("grpF"); |
| 1892 | setThreadIsGroup((id) => id === "grpF"); |
| 1893 | const cat = commandCatalog("grpF"); |
| 1894 | const here = cat.find((c) => c.name === `/triple ${HERE_FLAG}`); |
| 1895 | assert.ok(here && here.alt && here.place === "chat", "@here rides as an install offer in a group"); |
| 1896 | assert.equal(here.desc, "Add a triple to a new Triple app for this group", "an install row names the new app it creates"); |
| 1897 | const everyone = cat.find((c) => c.name === `/triple ${EVERYONE_FLAG}`); |
| 1898 | assert.ok(everyone && everyone.alt && everyone.place === "space", "@everyone rides as an install offer"); |
| 1899 | assert.equal(everyone.desc, "Add a triple to a new Triple app for this space"); |
| 1900 | // sortPlace carries the BARE verb's place, so the typeahead's nearness sort |
| 1901 | // never ranks an install offer above the route the bare verb takes. |
| 1902 | assert.equal(here.sortPlace, "you"); |
| 1903 | assert.equal(everyone.sortPlace, "you"); |
| 1904 | // A parked (you+chat) app never offers @everyone — the pullback's whole |
| 1905 | // point: the flag rows follow the places declaration, no UI enforcement. |
| 1906 | assert.ok(!cat.some((c) => c.name === `/todo ${EVERYONE_FLAG}`), "no @everyone offer for a you+chat app"); |
| 1907 | assert.ok(cat.some((c) => c.name === `/todo ${HERE_FLAG}`), "the chat offer stays for a you+chat app"); |
| 1908 | // Outside a group @here would only refuse — but @everyone works from any chat. |
| 1909 | const dm = commandCatalog("chatX"); |
| 1910 | assert.ok(!dm.some((c) => c.name === `/triple ${HERE_FLAG}`), "@here only offered in a group"); |
| 1911 | assert.ok(dm.some((c) => c.name === `/triple ${EVERYONE_FLAG}`), "@everyone offered from any chat"); |
| 1912 | // A member the policy bars from space installs isn't offered a row that |
| 1913 | // would only refuse. |
| 1914 | setSpaceInstallPolicy(() => ({ adminsOnly: true, canInstall: false, adderOK: () => false })); |
| 1915 | assert.ok(!commandCatalog("grpF").some((c) => c.name === `/triple ${EVERYONE_FLAG}`), "no @everyone offer when installs are admins-only"); |
| 1916 | setSpaceInstallPolicy(null); |
| 1917 | setThreadIsGroup(null); |
| 1918 | resetThread("grpF"); |
| 1919 | }); |
| 1920 | |
| 1921 | await test("/todo @here installs the app into the group AND files the item there", async () => { |
| 1922 | resetThread("grp6"); |
| 1923 | setThreadIsGroup((id) => id === "grp6"); |
| 1924 | const sent = []; |
| 1925 | const replies = []; |
| 1926 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 1927 | setAppThreadResolver(async () => { |
| 1928 | throw new Error("@here never routes to the personal thread"); |
| 1929 | }); |
| 1930 | assert.equal(runCommand("/todo @here camp chairs", "grp6", (r) => replies.push(r)), true); |
| 1931 | await tick(); |
| 1932 | setAppThreadResolver(async () => "todoThread"); // restore for later tests |
| 1933 | assert.equal(sent.length, 2); |
| 1934 | assert.deepEqual([sent[0].threadId, sent[0].event.type, sent[0].event.app], ["grp6", "groupapps.add", "todo"]); |
| 1935 | assert.deepEqual([sent[1].threadId, sent[1].event.type, sent[1].event.text], ["grp6", "todo.add", "camp chairs"]); |
| 1936 | // In-thread the install + add action lines are the record — no extra reply. |
| 1937 | assert.deepEqual(replies, []); |
| 1938 | // A bare "/todo @here" is JUST the install; already-added → no duplicate add. |
| 1939 | foldEvent("grp6", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 }); |
| 1940 | const sent2 = []; |
| 1941 | setEmitTransport((threadId, event) => (sent2.push(event), Promise.resolve(true))); |
| 1942 | assert.equal(runCommand("/todo @here", "grp6", (r) => replies.push(r)), true); |
| 1943 | await tick(); |
| 1944 | assert.deepEqual(sent2, [], "already added + no text → nothing to emit"); |
| 1945 | setThreadIsGroup(null); |
| 1946 | resetThread("grp6"); |
| 1947 | }); |
| 1948 | |
| 1949 | await test("@here outside a group (or for an unshareable app) explains instead of mis-filing", async () => { |
| 1950 | setThreadIsGroup(() => false); |
| 1951 | const sent = []; |
| 1952 | const replies = []; |
| 1953 | setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); |
| 1954 | assert.equal(runCommand("/todo @here buy milk", "chatX", (r) => replies.push(r)), true); |
| 1955 | await tick(); |
| 1956 | assert.equal(sent.length, 0, "nothing emitted — the text is not silently filed anywhere"); |
| 1957 | assert.match(replies[0].text, /only works in a group chat/); |
| 1958 | setThreadIsGroup(null); |
| 1959 | }); |
| 1960 | |
| 1961 | await test("the / catalog says where a group-capable command lands", () => { |
| 1962 | resetThread("grp7"); |
| 1963 | setThreadIsGroup((id) => id === "grp7"); |
| 1964 | const desc = (list) => list.find((c) => c.name === "/todo").desc; |
| 1965 | // Fresh instance: the bare verb names its place plainly — the sharing hint |
| 1966 | // is no longer a suffix but the flagged install rows (tested below). |
| 1967 | assert.match(desc(commandCatalog("grp7")), /just for you$/); |
| 1968 | foldEvent("grp7", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 }); |
| 1969 | assert.match(desc(commandCatalog("grp7")), /for this group$/); |
| 1970 | // Outside a group too — every app command names where its data lands (the |
| 1971 | // places grammar), reading on from the desc as one sentence. |
| 1972 | assert.equal(desc(commandCatalog("chatX")), "Add a to-do just for you"); |
| 1973 | setThreadIsGroup(null); |
| 1974 | resetThread("grp7"); |
| 1975 | }); |
| 1976 | |
| 1977 | // ---- space-wide apps (spaceapps registry + space routing) ------------------- |
| 1978 | // docs/space-apps.md. A synthetic app stands in for the member-built case (a |
| 1979 | // sticker library): personal + groupCapable + spaceCapable, so the routing |
| 1980 | // precedence — local → this chat's instance → the space instance → personal — |
| 1981 | // is exercised end to end. |
| 1982 | |
| 1983 | const stickers = defineApp({ |
| 1984 | name: "stickers", |
| 1985 | title: "Stickers", |
| 1986 | glyph: "🖼️", |
| 1987 | personal: true, |
| 1988 | groupCapable: true, |
| 1989 | spaceCapable: true, |
| 1990 | initialState: () => ({ items: [] }), |
| 1991 | events: { |
| 1992 | "stickers.add": (s, e) => (e.id && e.text ? { items: addItem(s.items, { id: e.id, text: e.text }) } : s), |
| 1993 | }, |
| 1994 | commands: { |
| 1995 | "/sticker": { |
| 1996 | desc: "Post a sticker", |
| 1997 | args: "<text>", |
| 1998 | run: (args, ctx) => { |
| 1999 | if (args) ctx.emit({ type: "stickers.add", id: ctx.id(), text: args }); |
| 2000 | }, |
| 2001 | }, |
| 2002 | }, |
| 2003 | defaultCommand: "/sticker", |
| 2004 | render() {}, |
| 2005 | }); |
| 2006 | const spaceReduce = spaceAppsApp.reduce; |
| 2007 | |
| 2008 | await test("spaceapps: adds fold idempotently, removes drop, malformed no-op", () => { |
| 2009 | let s = spaceReduce(undefined, { type: "spaceapps.add", app: "stickers", name: "Ana", uid: "uA", ts: 5 }); |
| 2010 | assert.deepEqual(s, { apps: [{ id: "stickers", by: "Ana", uid: "uA", ts: 5 }] }); |
| 2011 | // Re-installing (another member, a refold) is a no-op — the id IS the app name. |
| 2012 | const s2 = spaceReduce(s, { type: "spaceapps.add", app: "stickers", name: "Bob", ts: 9 }); |
| 2013 | assert.deepEqual(s2.apps, s.apps); |
| 2014 | // Unknown app names still fold (state converges across client versions). |
| 2015 | s = spaceReduce(s, { type: "spaceapps.add", app: "flarp", name: "Bob", ts: 9 }); |
| 2016 | assert.equal(s.apps.length, 2); |
| 2017 | s = spaceReduce(s, { type: "spaceapps.remove", app: "stickers" }); |
| 2018 | assert.deepEqual(s.apps.map((a) => a.id), ["flarp"]); |
| 2019 | // Hostile / malformed events are no-ops, never a crash (Invariant #8). |
| 2020 | assert.equal(spaceReduce(s, { type: "spaceapps.add" }), s); |
| 2021 | assert.equal(spaceReduce(s, { type: "spaceapps.add", app: 7 }), s); |
| 2022 | assert.deepEqual(spaceReduce(s, { type: "spaceapps.remove", app: "missing" }).apps, s.apps); |
| 2023 | }); |
| 2024 | |
| 2025 | await test("parseEveryone: leading @everyone only, breaking on whitespace", () => { |
| 2026 | assert.deepEqual(parseEveryone("@everyone tada"), { rest: "tada" }); |
| 2027 | assert.deepEqual(parseEveryone("@everyone"), { rest: "" }); |
| 2028 | assert.deepEqual(parseEveryone(" @everyone tada "), { rest: "tada" }); |
| 2029 | assert.equal(parseEveryone("@everyones party"), null); |
| 2030 | assert.equal(parseEveryone("tada @everyone"), null); |
| 2031 | assert.equal(parseEveryone(""), null); |
| 2032 | }); |
| 2033 | |
| 2034 | await test("spaceAppsFor resolves installs against the registry, filtering unknown and non-space-capable", () => { |
| 2035 | resetThread("spaceT"); |
| 2036 | setSpaceThread(() => "spaceT"); |
| 2037 | foldEvent("spaceT", { type: "spaceapps.add", app: "stickers", name: "Ana", ts: 5 }, { id: 1 }); |
| 2038 | foldEvent("spaceT", { type: "spaceapps.add", app: "flarp", name: "Ana" }, { id: 2 }); // unknown → inert |
| 2039 | foldEvent("spaceT", { type: "spaceapps.add", app: "connect", name: "Ana" }, { id: 3 }); // not spaceCapable → inert |
| 2040 | assert.deepEqual(spaceAppsFor().map((a) => a.name), ["stickers"]); |
| 2041 | assert.deepEqual(spaceAppInfo("stickers"), { by: "Ana", ts: 5 }); |
| 2042 | assert.equal(spaceAppInfo("connect"), null, "an inert install still isn't attributed as one"); |
| 2043 | // No space thread known (fresh boot, key not here yet) → nothing is installed. |
| 2044 | setSpaceThread(null); |
| 2045 | assert.deepEqual(spaceAppsFor(), []); |
| 2046 | setSpaceThread(() => "spaceT"); |
| 2047 | foldEvent("spaceT", { type: "spaceapps.remove", app: "stickers" }, { id: 4 }); |
| 2048 | assert.deepEqual(spaceAppsFor(), []); |
| 2049 | setSpaceThread(null); |
| 2050 | resetThread("spaceT"); |
| 2051 | }); |
| 2052 | |
| 2053 | await test("the space-capable roster: shipped apps parked at you+chat, the stand-ins keep it covered", () => { |
| 2054 | const names = spaceCapableApps().map((a) => a.name); |
| 2055 | assert.ok(names.includes("stickers"), "the synthetic member-app stand-in"); |
| 2056 | assert.ok(names.includes("triple"), "the synthetic all-places stand-in"); |
| 2057 | // Parked while the places UI is hidden (docs/space-apps.md § The pullback) — |
| 2058 | // each is one places-line edit away from returning. |
| 2059 | for (const n of ["todo", "remind", "bookmarks", "vault"]) assert.ok(!names.includes(n), n + " is parked at you+chat"); |
| 2060 | for (const n of ["connect", "dnsimple"]) assert.ok(!names.includes(n), n + " stays personal-only"); |
| 2061 | }); |
| 2062 | |
| 2063 | await test("a space-installed app's command targets the SPACE instance from any chat", async () => { |
| 2064 | resetThread("spaceT"); |
| 2065 | setSpaceThread(() => "spaceT"); |
| 2066 | foldEvent("spaceT", { type: "spaceapps.add", app: "stickers", name: "Ana" }, { id: 1 }); |
| 2067 | const sent = []; |
| 2068 | const replies = []; |
| 2069 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 2070 | // Resolver poisoned: a space-installed app's command must NOT open a |
| 2071 | // personal app-thread — the shared space instance claims the verb. |
| 2072 | setAppThreadResolver(async () => { |
| 2073 | throw new Error("space-scoped commands never route to the personal thread"); |
| 2074 | }); |
| 2075 | assert.equal(runCommand("/sticker high five", "chatX", (r) => replies.push(r)), true); |
| 2076 | await tick(); |
| 2077 | setAppThreadResolver(async () => "todoThread"); // restore for later tests |
| 2078 | assert.equal(sent.length, 1); |
| 2079 | assert.equal(sent[0].threadId, "spaceT", "the event lands in the space thread"); |
| 2080 | assert.equal(sent[0].event.type, "stickers.add"); |
| 2081 | assert.equal(sent[0].event.text, "high five"); |
| 2082 | setSpaceThread(null); |
| 2083 | resetThread("spaceT"); |
| 2084 | }); |
| 2085 | |
| 2086 | await test("a space install never steals the verb inside the app's OWN thread", async () => { |
| 2087 | resetThread("spaceT"); |
| 2088 | setSpaceThread(() => "spaceT"); |
| 2089 | foldEvent("spaceT", { type: "spaceapps.add", app: "stickers", name: "Ana" }, { id: 1 }); |
| 2090 | // Typed inside the stickers app-thread itself, /sticker addresses THAT |
| 2091 | // instance — the one you're looking at — not the space copy. |
| 2092 | setThreadOwner((id) => (id === "stickersThread" ? "stickers" : null)); |
| 2093 | const sent = []; |
| 2094 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 2095 | assert.equal(runCommand("/sticker mine", "stickersThread"), true); |
| 2096 | await tick(); |
| 2097 | assert.equal(sent[0].threadId, "stickersThread", "the own thread wins over the space instance"); |
| 2098 | setThreadOwner((id) => null); |
| 2099 | setSpaceThread(null); |
| 2100 | resetThread("spaceT"); |
| 2101 | }); |
| 2102 | |
| 2103 | await test("a group add is the NEARER scope: it beats the space install in that chat", async () => { |
| 2104 | resetThread("spaceT"); |
| 2105 | resetThread("grp8"); |
| 2106 | setSpaceThread(() => "spaceT"); |
| 2107 | foldEvent("spaceT", { type: "spaceapps.add", app: "stickers", name: "Ana" }, { id: 1 }); |
| 2108 | foldEvent("grp8", { type: "groupapps.add", app: "stickers", name: "Ana" }, { id: 1 }); |
| 2109 | const sent = []; |
| 2110 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 2111 | assert.equal(runCommand("/sticker in the group", "grp8"), true); |
| 2112 | await tick(); |
| 2113 | assert.equal(sent[0].threadId, "grp8", "the chat's own instance wins where it exists"); |
| 2114 | // …and elsewhere the space instance still claims the verb. |
| 2115 | assert.equal(runCommand("/sticker elsewhere", "chatX"), true); |
| 2116 | await tick(); |
| 2117 | assert.equal(sent[1].threadId, "spaceT"); |
| 2118 | setSpaceThread(null); |
| 2119 | resetThread("spaceT"); |
| 2120 | resetThread("grp8"); |
| 2121 | }); |
| 2122 | |
| 2123 | await test("/sticker @everyone installs for the space AND files the trailing text there", async () => { |
| 2124 | resetThread("spaceT"); |
| 2125 | setSpaceThread(() => "spaceT"); |
| 2126 | setSpaceThreadResolver(async () => "spaceT"); |
| 2127 | const sent = []; |
| 2128 | const replies = []; |
| 2129 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 2130 | setAppThreadResolver(async () => { |
| 2131 | throw new Error("@everyone never routes to the personal thread"); |
| 2132 | }); |
| 2133 | assert.equal(runCommand("/sticker @everyone tada", "chatX", (r) => replies.push(r)), true); |
| 2134 | await tick(); |
| 2135 | setAppThreadResolver(async () => "todoThread"); // restore for later tests |
| 2136 | assert.equal(sent.length, 2); |
| 2137 | assert.deepEqual([sent[0].threadId, sent[0].event.type, sent[0].event.app], ["spaceT", "spaceapps.add", "stickers"]); |
| 2138 | assert.deepEqual([sent[1].threadId, sent[1].event.type, sent[1].event.text], ["spaceT", "stickers.add", "tada"]); |
| 2139 | // A bare "@everyone" is JUST the install, confirmed privately (no chat |
| 2140 | // surface shows the space log, so the reply is the feedback). |
| 2141 | foldEvent("spaceT", { type: "spaceapps.add", app: "stickers", name: "Ana" }, { id: 1 }); |
| 2142 | const sent2 = []; |
| 2143 | setEmitTransport((threadId, event) => (sent2.push(event), Promise.resolve(true))); |
| 2144 | assert.equal(runCommand("/sticker @everyone", "chatX", (r) => replies.push(r)), true); |
| 2145 | await tick(); |
| 2146 | assert.deepEqual(sent2, [], "already installed + no text → nothing to emit"); |
| 2147 | assert.match(replies[replies.length - 1].text, /added for everyone/i); |
| 2148 | setSpaceThreadResolver(null); |
| 2149 | setSpaceThread(null); |
| 2150 | resetThread("spaceT"); |
| 2151 | }); |
| 2152 | |
| 2153 | await test("@everyone for an app that isn't space-capable explains instead of mis-filing", async () => { |
| 2154 | const sent = []; |
| 2155 | const replies = []; |
| 2156 | setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); |
| 2157 | // Poll is chat-place-only (its placeNote says why) — the refusal example. |
| 2158 | assert.equal(runCommand("/poll @everyone free pizza?", "chatX", (r) => replies.push(r)), true); |
| 2159 | await tick(); |
| 2160 | assert.equal(sent.length, 0, "nothing emitted — the text is not silently filed anywhere"); |
| 2161 | assert.match(replies[0].text, /can't be installed for the whole space/); |
| 2162 | }); |
| 2163 | |
| 2164 | await test("installSpaceApp answers false when the space thread can't be resolved (keys syncing)", async () => { |
| 2165 | setSpaceThread(null); |
| 2166 | setSpaceThreadResolver(async () => null); |
| 2167 | assert.equal(await installSpaceApp(stickers), false); |
| 2168 | setSpaceThreadResolver(null); |
| 2169 | }); |
| 2170 | |
| 2171 | await test("the / catalog labels a space-installed command in EVERY chat", () => { |
| 2172 | resetThread("spaceT"); |
| 2173 | setSpaceThread(() => "spaceT"); |
| 2174 | const desc = (list) => list.find((c) => c.name === "/sticker").desc; |
| 2175 | assert.equal(desc(commandCatalog("chatX")), "Post a sticker just for you"); |
| 2176 | foldEvent("spaceT", { type: "spaceapps.add", app: "stickers", name: "Ana" }, { id: 1 }); |
| 2177 | assert.match(desc(commandCatalog("chatX")), /for this space$/); |
| 2178 | // In a group that ALSO added it, the nearer scope's label wins. |
| 2179 | resetThread("grp9"); |
| 2180 | setThreadIsGroup((id) => id === "grp9"); |
| 2181 | foldEvent("grp9", { type: "groupapps.add", app: "stickers", name: "Ana" }, { id: 1 }); |
| 2182 | assert.match(desc(commandCatalog("grp9")), /for this group$/); |
| 2183 | setThreadIsGroup(null); |
| 2184 | setSpaceThread(null); |
| 2185 | resetThread("spaceT"); |
| 2186 | resetThread("grp9"); |
| 2187 | }); |
| 2188 | |
| 2189 | await test("placePhrase: the one vocabulary every surface shares", () => { |
| 2190 | assert.equal(placePhrase("you"), "just for you"); |
| 2191 | assert.equal(placePhrase("chat"), "shared in this chat"); |
| 2192 | assert.equal(placePhrase("space"), "for everyone in this space"); |
| 2193 | assert.equal(placePhrase("elsewhere"), ""); |
| 2194 | assert.equal(placePhrase(), ""); |
| 2195 | }); |
| 2196 | |
| 2197 | await test("placeRowPhrase: the flowing forms a typeahead row reads with", () => { |
| 2198 | assert.equal(placeRowPhrase("you"), "just for you"); |
| 2199 | assert.equal(placeRowPhrase("chat"), "for this group"); |
| 2200 | assert.equal(placeRowPhrase("space"), "for this space"); |
| 2201 | assert.equal(placeRowPhrase("elsewhere"), ""); |
| 2202 | assert.equal(placeRowPhrase(), ""); |
| 2203 | }); |
| 2204 | |
| 2205 | await test("installRowPhrase: a fresh-case row names the new app it would create", () => { |
| 2206 | assert.equal(installRowPhrase("To-do", "chat"), "to a new To-do app for this group"); |
| 2207 | assert.equal(installRowPhrase("To-do", "space"), "to a new To-do app for this space"); |
| 2208 | assert.equal(installRowPhrase("To-do", "you"), "", "nothing installs into the personal place"); |
| 2209 | assert.equal(installRowPhrase("To-do"), ""); |
| 2210 | }); |
| 2211 | |
| 2212 | await test("spaceRowTitle: a space copy is named after the space, never a twin of the personal row", () => { |
| 2213 | assert.equal(spaceRowTitle("To-do", "Lazyatom"), "Lazyatom To-do"); |
| 2214 | assert.equal(spaceRowTitle("To-do", " The Campbells "), "The Campbells To-do", "names are trimmed"); |
| 2215 | // Unset (the compiled-in default — callers pass "") and over-long names |
| 2216 | // fall back to the plain word, keeping the app title visible. |
| 2217 | assert.equal(spaceRowTitle("To-do", ""), "Space To-do"); |
| 2218 | assert.equal(spaceRowTitle("To-do", "The Extended Johnson Family Reunion"), "Space To-do"); |
| 2219 | assert.equal(spaceRowTitle("To-do"), "Space To-do"); |
| 2220 | }); |
| 2221 | |
| 2222 | await test("a routed reply is stamped with its place — and the app words the destination", async () => { |
| 2223 | resetThread("spaceT"); |
| 2224 | setSpaceThread(() => "spaceT"); |
| 2225 | foldEvent("spaceT", { type: "spaceapps.add", app: "triple", name: "Ana" }, { id: 1 }); |
| 2226 | const sent = []; |
| 2227 | const replies = []; |
| 2228 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 2229 | assert.equal(runCommand("/triple space milk", "chatX", (r) => replies.push(r)), true); |
| 2230 | await tick(); |
| 2231 | assert.equal(sent[0].threadId, "spaceT"); |
| 2232 | assert.equal(replies.length, 1); |
| 2233 | assert.equal(replies[0].place, "space", "the framework stamps the routed place — no app cooperation needed"); |
| 2234 | assert.match(replies[0].text, /the space triple list/, "the app names the destination via ctx.place"); |
| 2235 | // Routed personally (no installs anywhere): stamped "you", worded "your" — |
| 2236 | // todo is the shipped reference for the wording. ctx.place learns "you" |
| 2237 | // from the thread-owner map, so bind it here. |
| 2238 | resetThread("spaceT"); |
| 2239 | setSpaceThread(null); |
| 2240 | setThreadOwner((id) => (id === "todoThread" ? "todo" : null)); |
| 2241 | const replies2 = []; |
| 2242 | assert.equal(runCommand("/todo my milk", "chatX", (r) => replies2.push(r)), true); |
| 2243 | await tick(); |
| 2244 | assert.equal(replies2[0].place, "you"); |
| 2245 | assert.match(replies2[0].text, /your to-do list/); |
| 2246 | setThreadOwner((id) => null); // the ambient state the surrounding tests expect |
| 2247 | }); |
| 2248 | |
| 2249 | await test("ctx.open shows the INSTANCE through the host's opener — never a raw thread navigation", async () => { |
| 2250 | resetThread("spaceT"); |
| 2251 | setSpaceThread(() => "spaceT"); |
| 2252 | foldEvent("spaceT", { type: "spaceapps.add", app: "triple", name: "Ana" }, { id: 1 }); |
| 2253 | const opened = []; |
| 2254 | setInstanceOpener((appName, tid) => opened.push([appName, tid])); |
| 2255 | const replies = []; |
| 2256 | setEmitTransport(() => Promise.resolve(true)); |
| 2257 | assert.equal(runCommand("/triple view me", "chatX", (r) => replies.push(r)), true); |
| 2258 | await tick(); |
| 2259 | const view = (replies[0].actions || []).find((a) => a.label === "View Triple"); |
| 2260 | assert.ok(view, "the reply offers a View chip"); |
| 2261 | view.run(); |
| 2262 | assert.deepEqual(opened, [["triple", "spaceT"]], "the chip names the app + its instance; the host picks the surface"); |
| 2263 | setInstanceOpener(null); |
| 2264 | setSpaceThread(null); |
| 2265 | resetThread("spaceT"); |
| 2266 | }); |
| 2267 | |
| 2268 | await test("places: the one declaration — passed through, with listed derived", () => { |
| 2269 | const p = defineApp({ name: "ptest", title: "P", glyph: "▣", places: ["you", "space"], initialState: () => ({}), events: { "ptest.x": (s) => s } }); |
| 2270 | assert.deepEqual(p.places, ["you", "space"]); |
| 2271 | assert.equal(p.listed, true); |
| 2272 | // The shipped apps sit at you+chat while the places UI is hidden (§ The |
| 2273 | // pullback). |
| 2274 | assert.deepEqual(todoApp.places, ["you", "chat"]); |
| 2275 | assert.deepEqual(bookmarksApp.places, ["you", "chat"]); |
| 2276 | assert.deepEqual(emojiApp.places, [], "a utility app lives nowhere — host-mounted only"); |
| 2277 | }); |
| 2278 | |
| 2279 | await test("the catalog carries a machine-readable place for ranking", () => { |
| 2280 | resetThread("spaceT"); |
| 2281 | setSpaceThread(() => "spaceT"); |
| 2282 | foldEvent("spaceT", { type: "spaceapps.add", app: "stickers", name: "Ana" }, { id: 1 }); |
| 2283 | const by = (list, name) => list.find((c) => c.name === name); |
| 2284 | const cat = commandCatalog("chatX"); |
| 2285 | assert.equal(by(cat, "/sticker").place, "space"); |
| 2286 | assert.equal(by(cat, "/todo").place, "you"); |
| 2287 | assert.equal(by(cat, "/emoji").place, "", "a stateless utility command names no place"); |
| 2288 | resetThread("grp10"); |
| 2289 | foldEvent("grp10", { type: "groupapps.add", app: "stickers", name: "Ana" }, { id: 1 }); |
| 2290 | assert.equal(by(commandCatalog("grp10"), "/sticker").place, "chat", "the nearer copy wins the label too"); |
| 2291 | setSpaceThread(null); |
| 2292 | resetThread("spaceT"); |
| 2293 | resetThread("grp10"); |
| 2294 | }); |
| 2295 | |
| 2296 | await test("admins-only policy: non-admin adds refuse with honest copy; reads filter by adder", async () => { |
| 2297 | resetThread("spaceT"); |
| 2298 | setSpaceThread(() => "spaceT"); |
| 2299 | // Non-admin adds folded into the registry: uid-carrying items filter at |
| 2300 | // read time while the policy holds; pre-uid items are grandfathered. |
| 2301 | foldEvent("spaceT", { type: "spaceapps.add", app: "stickers", name: "Mal", uid: "uMal" }, { id: 1 }); |
| 2302 | foldEvent("spaceT", { type: "spaceapps.add", app: "triple", name: "Old" }, { id: 2 }); // pre-uid log |
| 2303 | setSpaceInstallPolicy(() => ({ adminsOnly: true, canInstall: false, adderOK: (uid) => uid === "uAdmin" })); |
| 2304 | assert.deepEqual(spaceAppsFor().map((a) => a.name), ["triple"], "non-admin install inert; grandfathered kept"); |
| 2305 | // …and @everyone from a non-admin explains instead of emitting. |
| 2306 | const sent = []; |
| 2307 | const replies = []; |
| 2308 | setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); |
| 2309 | assert.equal(runCommand("/sticker @everyone tada", "chatX", (r) => replies.push(r)), true); |
| 2310 | await tick(); |
| 2311 | assert.equal(sent.length, 0); |
| 2312 | assert.match(replies[0].text, /Only admins can add apps for everyone/); |
| 2313 | // Policy off again: the filtered install reappears — read-time is reversible. |
| 2314 | setSpaceInstallPolicy(null); |
| 2315 | assert.deepEqual(spaceAppsFor().map((a) => a.name).sort(), ["stickers", "triple"]); |
| 2316 | setSpaceThread(null); |
| 2317 | resetThread("spaceT"); |
| 2318 | }); |
| 2319 | |
| 2320 | await test("/help teaches the routing sentence in the same words the typeahead uses", async () => { |
| 2321 | const replies = []; |
| 2322 | assert.equal(runCommand("/help", "chatX", (r) => replies.push(r)), true); |
| 2323 | await tick(); |
| 2324 | assert.match(replies[0].text, /nearest copy of its app/, "the one routing sentence rides every /help"); |
| 2325 | assert.match(replies[0].text, /just for you/, "personal commands name their place in the list"); |
| 2326 | }); |
| 2327 | |
| 2328 | await test("@-mentioning a space-installed app works in any chat and targets the space instance", async () => { |
| 2329 | resetThread("spaceT"); |
| 2330 | setSpaceThread(() => "spaceT"); |
| 2331 | foldEvent("spaceT", { type: "spaceapps.add", app: "stickers", name: "Ana" }, { id: 1 }); |
| 2332 | const m = parseAppMention("@Stickers party time", "chatX"); |
| 2333 | assert.equal(m.app.name, "stickers"); |
| 2334 | assert.equal(m.args, "party time"); |
| 2335 | const sent = []; |
| 2336 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 2337 | assert.equal(runAppMention("@Stickers party time", "chatX", () => {}), true); |
| 2338 | await tick(); |
| 2339 | assert.equal(sent[0].threadId, "spaceT", "the mention acts on the space instance"); |
| 2340 | assert.equal(sent[0].event.type, "stickers.add"); |
| 2341 | setSpaceThread(null); |
| 2342 | assert.equal(parseAppMention("@Stickers party time", "chatX"), null, "no space thread → no space mentions"); |
| 2343 | resetThread("spaceT"); |
| 2344 | }); |
| 2345 | |
| 2346 | await test("runAppMention: a bare mention gets the usage reply; plain text never matches", async () => { |
| 2347 | resetThread("grp4"); |
| 2348 | foldEvent("grp4", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 }); |
| 2349 | const sent = []; |
| 2350 | const replies = []; |
| 2351 | setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); |
| 2352 | assert.equal(runAppMention("@To-do", "grp4", (r) => replies.push(r)), true); |
| 2353 | await tick(); |
| 2354 | assert.equal(sent.length, 0, "nothing to add"); |
| 2355 | assert.equal(replies.length, 1, "the usage line still shows (it's content, not a confirmation)"); |
| 2356 | assert.equal(replies[0].sender.name, "To-do"); |
| 2357 | assert.equal(replies[0].text, "Usage: @To-do <task>", "usage speaks in mention form, not a slash verb"); |
| 2358 | // Ordinary conversation sails past — an app must never eat a group's messages. |
| 2359 | assert.equal(runAppMention("who's around for lunch?", "grp4", (r) => replies.push(r)), false); |
| 2360 | assert.equal(runAppMention("@Ana can you look?", "grp4", (r) => replies.push(r)), false); |
| 2361 | resetThread("grp4"); |
| 2362 | }); |
| 2363 | |
| 2364 | // ---- command panels (ctx.panel) + the /emoji reference app ----------------- |
| 2365 | |
| 2366 | await test("searchEmoji filters by name substring and collapses aliases", () => { |
| 2367 | const cats = searchEmoji("cat"); |
| 2368 | assert.ok(cats.some((r) => r.ch === "🐱"), "cat matches"); |
| 2369 | assert.ok(cats.every((r) => r.name.includes("cat")), "only matching names"); |
| 2370 | // laughing/satisfied are aliases of one glyph — a bare search dedupes them. |
| 2371 | const all = searchEmoji(""); |
| 2372 | assert.equal(all.filter((r) => r.ch === "😆").length, 1, "aliases collapse to one tile"); |
| 2373 | assert.ok(all.length > 100, "a bare query browses the whole set"); |
| 2374 | assert.deepEqual(searchEmoji("zzzznope"), [], "no match → empty"); |
| 2375 | }); |
| 2376 | |
| 2377 | await test("ctx.panel opens a command panel in the thread you TYPED in", async () => { |
| 2378 | const opened = []; |
| 2379 | setCommandPanelHost((threadId, spec) => { |
| 2380 | opened.push({ threadId, spec }); |
| 2381 | return true; |
| 2382 | }); |
| 2383 | const replies = []; |
| 2384 | assert.equal(runCommand("/emoji cat", "chatX", (r) => replies.push(r)), true); |
| 2385 | await tick(); |
| 2386 | assert.equal(opened.length, 1); |
| 2387 | assert.equal(opened[0].threadId, "chatX", "the panel opens where you typed, not a routed app-thread"); |
| 2388 | assert.equal(typeof opened[0].spec.render, "function"); |
| 2389 | assert.match(opened[0].spec.title, /cat/); |
| 2390 | assert.equal(opened[0].spec.sender.name, "Emoji", "runCommand injects the app persona as the panel's sender"); |
| 2391 | assert.equal(replies.length, 0, "the panel IS the answer — no reply too"); |
| 2392 | }); |
| 2393 | |
| 2394 | await test("a command falls back to a reply when no chat surface can draw a panel", async () => { |
| 2395 | setCommandPanelHost(() => false); // the thread has no mounted chat pane |
| 2396 | const replies = []; |
| 2397 | assert.equal(runCommand("/emoji cat", "chatX", (r) => replies.push(r)), true); |
| 2398 | await tick(); |
| 2399 | assert.equal(replies.length, 1, "ctx.panel returned false → the command replied instead"); |
| 2400 | assert.match(replies[0].text, /🐱/); |
| 2401 | }); |
| 2402 | |
| 2403 | await test("openCommandPanel guards: no host / no render → false; close reaches the host", () => { |
| 2404 | const calls = []; |
| 2405 | setCommandPanelHost((threadId, spec) => (calls.push({ threadId, spec }), true)); |
| 2406 | assert.equal(openCommandPanel("t1", { title: "no render" }), false, "a panel must have render()"); |
| 2407 | assert.equal(calls.length, 0); |
| 2408 | assert.equal(openCommandPanel("t1", { render() {} }), true); |
| 2409 | // The spec passes through verbatim — animate/position are the host's to read. |
| 2410 | assert.equal(openCommandPanel("t1", { render() {}, animate: false }), true); |
| 2411 | assert.equal(calls[calls.length - 1].spec.animate, false); |
| 2412 | closeCommandPanel("t1"); |
| 2413 | assert.equal(calls.length, 3); // two opens + the close |
| 2414 | assert.equal(calls[2].spec, null, "close is a null spec to the same host"); |
| 2415 | setCommandPanelHost(null); |
| 2416 | assert.equal(openCommandPanel("t1", { render() {} }), false, "no host → false (caller falls back to a reply)"); |
| 2417 | }); |
| 2418 | |
| 2419 | await test("gif: pickRendition prefers downsized, skips oversize, tolerates missing sizes", () => { |
| 2420 | const images = { |
| 2421 | downsized: { url: "https://media0.giphy.com/a.gif", width: "320", height: "240", size: "900000" }, |
| 2422 | fixed_height: { url: "https://media0.giphy.com/b.gif", width: "356", height: "200", size: "1500000" }, |
| 2423 | original: { url: "https://media0.giphy.com/c.gif", width: "480", height: "360", size: "9000000" }, |
| 2424 | }; |
| 2425 | assert.equal(pickRendition(images).url, "https://media0.giphy.com/a.gif"); |
| 2426 | // downsized too big → the next acceptable rendition wins |
| 2427 | assert.equal(pickRendition({ ...images, downsized: { ...images.downsized, size: String(GIF_MAX_BYTES + 1) } }).url, "https://media0.giphy.com/b.gif"); |
| 2428 | // unknown size sails through — the byte fetch re-checks the real length |
| 2429 | assert.equal(pickRendition({ downsized: { url: "https://media0.giphy.com/d.gif", width: "1", height: "1" } }).url, "https://media0.giphy.com/d.gif"); |
| 2430 | // nothing acceptable → null |
| 2431 | assert.equal(pickRendition({ original: { url: "https://media0.giphy.com/c.gif", size: String(GIF_MAX_BYTES * 2) } }), null); |
| 2432 | assert.equal(pickRendition(undefined), null); |
| 2433 | // The post walks ALL acceptable renditions (fallback when one download |
| 2434 | // fails), deduped by url, best first. |
| 2435 | const cands = renditionCandidates(images); |
| 2436 | assert.deepEqual(cands.map((c) => c.url.split("/").pop()), ["a.gif", "b.gif"], "oversize original dropped; order kept"); |
| 2437 | assert.deepEqual(renditionCandidates(undefined), []); |
| 2438 | }); |
| 2439 | |
| 2440 | await test("gif: gifItems maps a Giphy page and drops unusable results", () => { |
| 2441 | const data = [ |
| 2442 | { id: "g1", title: "Cat", images: { |
| 2443 | fixed_height_small: { url: "https://media0.giphy.com/s.gif", width: "89", height: "100" }, |
| 2444 | downsized: { url: "https://media0.giphy.com/f.gif", width: "320", height: "240", size: "500000" }, |
| 2445 | } }, |
| 2446 | { id: "g2", title: "No preview", images: { downsized: { url: "https://media0.giphy.com/x.gif", size: "1" } } }, |
| 2447 | { id: "g3", title: "No postable rendition", images: { fixed_height_small: { url: "https://media0.giphy.com/y.gif" } } }, |
| 2448 | null, |
| 2449 | ]; |
| 2450 | const items = gifItems(data); |
| 2451 | assert.equal(items.length, 1); // g2 has no preview rendition, g3 nothing postable |
| 2452 | assert.equal(items[0].id, "g1"); |
| 2453 | assert.equal(items[0].preview.url, "https://media0.giphy.com/s.gif"); |
| 2454 | assert.deepEqual(gifItems(null), []); |
| 2455 | }); |
| 2456 | |
| 2457 | await test("gif: /gif opens a panel where you typed; no host doorway → a reply", async () => { |
| 2458 | resetThread("spaceT"); |
| 2459 | setSpaceThread(() => "spaceT"); |
| 2460 | setSpaceThreadResolver(async () => "spaceT"); |
| 2461 | const opened = []; |
| 2462 | setCommandPanelHost((threadId, spec) => (opened.push({ threadId, spec }), true)); |
| 2463 | setHostBridge({ gif: { search: async () => [], bytes: async () => null, post: async () => true } }); |
| 2464 | const replies = []; |
| 2465 | assert.equal(runCommand("/gif office party", "chatX", (r) => replies.push(r)), true); |
| 2466 | await tick(); |
| 2467 | assert.equal(opened.length, 1); |
| 2468 | // The SPLIT: ctx addresses the space (its key), but the panel opens in the |
| 2469 | // chat you typed in — the two threads a space-placed command app straddles. |
| 2470 | assert.equal(opened[0].threadId, "chatX"); |
| 2471 | assert.match(opened[0].spec.title, /office party/); |
| 2472 | assert.equal(replies.length, 0); |
| 2473 | // Without the host doorway (e.g. a stripped-down embed) it explains itself. |
| 2474 | setHostBridge(null); |
| 2475 | assert.equal(runCommand("/gif cats", "chatX", (r) => replies.push(r)), true); |
| 2476 | await tick(); |
| 2477 | assert.equal(replies.length, 1); |
| 2478 | assert.match(replies[0].text, /isn't available/); |
| 2479 | assert.equal(opened.length, 1, "no panel without the doorway"); |
| 2480 | setCommandPanelHost(null); |
| 2481 | setSpaceThreadResolver(null); |
| 2482 | setSpaceThread(null); |
| 2483 | resetThread("spaceT"); |
| 2484 | assert.ok(!gifApp.places.includes("you"), "gif keeps no personal copy"); |
| 2485 | }); |
| 2486 | |
| 2487 | await test("gif is implicitly space-placed: unlisted + spaceCapable routes to the space with no install", async () => { |
| 2488 | resetThread("spaceT"); |
| 2489 | // Deliberately NO spaceapps.add for gif: an unlisted app has no Add button, |
| 2490 | // so routing must not depend on an install event that can never arrive. |
| 2491 | setSpaceThread(() => "spaceT"); |
| 2492 | setSpaceThreadResolver(async () => "spaceT"); |
| 2493 | assert.deepEqual(gifApp.places, ["space"], "gif lives only in the space place"); |
| 2494 | assert.equal(gifApp.listed, false, "explicit listed:false survives normalizePlaces"); |
| 2495 | assert.equal( |
| 2496 | listedApps().some((a) => a.name === "gif"), |
| 2497 | false, |
| 2498 | "an unlisted app stays out of the Apps catalogue", |
| 2499 | ); |
| 2500 | assert.equal(!!gifApp.render, false, "no app-level render → off the widget bar and sidebar band for free"); |
| 2501 | assert.equal(await instanceThread(gifApp, "chatX"), "spaceT"); |
| 2502 | // The whole point of the rule: WITHOUT it this falls through to the chat and |
| 2503 | // the space's Giphy key folds into whatever conversation you typed in. |
| 2504 | assert.notEqual(await instanceThread(gifApp, "chatX"), "chatX"); |
| 2505 | setSpaceThread(null); |
| 2506 | setSpaceThreadResolver(null); |
| 2507 | resetThread("spaceT"); |
| 2508 | }); |
| 2509 | |
| 2510 | await test("gif: the space's key mints the space thread on first use, and folds last-writer-wins", async () => { |
| 2511 | resetThread("spaceT"); |
| 2512 | let minted = 0; |
| 2513 | setSpaceThread(() => null); // not loaded/created yet — the first /gif in a space |
| 2514 | setSpaceThreadResolver(async () => (minted++, "spaceT")); |
| 2515 | assert.equal(await instanceThread(gifApp, "chatX"), "spaceT"); |
| 2516 | assert.equal(minted, 1, "resolved (minted) rather than falling through to the chat"); |
| 2517 | setSpaceThread(() => "spaceT"); |
| 2518 | // The key is ordinary fold state on the space thread — every member reads it, |
| 2519 | // which is the point; the server only ever holds the ciphertext. |
| 2520 | foldEvent("spaceT", { type: "gif.key", key: " abc123 " }, { id: 1 }); |
| 2521 | assert.equal(appStateFor("spaceT", "gif").key, "abc123", "trimmed"); |
| 2522 | foldEvent("spaceT", { type: "gif.key", key: "xyz789" }, { id: 2 }); |
| 2523 | assert.equal(appStateFor("spaceT", "gif").key, "xyz789", "last writer wins — re-running the form just re-keys"); |
| 2524 | foldEvent("spaceT", { type: "gif.key" }, { id: 3 }); |
| 2525 | assert.equal(appStateFor("spaceT", "gif").key, "", "a malformed event folds to empty, never undefined"); |
| 2526 | setSpaceThread(null); |
| 2527 | setSpaceThreadResolver(null); |
| 2528 | resetThread("spaceT"); |
| 2529 | }); |
| 2530 | |
| 2531 | await test("an unroutable command explains itself instead of doing nothing", async () => { |
| 2532 | // gif with no space thread: a native embed (bootEmbed doesn't load it) or a |
| 2533 | // member whose instance key hasn't arrived. The old build would have asked for |
| 2534 | // a key and stashed it on the device; the key is the space's now, so the |
| 2535 | // honest answer is to say so — never to resurrect a silent per-device key, and |
| 2536 | // never (as this used to) to return without a word. |
| 2537 | setSpaceThread(() => null); |
| 2538 | setSpaceThreadResolver(null); |
| 2539 | const opened = []; |
| 2540 | setCommandPanelHost((threadId, spec) => (opened.push({ threadId, spec }), true)); |
| 2541 | setHostBridge({ gif: { search: async () => [], bytes: async () => null, post: async () => true } }); |
| 2542 | const replies = []; |
| 2543 | assert.equal(runCommand("/gif cats", "chatX", (r) => replies.push(r)), true); |
| 2544 | await tick(); |
| 2545 | assert.equal(opened.length, 0, "no panel with nowhere to read the key from"); |
| 2546 | assert.equal(replies.length, 1); |
| 2547 | assert.match(replies[0].text, /isn't available here yet/i); |
| 2548 | setCommandPanelHost(null); |
| 2549 | setHostBridge(null); |
| 2550 | setSpaceThread(null); |
| 2551 | }); |
| 2552 | |
| 2553 | await test("the declarative panel form: ctx.panel({data}) + a panel() hook synthesizes render", async () => { |
| 2554 | const seen = []; |
| 2555 | const fixture = defineApp({ |
| 2556 | name: "paneldemo", |
| 2557 | title: "Panel Demo", |
| 2558 | glyph: "▣", |
| 2559 | personal: false, |
| 2560 | listed: false, |
| 2561 | initialState: () => ({}), |
| 2562 | events: {}, |
| 2563 | commands: { "/paneldemo": (args, ctx) => ctx.panel({ title: "Demo", data: { args } }) }, |
| 2564 | panel(data, el, pctx) { |
| 2565 | seen.push({ data, el, keys: Object.keys(pctx).sort() }); |
| 2566 | }, |
| 2567 | }); |
| 2568 | const opened = []; |
| 2569 | setCommandPanelHost((threadId, spec) => (opened.push({ threadId, spec }), true)); |
| 2570 | assert.equal(runCommand("/paneldemo hello", "chatX"), true); |
| 2571 | await tick(); |
| 2572 | assert.equal(opened.length, 1); |
| 2573 | const spec = opened[0].spec; |
| 2574 | assert.equal(typeof spec.render, "function", "the hook synthesizes the host render"); |
| 2575 | // The host invokes render(el, handle); the hook gets (data, el, panelCtx) |
| 2576 | // with the handle's members plus the app ctx's emit/id/me/getState. |
| 2577 | const el = {}; |
| 2578 | spec.render(el, { close() {}, insertText() {}, refresh() {}, attachFiles() {}, threadId: "chatX" }); |
| 2579 | assert.equal(seen.length, 1); |
| 2580 | assert.deepEqual(seen[0].data, { args: "hello" }); |
| 2581 | assert.equal(seen[0].el, el); |
| 2582 | for (const k of ["close", "insertText", "emit", "id", "me", "getState"]) { |
| 2583 | assert.ok(seen[0].keys.includes(k), `panelCtx offers ${k}`); |
| 2584 | } |
| 2585 | assert.ok(fixture.panel, "the hook stays on the app object"); |
| 2586 | setCommandPanelHost(null); |
| 2587 | }); |
| 2588 | |
| 2589 | await test("a no-match /emoji answers with a reply, and the app stays command-only", async () => { |
| 2590 | setCommandPanelHost((threadId, spec) => true); |
| 2591 | const replies = []; |
| 2592 | assert.equal(runCommand("/emoji zzzznope", "chatX", (r) => replies.push(r)), true); |
| 2593 | await tick(); |
| 2594 | assert.equal(replies.length, 1); |
| 2595 | assert.match(replies[0].text, /No emoji match/); |
| 2596 | // Command-only shape: no personal thread to route to, nothing to instantiate. |
| 2597 | assert.deepEqual(emojiApp.places, [], "a command-only utility lives nowhere"); |
| 2598 | assert.equal(emojiApp.listed, false); |
| 2599 | setCommandPanelHost(null); |
| 2600 | }); |
| 2601 | |
| 2602 | // ---- quiet apps (issue #227) --------------------------------------------- |
| 2603 | |
| 2604 | await test("appIsQuiet reads the flag off the app registry", () => { |
| 2605 | assert.equal(appIsQuiet("dice"), true); |
| 2606 | assert.equal(appIsQuiet("todo"), false, "an ordinary personal app is not quiet"); |
| 2607 | assert.equal(appIsQuiet("nope-not-a-real-app"), false); |
| 2608 | assert.equal(appIsQuiet(undefined), false, "no app name (e.g. a non-app thread) → not quiet"); |
| 2609 | }); |
| 2610 | |
| 2611 | // quiet and groupCapable are orthogonal (like personal/groupCapable already |
| 2612 | // are) — quiet only ever governs a PERSONAL thread's visibility. Prove both |
| 2613 | // halves of that claim against a single app that's both. |
| 2614 | defineApp({ |
| 2615 | name: "questpin", |
| 2616 | title: "Quest Pin", |
| 2617 | glyph: "📌", |
| 2618 | personal: true, |
| 2619 | groupCapable: true, |
| 2620 | quiet: true, |
| 2621 | initialState: () => ({}), |
| 2622 | events: { "questpin.set": (s) => s }, |
| 2623 | commands: { "/questpin": (args, ctx) => ctx.emit({ type: "questpin.set", id: ctx.id() }) }, |
| 2624 | render() {}, // groupAppsFor requires a render to count as addable to a group |
| 2625 | }); |
| 2626 | |
| 2627 | await test("quiet + groupCapable: PERSONAL routing (not added here) still resolves the app-thread", async () => { |
| 2628 | let resolves = 0; |
| 2629 | setAppThreadResolver(async () => { |
| 2630 | resolves++; |
| 2631 | return "questpinThread"; |
| 2632 | }); |
| 2633 | const sent = []; |
| 2634 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 2635 | assert.equal(runCommand("/questpin", "chatX"), true); |
| 2636 | await tick(); |
| 2637 | setAppThreadResolver(async () => "todoThread"); // restore for later tests |
| 2638 | assert.equal(resolves, 1, "quiet doesn't change WHETHER the personal thread resolves, only its visibility"); |
| 2639 | assert.equal(sent[0].threadId, "questpinThread"); |
| 2640 | }); |
| 2641 | |
| 2642 | await test("quiet + groupCapable: ADDED to a group targets the group thread — quiet's create/hide path never runs", async () => { |
| 2643 | resetThread("questpinGrp"); |
| 2644 | foldEvent("questpinGrp", { type: "groupapps.add", app: "questpin", name: "Ana" }, { id: 1 }); |
| 2645 | // Resolver poisoned: the group's shared instance is the nearer scope, so |
| 2646 | // resolveAppThread (and therefore createAppThread's hide-on-create logic) |
| 2647 | // must never even be consulted — quiet has nothing to do here. |
| 2648 | setAppThreadResolver(async () => { |
| 2649 | throw new Error("group-scoped commands never route to the personal thread"); |
| 2650 | }); |
| 2651 | const sent = []; |
| 2652 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 2653 | assert.equal(runCommand("/questpin", "questpinGrp"), true); |
| 2654 | await tick(); |
| 2655 | setAppThreadResolver(async () => "todoThread"); // restore for later tests |
| 2656 | assert.equal(sent.length, 1); |
| 2657 | assert.equal(sent[0].threadId, "questpinGrp", "targets the GROUP thread, not a personal one"); |
| 2658 | }); |
| 2659 | |
| 2660 | await test("parseSides reads a die size, defaults to d6, rejects out-of-range/garbage", () => { |
| 2661 | assert.equal(parseSides(""), 6); |
| 2662 | assert.equal(parseSides(" "), 6); |
| 2663 | assert.equal(parseSides("20"), 20); |
| 2664 | assert.equal(parseSides("d20"), 20); |
| 2665 | assert.equal(parseSides("D20"), 20); |
| 2666 | assert.equal(parseSides("1"), null, "below the 2-side floor"); |
| 2667 | assert.equal(parseSides("5000"), null, "above the 1000-side ceiling"); |
| 2668 | assert.equal(parseSides("banana"), null); |
| 2669 | }); |
| 2670 | |
| 2671 | await test("dice.js's reducer folds a roll and ignores malformed events", () => { |
| 2672 | const reduce = diceApp.reduce; |
| 2673 | let s = reduce(undefined, { type: "dice.roll", id: "r1", sides: 20, result: 17, name: "Ana" }); |
| 2674 | assert.deepEqual(s.rolls, [{ id: "r1", sides: 20, result: 17, by: "Ana" }]); |
| 2675 | // A second, well-formed roll appends; a malformed one (missing result) no-ops. |
| 2676 | s = reduce(s, { type: "dice.roll", id: "r2", sides: 6, result: 3 }); |
| 2677 | s = reduce(s, { type: "dice.roll", id: "r3", sides: 6 }); |
| 2678 | assert.equal(s.rolls.length, 2); |
| 2679 | assert.equal(s.rolls[1].result, 3); |
| 2680 | }); |
| 2681 | |
| 2682 | await test("/roll emits a dice.roll event and replies in place with an Open action", async () => { |
| 2683 | setAppThreadResolver(async () => "diceThread"); |
| 2684 | const sent = []; |
| 2685 | setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| 2686 | const replies = []; |
| 2687 | assert.equal(runCommand("/roll d20", "chatX", (r) => replies.push(r)), true); |
| 2688 | await tick(); |
| 2689 | setAppThreadResolver(async () => "todoThread"); // restore for later tests |
| 2690 | assert.equal(sent.length, 1); |
| 2691 | assert.equal(sent[0].threadId, "diceThread"); |
| 2692 | assert.equal(sent[0].event.type, "dice.roll"); |
| 2693 | assert.equal(sent[0].event.sides, 20); |
| 2694 | assert.ok(Number.isInteger(sent[0].event.result) && sent[0].event.result >= 1 && sent[0].event.result <= 20); |
| 2695 | assert.equal(replies.length, 1); |
| 2696 | assert.match(replies[0].text, /Rolled a \d+ on a d20/); |
| 2697 | assert.equal(replies[0].actions[0].label, "Roll history"); |
| 2698 | }); |
| 2699 | |
| 2700 | // ---- 🤖 the vault lock layer (docs/locked-vaults.md) -------------------------- |
| 2701 | |
| 2702 | // Well-formed keyring wraps for reuse below. `wrapped` is an opaque b64url blob |
| 2703 | // to the reducer — no crypto runs in a fold. W = a durable passkey wrap; M = a |
| 2704 | // `member` handoff (the vault key sealed to member `uid`'s roster key). |
| 2705 | const W = (id, extra) => ({ id, kind: "passkey", label: "Paul", credId: "cred-" + id, wrapped: "wrap-" + id, ...extra }); |
| 2706 | const M = (id, uid, label) => ({ id, kind: "member", uid, label: label || "", wrapped: "handoff-" + id }); |
| 2707 | const LOCK = (over = {}) => ({ type: "vault.lock", aid: "al", pub: "vaultpub", wrap: W("w1", { uid: "u1" }), sealed: {}, ...over }); |
| 2708 | const vreduce = vaultApp.reduce; |
| 2709 | |
| 2710 | await test("vault.lock installs the keyring, seals covered items, keeps uncovered plaintext", () => { |
| 2711 | let s = vreduce(undefined, { type: "vault.add", id: "s1", aid: "a1", label: "WiFi", value: "hunter2" }); |
| 2712 | s = vreduce(s, { type: "vault.add", id: "s2", aid: "a2", label: "Door", value: "0000" }); |
| 2713 | s = vreduce(s, LOCK({ sealed: { s1: "sealed-s1" } })); |
| 2714 | assert.equal(s.keyring.pub, "vaultpub"); |
| 2715 | assert.deepEqual(s.keyring.wraps.map((w) => w.id), ["w1"]); |
| 2716 | const s1 = s.items.find((i) => i.id === "s1"); |
| 2717 | assert.equal(s1.sealed, "sealed-s1"); |
| 2718 | assert.equal("value" in s1, false, "sealed item drops its plaintext"); |
| 2719 | assert.equal(s.items.find((i) => i.id === "s2").value, "0000", "uncovered item keeps plaintext (render flags it)"); |
| 2720 | assert.equal(s.log.at(-1).action, "lock"); |
| 2721 | }); |
| 2722 | |
| 2723 | await test("vault.lock is one-way and malformed locks fold to no-ops", () => { |
| 2724 | const locked = vreduce(undefined, LOCK()); |
| 2725 | const again = vreduce(locked, LOCK({ aid: "al2", pub: "otherpub", wrap: W("w9") })); |
| 2726 | assert.equal(again, locked, "a second lock never replaces the keyring"); |
| 2727 | for (const bad of [LOCK({ pub: "" }), LOCK({ wrap: null }), LOCK({ wrap: { id: "x" } }), LOCK({ aid: "" })]) { |
| 2728 | assert.deepEqual(vreduce(undefined, bad).keyring ?? null, null); |
| 2729 | } |
| 2730 | }); |
| 2731 | |
| 2732 | await test("vault.lock does not mutate the prior state (snapshot-safe)", () => { |
| 2733 | const s0 = vreduce(undefined, { type: "vault.add", id: "s1", aid: "a1", label: "WiFi", value: "hunter2" }); |
| 2734 | const s1 = vreduce(s0, LOCK({ sealed: { s1: "sealed-s1" } })); |
| 2735 | assert.equal(s0.items[0].value, "hunter2", "prior state untouched"); |
| 2736 | assert.equal(s1.items[0].sealed, "sealed-s1"); |
| 2737 | }); |
| 2738 | |
| 2739 | await test("vault.add accepts exactly one of value/sealed", () => { |
| 2740 | let s = vreduce(undefined, LOCK()); |
| 2741 | s = vreduce(s, { type: "vault.add", id: "s1", aid: "a1", label: "Token", sealed: "sealed-blob" }); |
| 2742 | assert.equal(s.items[0].sealed, "sealed-blob"); |
| 2743 | // both forms, neither form, empty sealed — all fold to no-ops |
| 2744 | for (const bad of [ |
| 2745 | { type: "vault.add", id: "x", aid: "ax", label: "B", value: "v", sealed: "s" }, |
| 2746 | { type: "vault.add", id: "y", aid: "ay", label: "B" }, |
| 2747 | { type: "vault.add", id: "z", aid: "az", label: "B", sealed: "" }, |
| 2748 | ]) { |
| 2749 | assert.equal(vreduce(s, bad).items.length, 1); |
| 2750 | } |
| 2751 | }); |
| 2752 | |
| 2753 | await test("vault.change carrying sealed replaces a plaintext value (the Seal path) and vice versa", () => { |
| 2754 | let s = vreduce(undefined, { type: "vault.add", id: "s1", aid: "a1", label: "WiFi", value: "hunter2" }); |
| 2755 | s = vreduce(s, LOCK()); // lock WITHOUT covering s1 — the stale-plaintext case |
| 2756 | assert.equal(s.items[0].value, "hunter2"); |
| 2757 | s = vreduce(s, { type: "vault.change", id: "s1", aid: "a2", sealed: "sealed-now" }); |
| 2758 | assert.equal(s.items[0].sealed, "sealed-now"); |
| 2759 | assert.equal(s.items[0].value, undefined, "sealing a change drops the plaintext"); |
| 2760 | }); |
| 2761 | |
| 2762 | await test("vault.lock folds member handoffs; vault.enroll turns one into a durable wrap, dropping it by uid", () => { |
| 2763 | // Lock hands James (u2) a member handoff alongside the locker's own wrap. |
| 2764 | let s = vreduce(undefined, LOCK({ handoffs: [M("h2", "u2", "James")] })); |
| 2765 | assert.deepEqual(s.keyring.wraps.map((w) => w.id), ["w1", "h2"]); |
| 2766 | assert.equal(s.keyring.wraps.find((w) => w.id === "h2").kind, "member"); |
| 2767 | // James self-enrols: his member handoff drops, his durable wrap goes in. |
| 2768 | s = vreduce(s, { type: "vault.enroll", aid: "a2", wrap: W("w2", { uid: "u2", label: "James" }), uid: "u2", name: "James" }); |
| 2769 | assert.deepEqual(s.keyring.wraps.map((w) => w.id), ["w1", "w2"]); |
| 2770 | assert.equal(s.keyring.wraps.some((w) => w.kind === "member"), false, "the handoff is gone once enrolled"); |
| 2771 | assert.equal(s.log.at(-1).action, "enroll"); |
| 2772 | // enrolling a member's wrap NEVER touches another member's handoff (drop is by uid) |
| 2773 | let two = vreduce(undefined, LOCK({ handoffs: [M("h2", "u2", "James"), M("h3", "u3", "Carol")] })); |
| 2774 | two = vreduce(two, { type: "vault.enroll", aid: "b1", wrap: W("w2", { uid: "u2" }), uid: "u2", name: "James" }); |
| 2775 | assert.deepEqual(two.keyring.wraps.map((w) => w.id), ["w1", "h3", "w2"], "only the enroller's handoff drops"); |
| 2776 | // duplicate wrap id dedupes; a `member`-kind enroll is rejected (must be durable) |
| 2777 | assert.equal(vreduce(s, { type: "vault.enroll", aid: "a3", wrap: W("w2", { uid: "u2" }) }).keyring.wraps.length, 2, "duplicate wrap id dedupes"); |
| 2778 | assert.equal(vreduce(s, { type: "vault.enroll", aid: "a4", wrap: M("w9", "u2") }).keyring.wraps.length, 2, "a member wrap can't enroll"); |
| 2779 | // no keyring → enroll no-ops |
| 2780 | assert.equal(vreduce(undefined, { type: "vault.enroll", aid: "a5", wrap: W("w9") }).keyring ?? null, null); |
| 2781 | }); |
| 2782 | |
| 2783 | await test("vault.give hands a member a handoff; junk gives fold to no-ops", () => { |
| 2784 | let s = vreduce(undefined, LOCK()); |
| 2785 | s = vreduce(s, { type: "vault.give", aid: "a1", wrap: M("h2", "u2", "James"), uid: "u1", name: "Paul" }); |
| 2786 | assert.deepEqual(s.keyring.wraps.map((w) => w.id), ["w1", "h2"]); |
| 2787 | assert.equal(s.log.at(-1).action, "give"); |
| 2788 | assert.equal(s.log.at(-1).name, "James", "the give logs the recipient"); |
| 2789 | // a give without a recipient uid, or of a non-member wrap, is rejected |
| 2790 | assert.equal(vreduce(s, { type: "vault.give", aid: "a2", wrap: { id: "g1", kind: "member", wrapped: "x" } }).keyring.wraps.length, 2, "give needs a uid"); |
| 2791 | 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"); |
| 2792 | // no keyring → give no-ops |
| 2793 | assert.equal(vreduce(undefined, { type: "vault.give", aid: "a4", wrap: M("g", "u2") }).keyring ?? null, null); |
| 2794 | }); |
| 2795 | |
| 2796 | await test("vault.revoke drops a wrap but never the last one", () => { |
| 2797 | let s = vreduce(undefined, LOCK()); |
| 2798 | assert.equal(vreduce(s, { type: "vault.revoke", aid: "a1", wrapId: "w1" }), s, "the last wrap is unrevokable"); |
| 2799 | s = vreduce(s, { type: "vault.enroll", aid: "a2", wrap: W("w2") }); |
| 2800 | s = vreduce(s, { type: "vault.revoke", aid: "a3", wrapId: "w1" }); |
| 2801 | assert.deepEqual(s.keyring.wraps.map((w) => w.id), ["w2"]); |
| 2802 | assert.equal(s.log.at(-1).action, "revoke"); |
| 2803 | }); |
| 2804 | |
| 2805 | await test("vault: a legacy recovery wrap (#995, removed) never folds in", () => { |
| 2806 | let s = vreduce(undefined, LOCK()); |
| 2807 | // The passphrase-derived break-glass was removed 2026-07-25 — recovery is a |
| 2808 | // passkey, never a passphrase. wrapShape rejects the kind like any unknown one. |
| 2809 | s = vreduce(s, { type: "vault.enroll", aid: "a2", wrap: { id: "rec", kind: "recovery", label: "Recovery key (1Password)", wrapped: "recov-blob" } }); |
| 2810 | assert.deepEqual(s.keyring.wraps.map((w) => w.kind), ["passkey"], "recovery kind never folds in"); |
| 2811 | // a bogus kind is still rejected by wrapShape |
| 2812 | const bad = vreduce(vreduce(undefined, LOCK()), { type: "vault.enroll", aid: "a5", wrap: { id: "x", kind: "usb-fob", wrapped: "b" } }); |
| 2813 | assert.equal(bad.keyring.wraps.length, 1, "unknown wrap kind never folds in"); |
| 2814 | }); |
| 2815 | |
| 2816 | await test("vault: keyring wraps are sanitised — a stranger's spare fields never fold in", () => { |
| 2817 | const s = vreduce(undefined, LOCK({ wrap: { ...W("w1"), evil: "payload", credId: 42 } })); |
| 2818 | const w = s.keyring.wraps[0]; |
| 2819 | assert.equal("evil" in w, false); |
| 2820 | assert.equal("credId" in w, false, "non-string credId dropped"); |
| 2821 | assert.deepEqual(Object.keys(w).sort(), ["id", "kind", "label", "uid", "wrapped"]); |
| 2822 | }); |
| 2823 | |
| 2824 | await test("vault: lock surfaces in previews and the group feed projection; give/enroll stay quiet", () => { |
| 2825 | assert.equal(vaultApp.preview({ type: "vault.lock", uid: "u2", name: "Ciara" }), "🔒 Ciara locked the Vault"); |
| 2826 | // give/enroll are routine key housekeeping — never previewed, never in the feed. |
| 2827 | assert.equal(vaultApp.preview({ type: "vault.give", uid: "u1", name: "me" }), null); |
| 2828 | assert.equal(vaultApp.preview({ type: "vault.enroll", uid: "u2", name: "Ciara" }), null); |
| 2829 | // groupFeedMessage (not chatMessage) is the feed-only projection — the Vault |
| 2830 | // has no companion Chat view of its own (the hook split). |
| 2831 | const line = vaultApp.groupFeedMessage({ type: "vault.lock" }); |
| 2832 | assert.equal(line.action, true); |
| 2833 | assert.match(line.text, /locked the Vault/); |
| 2834 | assert.equal(vaultApp.groupFeedMessage({ type: "vault.enroll" }), null); |
| 2835 | assert.equal(vaultApp.groupFeedMessage({ type: "vault.give" }), null); |
| 2836 | }); |
| 2837 | |
| 2838 | await test("appGlyphInto clears a leftover photo background before painting the mark", () => { |
| 2839 | // The header/avatar elements are reused across thread switches: paintAvatar |
| 2840 | // leaves a photo as an inline background-image (e.g. Eleven's app icon), and |
| 2841 | // switching to a logo-app (Admin) must not leave it showing behind the mark. |
| 2842 | const el = { style: { backgroundImage: "url(eleven.png)" }, classList: { add() {} }, innerHTML: "", textContent: "" }; |
| 2843 | appGlyphInto(el, { logo: "<svg><!--wrench--></svg>" }); |
| 2844 | assert.equal(el.style.backgroundImage, "", "logo path must clear the leftover background"); |
| 2845 | assert.match(el.innerHTML, /wrench/); |
| 2846 | // The glyph fallback path clears it too. |
| 2847 | const g = { style: { backgroundImage: "url(eleven.png)" }, classList: { add() {} }, innerHTML: "", textContent: "" }; |
| 2848 | appGlyphInto(g, { glyph: "🛠️" }); |
| 2849 | assert.equal(g.style.backgroundImage, "", "glyph path must clear the leftover background"); |
| 2850 | assert.equal(g.textContent, "🛠️"); |
| 2851 | }); |
| 2852 | |
| 2853 | await test("newlyDirtyMentionSids: matches, cap, and the already-dirty gate (the boot-freeze regression)", () => { |
| 2854 | const msgs = [ |
| 2855 | { sid: 1, text: "hello @Ana" }, |
| 2856 | { sid: 2, text: "no mention here" }, |
| 2857 | { sid: 3 }, // deleted/attachment-only row: no text |
| 2858 | { sid: 4, text: "ping @Bob and @Ana" }, |
| 2859 | ]; |
| 2860 | // Only rows whose text references a given name are marked. |
| 2861 | assert.deepEqual(newlyDirtyMentionSids(msgs, ["Ana"], new Set()), [1, 4]); |
| 2862 | assert.deepEqual(newlyDirtyMentionSids(msgs, ["Bob"], new Set()), [4]); |
| 2863 | assert.deepEqual(newlyDirtyMentionSids(msgs, ["Zoe"], new Set()), []); |
| 2864 | // Already-dirty rows are never re-reported: a repeat call over the same feed |
| 2865 | // finds nothing, so repaintMentionsOf triggers no re-render — the guarantee |
| 2866 | // (with the mountedCtx snapshot) that the repaint loop always terminates. |
| 2867 | // (Nothing is lost: a dirty row's rev is the fixed "|mention" tag, so a |
| 2868 | // second resolving name couldn't rebuild it anyway.) |
| 2869 | const dirty = new Set(newlyDirtyMentionSids(msgs, ["Ana"], new Set())); |
| 2870 | assert.deepEqual(newlyDirtyMentionSids(msgs, ["Ana"], dirty), []); |
| 2871 | // …even when a DIFFERENT name resolves in an already-dirty row (sid 4 has both). |
| 2872 | assert.deepEqual(newlyDirtyMentionSids(msgs, ["Bob"], dirty), []); |
| 2873 | // Only the most recent 100 messages are ever considered. |
| 2874 | const long = Array.from({ length: 250 }, (_, i) => ({ sid: i + 1, text: "@Ana" })); |
| 2875 | const marked = newlyDirtyMentionSids(long, ["Ana"], new Set()); |
| 2876 | assert.equal(marked.length, 100); |
| 2877 | assert.equal(marked[0], 151); // the cap keeps the newest, drops the oldest |
| 2878 | assert.equal(marked[99], 250); |
| 2879 | }); |
| 2880 | |
| 2881 | console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| 2882 | process.exit(failures === 0 ? 0 : 1); |
| 2883 | |