eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1import assert from "assert";
2import { allVectors } from "./event-vectors/index.js";
3// The reply-thread rollup is tested against the REAL implementation (chat.js
4// exports both as pure functions), not a reconstruction β€” grouping rules are
5// too easy to fork silently.
6import { viewItems, replyTarget } from "../web/static/apps/chat.js";
7// Views fold against the REAL app (views.js exports it) β€” its whole job is
8// clamping hostile events, so a reconstruction would test the wrong thing.
9import { viewsApp } from "../web/static/apps/views.js";
10// πŸ€– Vault folds against the REAL reducer too β€” these vectors are the
11// cross-IMPLEMENTATION contract with the Go fold (internal/vault), which reads
12// the same test/event-vectors/vault.json in its own test suite.
13import { app as vaultApp } from "../web/static/apps/vault.js";
14// πŸ€– Webhooks: the ignore-unclaimed-types contract runs against the REAL
15// dispatch gate, with the shipped webhooks app loaded (it must claim nothing).
16import { isAppType, foldEvent } from "../web/static/apps.js";
17import "../web/static/apps/webhooks.js";
18
19// Import chat.js fold handlers β€” the ones we're testing.
20// The app.hostEvents from web/static/apps/chat.js.
21// For testing purposes, we'll reconstruct them here so the test is self-contained.
22
23// Mirrors chat.js's chat.msg hostEvent β€” the SPEC, not a snapshot. Includes the
24// optimistic-send lifecycle: an emitted message folds PENDING (meta.pending, a
25// client id `_cid`, no server id) so it shows instantly; its own broadcast echo
26// (same _cid, now with a server id) UPGRADES that record in place rather than
27// duplicating it. Keep this in step with the real fold in web/static/apps/chat.js.
28function bySidPendingLast(a, b) {
29 if (a.sid && b.sid) return a.sid - b.sid;
30 if (!a.sid && !b.sid) return a.ts - b.ts;
31 return a.sid ? -1 : 1;
32}
33function testChatMsgFold(state, event, meta) {
34 const text = typeof event.text === "string" ? event.text : "";
35 const atts = Array.isArray(event.attachments) ? event.attachments.filter((a) => a && a.id) : [];
36 const sid = (meta && meta.id) || 0;
37 const cid = typeof event._cid === "string" ? event._cid : "";
38 const pending = !!(meta && meta.pending);
39 if ((!text.trim() && !atts.length) || (!sid && !pending)) return state;
40 if (sid && state.msgs.some((m) => m.sid === sid)) return state;
41 if (cid) {
42 const idx = state.msgs.findIndex((m) => m.cid === cid);
43 if (idx >= 0) {
44 if (!sid) return state;
45 const upgraded = state.msgs.slice();
46 upgraded[idx] = { ...upgraded[idx], sid, ts: event.ts || upgraded[idx].ts, pending: false, slow: false };
47 upgraded.sort(bySidPendingLast);
48 return { msgs: upgraded };
49 }
50 }
51 const raw = { name: event.name || "", text, ts: event.ts || ((meta && meta.ts) || 0) };
52 if (event.uid) raw.uid = event.uid;
53 if (atts.length) raw.attachments = atts.map((a) => ({ ...a }));
54 if (event.replyTo) raw.replyTo = { ...event.replyTo };
55 if (Array.isArray(event.actions) && event.actions.length) raw.actions = event.actions;
56 if (event.action) raw.action = true;
57 if (event.warn) raw.warn = true;
58 const msgs = [
59 ...state.msgs,
60 {
61 id: sid ? "m" + sid : "c" + cid,
62 sid,
63 cid,
64 pending,
65 slow: false,
66 text,
67 name: raw.name,
68 uid: event.uid || "",
69 ts: raw.ts,
70 attachments: raw.attachments || [],
71 reply: event.replyTo ? { id: event.replyTo.id || 0, name: event.replyTo.name || "", snippet: event.replyTo.snippet || "" } : null,
72 actions: raw.actions || [],
73 action: !!event.action,
74 warn: !!event.warn,
75 reactions: [],
76 edited: false,
77 raw,
78 rev: 0,
79 },
80 ];
81 msgs.sort(bySidPendingLast);
82 return { msgs };
83}
84
85function testChatReactionFold(state, event, meta, ctx) {
86 if (event.targetSid === undefined || event.emoji === undefined || event.uid === undefined || event.on === undefined) return state;
87 const me = ctx && ctx.me;
88 const msgs = state.msgs.map((m) => {
89 if (m.sid !== event.targetSid) return m;
90 const reactionEvents = m.reactionEvents || [];
91 let found = false;
92 const updated = reactionEvents.map((ev) => {
93 if (ev.emoji === event.emoji && ev.uid === event.uid) {
94 found = true;
95 return { ...ev, on: event.on, ts: event.ts };
96 }
97 return ev;
98 });
99 if (!found && event.on) {
100 updated.push({ emoji: event.emoji, uid: event.uid, on: true, ts: event.ts });
101 }
102 const currentReactions = updated.filter((ev) => ev.on);
103 const derived = [];
104 const emojiMap = new Map();
105 for (const ev of currentReactions) {
106 if (!emojiMap.has(ev.emoji)) {
107 emojiMap.set(ev.emoji, []);
108 }
109 emojiMap.get(ev.emoji).push(ev.uid);
110 }
111 for (const [emoji, uids] of emojiMap) {
112 derived.push({ emoji, count: uids.length, mine: uids.includes(me) });
113 }
114 derived.sort((a, b) => a.emoji.localeCompare(b.emoji));
115 return { ...m, reactions: derived, reactionEvents: updated, rev: m.rev + 1 };
116 });
117 return { msgs };
118}
119
120function testChatEditFold(state, event) {
121 return {
122 msgs: state.msgs.map((m) => {
123 if (m.sid !== event.msgId) return m;
124 const text = typeof event.text === "string" ? event.text : m.text;
125 return { ...m, text, raw: { ...m.raw, text }, edited: true, rev: m.rev + 1 };
126 }),
127 };
128}
129
130function testChatDeleteFold(state, event) {
131 return { msgs: state.msgs.filter((m) => m.sid !== event.msgId) };
132}
133
134// Todo app fold handlers (web/static/apps/todo.js)
135function addItem(items, item) {
136 if (items.some((i) => i.id === item.id)) return items;
137 return [...items, item];
138}
139
140function updateItem(items, id, updates) {
141 return items.map((i) => (i.id === id ? { ...i, ...updates } : i));
142}
143
144function removeItem(items, id) {
145 return items.filter((i) => i.id !== id);
146}
147
148function testTodoAddFold(state, event) {
149 const text = typeof event.text === "string" ? event.text : "";
150 if (!text || !event.id) return state;
151 return { items: addItem(state.items, { id: event.id, text, done: false, doneAt: 0, by: event.name || "" }) };
152}
153
154function testTodoDoneFold(state, event) {
155 return { items: updateItem(state.items, event.id, { done: true, doneAt: event.ts || 0 }) };
156}
157
158function testTodoReopenFold(state, event) {
159 return { items: updateItem(state.items, event.id, { done: false, doneAt: 0 }) };
160}
161
162function testTodoRemoveFold(state, event) {
163 return { items: removeItem(state.items, event.id) };
164}
165
166// Remind app fold handlers (web/static/apps/remind.js)
167function testRemindAddFold(state, event) {
168 if (!event.id || !event.text || !event.when) return state;
169 const items = addItem(state.items, {
170 id: event.id,
171 text: event.text,
172 when: event.when,
173 ts: event.ts || 0,
174 sid: event.sid || "",
175 fired: false,
176 firedAt: 0,
177 });
178 items.sort((a, b) => a.when - b.when);
179 return { items };
180}
181
182function testRemindFireFold(state, event) {
183 return { items: updateItem(state.items, event.id, { fired: true, firedAt: event.ts || 0 }) };
184}
185
186function testRemindDoneFold(state, event) {
187 return { items: removeItem(state.items, event.id) };
188}
189
190// Group-apps registry fold handlers (web/static/apps.js β€” the groupapps app)
191function testGroupAppsAddFold(state, event) {
192 if (typeof event.app !== "string" || !event.app) return state;
193 return { apps: addItem(state.apps, { id: event.app, by: event.name || "", ts: event.ts || 0 }) };
194}
195
196function testGroupAppsRemoveFold(state, event) {
197 return { apps: removeItem(state.apps, event.app) };
198}
199
200// Test each vector
201let passes = 0;
202let fails = 0;
203
204function test(name, fn) {
205 try {
206 fn();
207 console.log(`PASS ${name}`);
208 passes++;
209 } catch (e) {
210 console.error(`FAIL ${name}`);
211 console.error(` ${e.message}`);
212 fails++;
213 }
214}
215
216// Optimistic-send lifecycle (the web/native contract for `_cid`). A message
217// folds PENDING the instant it's emitted, then its own echo silently upgrades
218// that same record β€” never a duplicate. Native must carry `_cid` through and
219// apply the same pairing.
220test("chat.optimistic: an emitted message folds pending, then its echo upgrades it in place", () => {
221 const cid = "cid-abc";
222 // 1. Optimistic local echo: no server id yet, pending, keyed by cid.
223 let state = testChatMsgFold({ msgs: [] }, { text: "hi there", name: "Ada", uid: "u1", ts: 1000, _cid: cid }, { cid, ts: 1000, pending: true });
224 assert.strictEqual(state.msgs.length, 1, "pending message shows immediately");
225 let m = state.msgs[0];
226 assert.strictEqual(m.sid, 0, "no server id while pending");
227 assert.strictEqual(m.pending, true, "flagged pending");
228 assert.strictEqual(m.cid, cid, "keyed by the client id");
229 assert.strictEqual(m.id, "c" + cid, "keyedList id is cid-stable so the bubble is never rebuilt");
230 // 2. The server broadcasts our own message back (same _cid, now a real id).
231 state = testChatMsgFold(state, { text: "hi there", name: "Ada", uid: "u1", ts: 1000, _cid: cid }, { id: 42, ts: 1000 });
232 assert.strictEqual(state.msgs.length, 1, "echo upgrades in place β€” no duplicate");
233 m = state.msgs[0];
234 assert.strictEqual(m.sid, 42, "adopts the server id");
235 assert.strictEqual(m.pending, false, "no longer pending");
236 assert.strictEqual(m.slow, false, "slow cleared");
237 assert.strictEqual(m.id, "c" + cid, "keyedList id stays stable across the upgrade");
238 // 3. A duplicate echo (catch-up on reopen) is idempotent.
239 const again = testChatMsgFold(state, { text: "hi there", name: "Ada", uid: "u1", ts: 1000, _cid: cid }, { id: 42, ts: 1000 });
240 assert.strictEqual(again.msgs.length, 1, "re-folding the confirmed message changes nothing");
241});
242
243test("chat.optimistic: a pending copy sorts after confirmed messages, then slots into id order on confirm", () => {
244 let state = testChatMsgFold({ msgs: [] }, { text: "first", ts: 10 }, { id: 100, ts: 10 });
245 state = testChatMsgFold(state, { text: "mine", ts: 20, _cid: "z" }, { cid: "z", ts: 20, pending: true });
246 assert.deepStrictEqual(state.msgs.map((m) => m.text), ["first", "mine"], "pending sits at the bottom");
247 // A higher-id message arriving before our echo still lands before the pending copy.
248 state = testChatMsgFold(state, { text: "later", ts: 30 }, { id: 200, ts: 30 });
249 assert.deepStrictEqual(state.msgs.map((m) => m.text), ["first", "later", "mine"], "pending stays last until confirmed");
250 state = testChatMsgFold(state, { text: "mine", ts: 20, _cid: "z" }, { id: 150, ts: 20 });
251 assert.deepStrictEqual(state.msgs.map((m) => m.text), ["first", "mine", "later"], "on confirm it slots into server order");
252});
253
254test("chat.optimistic: a normal server message carrying _cid still folds its text (native carry-and-ignore)", () => {
255 const state = testChatMsgFold({ msgs: [] }, { text: "hello", name: "Bo", ts: 5, _cid: "unmatched" }, { id: 7, ts: 5 });
256 assert.strictEqual(state.msgs.length, 1, "folds like any message");
257 assert.strictEqual(state.msgs[0].text, "hello", "_cid never leaks into the text");
258 assert.strictEqual(state.msgs[0].sid, 7, "real id");
259 assert.strictEqual(state.msgs[0].pending, false, "not pending β€” it arrived confirmed");
260});
261
262allVectors.chat.forEach((vector) => {
263 if (vector.expected_fold_event?.type === "chat.msg") {
264 test(`chat.msg: ${vector.name} β€” folds correctly and produces expected event shape`, () => {
265 const state = { msgs: [] };
266 const event = {
267 type: "chat.msg",
268 ...vector.decrypted_payload,
269 };
270 const meta = { id: vector.server_message.id, ts: vector.server_message.created_at * 1000 };
271 const result = testChatMsgFold(state, event, meta);
272
273 assert.strictEqual(result.msgs.length, 1, "should have one message");
274 const msg = result.msgs[0];
275 assert.strictEqual(msg.name, vector.expected_fold_event.name, `name should be '${vector.expected_fold_event.name}'`);
276 assert.strictEqual(msg.text, vector.expected_fold_event.text, "text should match");
277 assert.strictEqual(msg.uid, vector.expected_fold_event.uid || "", "uid should match");
278 assert.strictEqual(msg.ts, vector.expected_fold_event.ts, "ts should match (ms)");
279 assert.strictEqual(msg.action, vector.expected_fold_event.action || false, "action flag should match");
280 assert.strictEqual(msg.warn, vector.expected_fold_event.warn || false, "warn flag should match");
281
282 // Attachments
283 if (vector.expected_fold_event.attachments) {
284 assert.deepStrictEqual(msg.attachments, vector.expected_fold_event.attachments, "attachments should match exactly");
285 } else {
286 assert.strictEqual(msg.attachments.length, 0, "attachments should be empty");
287 }
288
289 // Reply quote
290 if (vector.expected_fold_event.replyTo) {
291 assert.notStrictEqual(msg.reply, null, "reply should be present");
292 assert.strictEqual(msg.reply.id, vector.expected_fold_event.replyTo.id, "reply id should match");
293 assert.strictEqual(msg.reply.name, vector.expected_fold_event.replyTo.name, "reply name should match");
294 assert.strictEqual(msg.reply.snippet, vector.expected_fold_event.replyTo.snippet, "reply snippet should match");
295 } else {
296 assert.strictEqual(msg.reply, null, "reply should be null");
297 }
298 });
299 }
300
301 // Reply-thread grouping vectors: fold the whole sequence, then assert the
302 // thread rollup (every reply resolves to its ROOT β€” legacy chains included)
303 // and the compose-side contract (replying anywhere in a thread stages the root).
304 if (vector.expected_threads) {
305 test(`chat threads: ${vector.name} β€” replies group on the resolved root`, () => {
306 let state = { msgs: [] };
307 for (const step of vector.message_sequence) {
308 const event = { type: "chat.msg", ...step.decrypted_payload };
309 const meta = { id: step.server_message.id, ts: step.server_message.created_at * 1000 };
310 state = testChatMsgFold(state, event, meta);
311 }
312 const items = viewItems(state.msgs);
313 const bySid = new Map(items.map((i) => [i.sid, i]));
314 for (const [sid, root] of Object.entries(vector.expected_threads.roots)) {
315 assert.strictEqual(bySid.get(Number(sid))?.replyRoot, root, `message ${sid} should resolve to root ${root}`);
316 }
317 for (const [root, count] of Object.entries(vector.expected_threads.reply_counts)) {
318 assert.strictEqual(bySid.get(Number(root))?.replyCount, count, `root ${root} should tally ${count} thread messages`);
319 }
320 for (const [root, first] of Object.entries(vector.expected_threads.first_reply)) {
321 const firstReply = items.find((i) => i.replyRoot === Number(root));
322 assert.strictEqual(firstReply && firstReply.sid, first, `first reply in root ${root}'s thread should be ${first}`);
323 }
324 const rt = vector.expected_reply_target;
325 if (rt) {
326 const m = state.msgs.find((x) => x.sid === rt.for_message);
327 assert.deepStrictEqual(replyTarget(state.msgs, m), rt.replyTo, "replyTarget should stage the thread's top message");
328 }
329 });
330 }
331
332 if (vector.expected_fold_event?.type === "chat.reaction") {
333 test(`chat.reaction: ${vector.name} β€” folds the encrypted reaction event into the target message`, () => {
334 const p = vector.decrypted_payload;
335 // Seed the target message with the reactor's reaction already on when the
336 // vector removes it, so on=false has something to fold away.
337 const seeded = p.on ? [] : [{ emoji: p.emoji, uid: p.uid, on: true, ts: p.ts - 1 }];
338 const state = { msgs: [{ sid: p.targetSid, uid: p.targetUid || "", reactions: [], reactionEvents: seeded, rev: 0 }] };
339 const event = { type: "chat.reaction", targetSid: p.targetSid, emoji: p.emoji, uid: p.uid, on: p.on, ts: p.ts };
340 assert.deepStrictEqual(
341 { type: event.type, targetSid: event.targetSid, emoji: event.emoji, uid: event.uid, on: event.on },
342 vector.expected_fold_event,
343 "fold event derived from the payload should match the vector"
344 );
345 const result = testChatReactionFold(state, event, {}, { me: "user-someone-else" });
346 assert.strictEqual(result.msgs.length, 1, "should have one message");
347 const msg = result.msgs[0];
348 if (p.on) {
349 assert.deepStrictEqual(msg.reactions, [{ emoji: p.emoji, count: 1, mine: false }], "add derives a count of 1, not mine");
350 } else {
351 assert.deepStrictEqual(msg.reactions, [], "remove folds the reaction away");
352 }
353 assert.strictEqual(msg.rev, 1, "a reaction bumps the rev");
354 // Folding the same event again is idempotent on the derived reactions.
355 const again = testChatReactionFold(result, event, {}, { me: "user-someone-else" });
356 assert.deepStrictEqual(again.msgs[0].reactions, msg.reactions, "re-fold is idempotent");
357 });
358 }
359
360 if (vector.expected_fold_event?.type === "chat.edit") {
361 test(`chat.edit: ${vector.name} β€” edits message text and marks as edited`, () => {
362 const state = { msgs: [{ sid: vector.server_message.id, text: vector.original_payload.text, edited: false, rev: 0, raw: { text: vector.original_payload.text } }] };
363 const event = {
364 type: "chat.edit",
365 msgId: vector.server_message.id,
366 text: vector.expected_fold_event.text,
367 };
368 const result = testChatEditFold(state, event);
369
370 assert.strictEqual(result.msgs.length, 1, "should have one message");
371 const msg = result.msgs[0];
372 assert.strictEqual(msg.text, vector.expected_fold_event.text, "text should match edit");
373 assert.strictEqual(msg.edited, true, "edited flag should be true");
374 assert.strictEqual(msg.rev, 1, "rev should increment");
375 });
376 }
377
378 if (vector.name === "edit") {
379 test("chat.edit over a pre-seeded store: history fold's text overwrites stale cached text (#457)", () => {
380 // The offline-edit path over the local message store: the cache seeded
381 // state with the ORIGINAL text; the reconcile refetch folds the edited
382 // row β€” its chat.msg dedupes against the stale bubble (same sid), so
383 // the synthetic chat.edit MUST carry the post-edit text or the stale
384 // text survives forever. This is why shell.js's history fold includes
385 // `text` β€” do not drop it as redundant (see the vector's note).
386 const sid = vector.server_message.id;
387 // 1. cached paint: the pre-edit payload folds in
388 let state = testChatMsgFold({ msgs: [] }, { ...vector.original_payload }, { id: sid, ts: vector.original_payload.ts });
389 assert.strictEqual(state.msgs[0].text, vector.original_payload.text, "cache seeded the original text");
390 // 2. reconcile: the refetched (edited) row's chat.msg is a dedupe no-op…
391 state = testChatMsgFold(state, { ...vector.edited_payload }, { id: sid, ts: vector.edited_payload.ts });
392 assert.strictEqual(state.msgs[0].text, vector.original_payload.text, "chat.msg dedupes β€” stale text still shown");
393 // …so only the history chat.edit (WITH text) can heal the bubble.
394 state = testChatEditFold(state, { type: "chat.edit", msgId: sid, text: vector.edited_payload.text });
395 assert.strictEqual(state.msgs[0].text, vector.expected_fold_event.text, "edit fold healed the stale text");
396 assert.strictEqual(state.msgs[0].edited, true, "marked edited");
397 });
398 }
399
400 if (vector.expected_fold_event?.type === "chat.delete") {
401 test(`chat.delete: ${vector.name} β€” removes message from state`, () => {
402 const msgId = vector.expected_fold_event.msgId;
403 const state = {
404 msgs: [
405 { sid: msgId - 1, text: "before" },
406 { sid: msgId, text: "deleted" },
407 { sid: msgId + 1, text: "after" },
408 ],
409 };
410 const event = {
411 type: "chat.delete",
412 msgId: msgId,
413 };
414 const result = testChatDeleteFold(state, event);
415
416 assert.strictEqual(result.msgs.length, 2, "should have two messages");
417 assert.strictEqual(result.msgs[0].sid, msgId - 1, "first message preserved");
418 assert.strictEqual(result.msgs[1].sid, msgId + 1, "last message preserved");
419 assert(!result.msgs.some((m) => m.sid === msgId), "deleted message should not be present");
420 });
421 }
422
423 if (vector.name === "tombstone_delete") {
424 test(`tombstone: ${vector.name} β€” stored row folds like the live delete frame`, () => {
425 // The translation rule (shell.js chatAppFoldMessage / foldAppThreadMessage):
426 // a history row carrying tombstone_of folds { type:"chat.delete",
427 // msgId: tombstone_of } β€” byte-identical to the live frame's fold event,
428 // so there is exactly one delete path.
429 const row = vector.server_message;
430 assert.strictEqual(row.blob, "", "tombstone rows carry no ciphertext");
431 assert(row.id > row.tombstone_of, "tombstone id is fresh β€” above the id it unsends");
432 const event = { type: "chat.delete", msgId: row.tombstone_of };
433 assert.deepStrictEqual(event, vector.expected_fold_event, "translation matches the pinned fold event");
434
435 const state = { msgs: [{ sid: row.tombstone_of, text: "unsent" }, { sid: row.tombstone_of + 1, text: "after" }] };
436 const result = testChatDeleteFold(state, event);
437 assert(!result.msgs.some((m) => m.sid === row.tombstone_of), "target dropped from state");
438 assert.strictEqual(result.msgs.length, 1, "only the target dropped");
439
440 // A client that never held the message (fresh load) folds it as a no-op.
441 const fresh = testChatDeleteFold({ msgs: [{ sid: 1, text: "x" }] }, event);
442 assert.strictEqual(fresh.msgs.length, 1, "no-op on a client with no local copy");
443 });
444 }
445});
446
447// The GROUP chat projections are cross-platform spec (AppChatProjection
448// mirrors the wording natively): run the REAL app's chatMessage against any
449// vector that declares one β€” the fold tests above use local handlers, but
450// wording must come from the shipping module.
451const { app: realTodoApp } = await import("../web/static/apps/todo.js");
452allVectors.todo.forEach((vector) => {
453 if (!("expected_group_chat_projection" in vector)) return;
454 test(`todo group projection: ${vector.name}`, () => {
455 const line = realTodoApp.chatMessage(vector.event, { group: true });
456 const expected = vector.expected_group_chat_projection;
457 if (expected === null) {
458 assert.strictEqual(line, null);
459 return;
460 }
461 const got = typeof line === "string" ? { text: line, action: false }
462 : { text: line.text, action: !!line.action };
463 assert.deepStrictEqual(got, expected);
464 });
465});
466
467// Test todo vectors
468allVectors.todo.forEach((vector) => {
469 if (vector.name === "todo_add") {
470 test(`todo.add: ${vector.name} β€” folds correctly`, () => {
471 const state = { items: [] };
472 const event = vector.event;
473 const result = testTodoAddFold(state, event);
474
475 assert.strictEqual(result.items.length, 1, "should have one item");
476 const item = result.items[0];
477 assert.strictEqual(item.id, vector.expected_state_update.items[0].id, "id should match");
478 assert.strictEqual(item.text, vector.expected_state_update.items[0].text, "text should match");
479 assert.strictEqual(item.done, false, "done should be false");
480 assert.strictEqual(item.doneAt, 0, "doneAt should be 0");
481 assert.strictEqual(item.by, vector.expected_state_update.items[0].by, "by should match");
482 });
483 }
484
485 if (vector.name === "todo_done") {
486 test(`todo.done: ${vector.name} β€” marks as done with timestamp`, () => {
487 const state = { items: vector.initial_state.items };
488 const event = vector.event;
489 const result = testTodoDoneFold(state, event);
490
491 assert.strictEqual(result.items.length, 1, "should have one item");
492 const item = result.items[0];
493 assert.strictEqual(item.done, true, "done should be true");
494 assert.strictEqual(item.doneAt, vector.expected_state_update.items[0].doneAt, "doneAt should match");
495 });
496 }
497
498 if (vector.name === "todo_reopen") {
499 test(`todo.reopen: ${vector.name} β€” unmarks as done`, () => {
500 const state = { items: vector.initial_state.items };
501 const event = vector.event;
502 const result = testTodoReopenFold(state, event);
503
504 assert.strictEqual(result.items.length, 1, "should have one item");
505 const item = result.items[0];
506 assert.strictEqual(item.done, false, "done should be false");
507 assert.strictEqual(item.doneAt, 0, "doneAt should be 0");
508 });
509 }
510
511 if (vector.name === "todo_remove") {
512 test(`todo.remove: ${vector.name} β€” deletes item`, () => {
513 const state = { items: vector.initial_state.items };
514 const event = vector.event;
515 const result = testTodoRemoveFold(state, event);
516
517 assert.strictEqual(result.items.length, 1, "should have one item left");
518 assert.strictEqual(result.items[0].id, "todo-2", "remaining item should be todo-2");
519 });
520 }
521});
522
523// Test remind vectors
524allVectors.remind.forEach((vector) => {
525 if (vector.name === "remind_add") {
526 test(`remind.add: ${vector.name} β€” folds correctly`, () => {
527 const state = { items: [] };
528 const event = vector.event;
529 const result = testRemindAddFold(state, event);
530
531 assert.strictEqual(result.items.length, 1, "should have one item");
532 const item = result.items[0];
533 assert.strictEqual(item.id, vector.expected_state_update.items[0].id, "id should match");
534 assert.strictEqual(item.text, vector.expected_state_update.items[0].text, "text should match");
535 assert.strictEqual(item.when, vector.expected_state_update.items[0].when, "when should match");
536 assert.strictEqual(item.sid, vector.expected_state_update.items[0].sid, "sid should match");
537 assert.strictEqual(item.fired, false, "fired should be false");
538 });
539 }
540
541 if (vector.name === "remind_fire") {
542 test(`remind.fire: ${vector.name} β€” marks as fired`, () => {
543 const state = { items: vector.initial_state.items };
544 const event = vector.event;
545 const result = testRemindFireFold(state, event);
546
547 assert.strictEqual(result.items.length, 1, "should have one item");
548 const item = result.items[0];
549 assert.strictEqual(item.fired, true, "fired should be true");
550 assert.strictEqual(item.firedAt, vector.expected_state_update.items[0].firedAt, "firedAt should match");
551 });
552 }
553
554 if (vector.name === "remind_done") {
555 test(`remind.done: ${vector.name} β€” removes reminder`, () => {
556 const state = { items: vector.initial_state.items };
557 const event = vector.event;
558 const result = testRemindDoneFold(state, event);
559
560 assert.strictEqual(result.items.length, 0, "should have no items");
561 });
562 }
563
564 if (vector.name === "remind_multiple_sorted") {
565 test(`remind.multiple_sorted: ${vector.name} β€” keeps items sorted by when`, () => {
566 let state = { items: [] };
567 for (const e of vector.event_sequence) {
568 state = testRemindAddFold(state, e);
569 }
570
571 assert.strictEqual(state.items.length, 2, "should have two items");
572 assert.strictEqual(state.items[0].id, "remind-2", "first item should be remind-2 (soonest)");
573 assert.strictEqual(state.items[1].id, "remind-1", "second item should be remind-1 (latest)");
574 assert.strictEqual(state.items[0].when, 1700003600000, "first should be soonest");
575 assert.strictEqual(state.items[1].when, 1700010000000, "second should be latest");
576 });
577 }
578});
579
580// Test group-apps registry vectors (which apps a group chat has added)
581allVectors.groupapps.forEach((vector) => {
582 test(`groupapps: ${vector.name} β€” folds to the expected registry state`, () => {
583 const state = { apps: (vector.initial_state && vector.initial_state.apps) || [] };
584 const result = vector.event.type === "groupapps.remove" ? testGroupAppsRemoveFold(state, vector.event) : testGroupAppsAddFold(state, vector.event);
585 assert.deepStrictEqual(result.apps, vector.expected_state_update.apps, "registry state should match the vector exactly");
586 });
587});
588
589// Space-apps registry fold handlers (web/static/apps.js β€” the spaceapps app).
590// The groupapps contract one level up, plus the adder's uid on each item β€”
591// the admins-only install policy filters by it at read time.
592function testSpaceAppsAddFold(state, event) {
593 if (typeof event.app !== "string" || !event.app) return state;
594 return { apps: addItem(state.apps, { id: event.app, by: event.name || "", uid: event.uid || "", ts: event.ts || 0 }) };
595}
596
597// Test space-apps registry vectors (which apps are installed for the whole space).
598allVectors.spaceapps.forEach((vector) => {
599 test(`spaceapps: ${vector.name} β€” folds to the expected registry state`, () => {
600 const state = { apps: (vector.initial_state && vector.initial_state.apps) || [] };
601 const result = vector.event.type === "spaceapps.remove" ? testGroupAppsRemoveFold(state, vector.event) : testSpaceAppsAddFold(state, vector.event);
602 assert.deepStrictEqual(result.apps, vector.expected_state_update.apps, "registry state should match the vector exactly");
603 });
604});
605
606// Admin app fold handlers (web/static/apps/admin.js) β€” the update stream.
607// `seen` is the posted-entry watermark; note folds are idempotent, baselines merge.
608function testAdminNoteFold(state, event) {
609 if (typeof event.id !== "string" || !event.id || (state.seen || []).includes(event.id)) return state;
610 return { seen: [...(state.seen || []), event.id] };
611}
612
613function testAdminBaselineFold(state, event) {
614 if (!Array.isArray(event.ids)) return state;
615 return { seen: [...new Set([...(state.seen || []), ...event.ids.filter((x) => typeof x === "string" && x)])] };
616}
617
618// The chat projection: a note is a bubble from Admin (a plain string β€” never an
619// action line); a baseline is silent (null).
620function testAdminChatProjection(event) {
621 if (event.type === "admin.note" && event.text) return event.text;
622 return null;
623}
624
625// Test admin vectors (the update stream in place of the retired Announcer)
626allVectors.admin.forEach((vector) => {
627 test(`admin: ${vector.name} β€” folds to the expected state`, () => {
628 const state = { seen: (vector.initial_state && vector.initial_state.seen) || [] };
629 const result = vector.event.type === "admin.baseline" ? testAdminBaselineFold(state, vector.event) : testAdminNoteFold(state, vector.event);
630 assert.deepStrictEqual(result.seen, vector.expected_state_update.seen, "seen watermark should match the vector exactly");
631 });
632
633 if ("expected_chat_projection" in vector) {
634 test(`admin: ${vector.name} β€” chat projection matches`, () => {
635 const line = testAdminChatProjection(vector.event);
636 const expected = vector.expected_chat_projection;
637 if (expected === null) {
638 assert.strictEqual(line, null, "projection should be silent");
639 } else {
640 assert.strictEqual(typeof line, "string", "a note projects as a message bubble (a string), not an action line");
641 assert.strictEqual(line, expected.text, "bubble text should match");
642 assert.strictEqual(expected.action, false, "a note is never an action line");
643 }
644 });
645 }
646});
647
648// Views vectors (docs/views.md) β€” folded by the real handlers, so the clamps
649// (url schemes, caps, update-never-creates) are pinned exactly as shipped.
650allVectors.views.forEach((vector) => {
651 test(`views: ${vector.name} β€” folds to the expected state`, () => {
652 const state = { views: (vector.initial_state && vector.initial_state.views) || [] };
653 const fold = viewsApp.events[vector.event.type];
654 assert.strictEqual(typeof fold, "function", `views app owns ${vector.event.type}`);
655 const result = fold(state, vector.event);
656 assert.deepStrictEqual(result.views, vector.expected_state_update.views, "views state should match the vector exactly");
657 });
658});
659
660// πŸ€– Webhook vectors (docs/webhooks.md, #610) β€” the contract here is the
661// ABSENCE of handling: webhooks.<ns> events are unclaimed until a consumer app
662// registers the exact type, and unclaimed typed events must be silently
663// ignored. Tested against the REAL dispatch gate (isAppType/foldEvent from
664// apps.js) with the shipped webhooks management app loaded, so a future change
665// that accidentally claims (or crashes on) these types fails here. Native pins
666// the same contract in WebhooksVectorTests.swift (MessagePayload.isRenderableChat).
667allVectors.webhooks.forEach((vector) => {
668 test(`webhooks: ${vector.name} β€” unclaimed type is silently ignored`, () => {
669 assert.strictEqual(vector.expected, "ignored", "webhook vectors document the ignore contract");
670 assert.strictEqual(isAppType(vector.event.type), false, `${vector.event.type} must be unclaimed (no shipped app owns webhooks.* types)`);
671 const folded = foldEvent("thread-1", vector.event, { id: 1, ts: vector.event.ts });
672 assert.strictEqual(folded, null, "foldEvent must drop an unclaimed type without error");
673 });
674});
675
676// πŸ€– Vault vectors (docs/locked-vaults.md) β€” folded by the real reducer and
677// compared in CANONICAL form (every field present, ""/0 defaults, keyring null
678// when unlocked), the same normalisation the Go side applies, so web and CLI
679// pin each other through one file.
680function canonVault(state) {
681 const s = state || {};
682 const item = (i) => ({
683 id: i.id || "", name: i.name || "",
684 value: typeof i.value === "string" ? i.value : "",
685 sealed: typeof i.sealed === "string" ? i.sealed : "",
686 kind: i.kind || "text", by: i.by || "", byName: i.byName || "", at: i.at || 0,
687 updatedBy: i.updatedBy || "", updatedByName: i.updatedByName || "", updatedAt: i.updatedAt || 0,
688 });
689 const entry = (l) => ({ id: l.id || "", ref: l.ref || "", action: l.action || "", name: l.name || "", by: l.by || "", byName: l.byName || "", at: l.at || 0 });
690 const wrap = (w) => ({ id: w.id || "", kind: w.kind || "", label: w.label || "", uid: w.uid || "", credId: w.credId || "", wrapped: w.wrapped || "" });
691 return {
692 items: (s.items || []).map(item),
693 log: (s.log || []).map(entry),
694 keyring: s.keyring
695 ? { v: s.keyring.v || 0, pub: s.keyring.pub || "", wraps: (s.keyring.wraps || []).map(wrap) }
696 : null,
697 };
698}
699
700allVectors.vault.forEach((vector) => {
701 test(`vault: ${vector.name} β€” folds to the expected state`, () => {
702 let state; // undefined β€” reduce seeds initialState on the first event
703 for (const event of vector.event_sequence) {
704 state = vaultApp.reduce(state, event);
705 }
706 assert.deepStrictEqual(canonVault(state), canonVault(vector.expected_state), "vault state should match the vector exactly");
707 });
708});
709
710if (fails) process.exit(1);
711