eleven / ocho Public

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

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