eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Tests for the mini-app framework + the /todo app. node test/apps.test.mjs
2import assert from "node:assert/strict";
3import {
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";
84import { app as todoApp } from "../web/static/apps/todo.js"; // registers the /todo app
85import { app as pollApp, pollSummary } from "../web/static/apps/poll.js";
86import { app as remindApp, parseReminder } from "../web/static/apps/remind.js";
87import { app as bookmarksApp } from "../web/static/apps/bookmarks.js"; // registers "Save link"
88import { app as boardApp, sanitizePoints } from "../web/static/apps/board.js";
89import { app as dnsimpleApp } from "../web/static/apps/dnsimple.js"; // registers the DNSimple companion app
90import { app as setupApp } from "../web/static/apps/setup.js"; // the first-run setup conversation's app
91import { app as welcomeApp } from "../web/static/apps/welcome.js"; // the joiner welcome conversation's app
92import { app as adminApp } from "../web/static/apps/admin.js"; // the admin panel (registers /admin)
93import { app as chatApp, viewItems, threadItems, newlyDirtyMentionSids } from "../web/static/apps/chat.js"; // the chat app
94import { app as vaultApp } from "../web/static/apps/vault.js"; // the Vault app (registers /vault)
95import { app as emojiApp, searchEmoji } from "../web/static/apps/emoji.js"; // registers /emoji (the command-panel reference app)
96import { app as gifApp, pickRendition, renditionCandidates, gifItems, GIF_MAX_BYTES } from "../web/static/apps/gif.js"; // registers /gif
97import { 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.
101const reduce = todoApp.reduce;
102const pollReduce = pollApp.reduce;
103const remindReduce = remindApp.reduce;
104const bookmarksReduce = bookmarksApp.reduce;
105const 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.
109setCurrentUser("u1");
110setAppThreadResolver(async () => "todoThread");
111const tick = () => new Promise((r) => setTimeout(r, 0)); // let runCommand's async emit flush
112
113let failures = 0;
114async 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
126await 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
140await 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
155await 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
162await 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
168await 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
175await 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
185await 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
191await 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
196await 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
205await 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
221await 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
229await 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.
246await 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
269await 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
300await 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.
315setThreadOwner((id) => (id === "todoThread" ? "todo" : id === "remindThread" ? "remind" : null));
316
317await 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
328await 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
340await 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
360await 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
368await 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
377await 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
385await 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
399await 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
407await 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
415await 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
439await 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
449await 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
457await 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
464await 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
476await 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
488await 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
506await 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
516await 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
540await 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
551await 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
564await 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
584await 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
605await 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
614await 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
624await 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
639await test("foldEvent returns null for a type no app owns", () => {
640 assert.equal(foldEvent("t1", { type: "chat", text: "hi" }, {}), null);
641});
642
643await 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
650await 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
671await 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
686await 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
701await 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
716await 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
734await 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
746await 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
763await 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
771await 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
786await 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
795await 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
805await 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
813await 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
817await 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
834await 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
843await 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
856await 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
870await 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
883await 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
898await 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
911await 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
934await 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
950await 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
977await 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
989await 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
1038await 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
1048await 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
1069await 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
1083await 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
1090await 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
1103await 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
1114await 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
1140await 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
1147await 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
1155await 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
1178await 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
1191await 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
1214await 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
1234await 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
1249await 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
1261await 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
1272await 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
1288await 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
1297await 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
1318await 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).
1345function 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}
1361function 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
1387await 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
1419await 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
1456await 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
1463await 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
1476const dnsimpleReduce = dnsimpleApp.reduce;
1477
1478await 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
1504await 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
1512await 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
1519await 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
1532await 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
1543await 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
1556await 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
1575await 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
1588await 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
1599await 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
1611await 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
1620const groupAppsReduce = groupAppsApp.reduce;
1621
1622await 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
1639await 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
1650await 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
1661await 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
1671await 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
1680await 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
1698await 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
1720await 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
1749await 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
1758await 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
1767await 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
1773await 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
1798await 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
1808await 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.
1829const 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
1855await 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
1890await 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
1921await 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
1949await 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
1961await 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
1983const 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});
2006const spaceReduce = spaceAppsApp.reduce;
2007
2008await 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
2025await 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
2034await 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
2053await 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
2063await 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
2086await 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
2103await 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
2123await 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
2153await 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
2164await 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
2171await 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
2189await 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
2197await 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
2205await 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
2212await 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
2222await 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
2249await 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
2268await 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
2279await 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
2296await 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
2320await 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
2328await 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
2346await 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
2366await 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
2377await 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
2394await 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
2403await 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
2419await 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
2440await 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
2457await 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
2487await 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
2510await 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
2531await 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
2553await 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
2589await 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
2604await 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.
2614defineApp({
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
2627await 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
2642await 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
2660await 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
2671await 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
2682await 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).
2705const W = (id, extra) => ({ id, kind: "passkey", label: "Paul", credId: "cred-" + id, wrapped: "wrap-" + id, ...extra });
2706const M = (id, uid, label) => ({ id, kind: "member", uid, label: label || "", wrapped: "handoff-" + id });
2707const LOCK = (over = {}) => ({ type: "vault.lock", aid: "al", pub: "vaultpub", wrap: W("w1", { uid: "u1" }), sealed: {}, ...over });
2708const vreduce = vaultApp.reduce;
2709
2710await 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
2723await 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
2732await 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
2739await 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
2753await 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
2762await 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
2783await 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
2796await 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
2805await 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
2816await 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
2824await 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
2838await 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
2853await 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
2881console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
2882process.exit(failures === 0 ? 0 : 1);
2883