eleven / ocho Public

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

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, quoteTarget, threadTarget, threadItems, threadListItems, threadListSignature, inMainFeed, bySidIndex, claimConverts, applyConverts, inlineChainIds } 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// πŸ€– Agents fold against the REAL reducer too β€” the session.* vocabulary is
19// the contract the workstation bridge (`lchat agents run`) builds against.
20import { app as agentsApp } from "../web/static/apps/agents.js";
21
22// Import chat.js fold handlers β€” the ones we're testing.
23// The app.hostEvents from web/static/apps/chat.js.
24// For testing purposes, we'll reconstruct them here so the test is self-contained.
25
26// Mirrors chat.js's chat.msg hostEvent β€” the SPEC, not a snapshot. Includes the
27// optimistic-send lifecycle: an emitted message folds PENDING (meta.pending, a
28// client id `_cid`, no server id) so it shows instantly; its own broadcast echo
29// (same _cid, now with a server id) UPGRADES that record in place rather than
30// duplicating it. Keep this in step with the real fold in web/static/apps/chat.js.
31function bySidPendingLast(a, b) {
32 if (a.sid && b.sid) return a.sid - b.sid;
33 if (!a.sid && !b.sid) return a.ts - b.ts;
34 return a.sid ? -1 : 1;
35}
36function testChatMsgFold(state, event, meta) {
37 const text = typeof event.text === "string" ? event.text : "";
38 const atts = Array.isArray(event.attachments) ? event.attachments.filter((a) => a && a.id) : [];
39 const sid = (meta && meta.id) || 0;
40 const cid = typeof event._cid === "string" ? event._cid : "";
41 const pending = !!(meta && meta.pending);
42 if ((!text.trim() && !atts.length) || (!sid && !pending)) return state;
43 if (sid && state.msgs.some((m) => m.sid === sid)) return state;
44 if (cid) {
45 const idx = state.msgs.findIndex((m) => m.cid === cid);
46 if (idx >= 0) {
47 if (!sid) return state;
48 const upgraded = state.msgs.slice();
49 upgraded[idx] = { ...upgraded[idx], sid, ts: event.ts || upgraded[idx].ts, pending: false, slow: false };
50 upgraded.sort(bySidPendingLast);
51 return { msgs: upgraded };
52 }
53 }
54 const raw = { name: event.name || "", text, ts: event.ts || ((meta && meta.ts) || 0) };
55 if (event.uid) raw.uid = event.uid;
56 if (atts.length) raw.attachments = atts.map((a) => ({ ...a }));
57 if (event.replyTo) raw.replyTo = { ...event.replyTo };
58 if (Array.isArray(event.actions) && event.actions.length) raw.actions = event.actions;
59 if (event.action) raw.action = true;
60 if (event.warn) raw.warn = true;
61 const msgs = [
62 ...state.msgs,
63 {
64 id: sid ? "m" + sid : "c" + cid,
65 sid,
66 cid,
67 pending,
68 slow: false,
69 text,
70 name: raw.name,
71 uid: event.uid || "",
72 ts: raw.ts,
73 attachments: raw.attachments || [],
74 reply: event.replyTo ? { id: event.replyTo.id || 0, name: event.replyTo.name || "", snippet: event.replyTo.snippet || "", thread: !!event.replyTo.thread } : null,
75 actions: raw.actions || [],
76 action: !!event.action,
77 warn: !!event.warn,
78 reactions: [],
79 edited: false,
80 raw,
81 rev: 0,
82 },
83 ];
84 msgs.sort(bySidPendingLast);
85 return { msgs };
86}
87
88// Fold a vector's message_sequence exactly as shell.js routes it: an ordinary
89// payload folds as chat.msg, a "threadconvert" payload as chat.convert β€” the
90// claim map (the REAL claimConverts, so the oldest-wins rule can't fork here)
91// projected onto the loaded window after EVERY step, which is what makes a
92// message arriving after its own conversion land in the thread anyway.
93//
94// `skipConverts` folds the same sequence the way a client too old to know the
95// payload type does: it drops the event entirely and the chain stays inline.
96function foldSequence(sequence, { skipConverts = false } = {}) {
97 let state = { msgs: [] };
98 let claims = {};
99 for (const step of sequence) {
100 const payload = step.decrypted_payload;
101 const meta = { id: step.server_message.id, ts: step.server_message.created_at * 1000 };
102 if (payload.type === "threadconvert") {
103 if (skipConverts) continue;
104 const next = claimConverts(claims, payload.root, payload.ids || [], meta.id);
105 if (next) claims = next;
106 } else {
107 state = testChatMsgFold(state, { type: "chat.msg", ...payload }, meta);
108 }
109 state = { ...state, msgs: applyConverts(state.msgs, claims) };
110 }
111 return state;
112}
113
114function testChatReactionFold(state, event, meta, ctx) {
115 if (event.targetSid === undefined || event.emoji === undefined || event.uid === undefined || event.on === undefined) return state;
116 const me = ctx && ctx.me;
117 const msgs = state.msgs.map((m) => {
118 if (m.sid !== event.targetSid) return m;
119 const reactionEvents = m.reactionEvents || [];
120 let found = false;
121 const updated = reactionEvents.map((ev) => {
122 if (ev.emoji === event.emoji && ev.uid === event.uid) {
123 found = true;
124 return { ...ev, on: event.on, ts: event.ts };
125 }
126 return ev;
127 });
128 if (!found && event.on) {
129 updated.push({ emoji: event.emoji, uid: event.uid, on: true, ts: event.ts });
130 }
131 const currentReactions = updated.filter((ev) => ev.on);
132 const derived = [];
133 const emojiMap = new Map();
134 for (const ev of currentReactions) {
135 if (!emojiMap.has(ev.emoji)) {
136 emojiMap.set(ev.emoji, []);
137 }
138 emojiMap.get(ev.emoji).push(ev.uid);
139 }
140 for (const [emoji, uids] of emojiMap) {
141 derived.push({ emoji, count: uids.length, mine: uids.includes(me) });
142 }
143 derived.sort((a, b) => a.emoji.localeCompare(b.emoji));
144 return { ...m, reactions: derived, reactionEvents: updated, rev: m.rev + 1 };
145 });
146 return { msgs };
147}
148
149function testChatEditFold(state, event) {
150 return {
151 msgs: state.msgs.map((m) => {
152 if (m.sid !== event.msgId) return m;
153 const text = typeof event.text === "string" ? event.text : m.text;
154 return { ...m, text, raw: { ...m.raw, text }, edited: true, rev: m.rev + 1 };
155 }),
156 };
157}
158
159function testChatDeleteFold(state, event) {
160 return { msgs: state.msgs.filter((m) => m.sid !== event.msgId) };
161}
162
163// Todo app fold handlers (web/static/apps/todo.js)
164function addItem(items, item) {
165 if (items.some((i) => i.id === item.id)) return items;
166 return [...items, item];
167}
168
169function updateItem(items, id, updates) {
170 return items.map((i) => (i.id === id ? { ...i, ...updates } : i));
171}
172
173function removeItem(items, id) {
174 return items.filter((i) => i.id !== id);
175}
176
177function testTodoAddFold(state, event) {
178 const text = typeof event.text === "string" ? event.text : "";
179 if (!text || !event.id) return state;
180 return { items: addItem(state.items, { id: event.id, text, done: false, doneAt: 0, by: event.name || "" }) };
181}
182
183function testTodoDoneFold(state, event) {
184 return { items: updateItem(state.items, event.id, { done: true, doneAt: event.ts || 0 }) };
185}
186
187function testTodoReopenFold(state, event) {
188 return { items: updateItem(state.items, event.id, { done: false, doneAt: 0 }) };
189}
190
191function testTodoRemoveFold(state, event) {
192 return { items: removeItem(state.items, event.id) };
193}
194
195// Remind app fold handlers (web/static/apps/remind.js)
196function testRemindAddFold(state, event) {
197 if (!event.id || !event.text || !event.when) return state;
198 const items = addItem(state.items, {
199 id: event.id,
200 text: event.text,
201 when: event.when,
202 ts: event.ts || 0,
203 sid: event.sid || "",
204 fired: false,
205 firedAt: 0,
206 });
207 items.sort((a, b) => a.when - b.when);
208 return { items };
209}
210
211function testRemindFireFold(state, event) {
212 return { items: updateItem(state.items, event.id, { fired: true, firedAt: event.ts || 0 }) };
213}
214
215function testRemindDoneFold(state, event) {
216 return { items: removeItem(state.items, event.id) };
217}
218
219// Group-apps registry fold handlers (web/static/apps.js β€” the groupapps app)
220function testGroupAppsAddFold(state, event) {
221 if (typeof event.app !== "string" || !event.app) return state;
222 return { apps: addItem(state.apps, { id: event.app, by: event.name || "", ts: event.ts || 0 }) };
223}
224
225function testGroupAppsRemoveFold(state, event) {
226 return { apps: removeItem(state.apps, event.app) };
227}
228
229// Test each vector
230let passes = 0;
231let fails = 0;
232
233function test(name, fn) {
234 try {
235 fn();
236 console.log(`PASS ${name}`);
237 passes++;
238 } catch (e) {
239 console.error(`FAIL ${name}`);
240 console.error(` ${e.message}`);
241 fails++;
242 }
243}
244
245// Optimistic-send lifecycle (the web/native contract for `_cid`). A message
246// folds PENDING the instant it's emitted, then its own echo silently upgrades
247// that same record β€” never a duplicate. Native must carry `_cid` through and
248// apply the same pairing.
249test("chat.optimistic: an emitted message folds pending, then its echo upgrades it in place", () => {
250 const cid = "cid-abc";
251 // 1. Optimistic local echo: no server id yet, pending, keyed by cid.
252 let state = testChatMsgFold({ msgs: [] }, { text: "hi there", name: "Ada", uid: "u1", ts: 1000, _cid: cid }, { cid, ts: 1000, pending: true });
253 assert.strictEqual(state.msgs.length, 1, "pending message shows immediately");
254 let m = state.msgs[0];
255 assert.strictEqual(m.sid, 0, "no server id while pending");
256 assert.strictEqual(m.pending, true, "flagged pending");
257 assert.strictEqual(m.cid, cid, "keyed by the client id");
258 assert.strictEqual(m.id, "c" + cid, "keyedList id is cid-stable so the bubble is never rebuilt");
259 // 2. The server broadcasts our own message back (same _cid, now a real id).
260 state = testChatMsgFold(state, { text: "hi there", name: "Ada", uid: "u1", ts: 1000, _cid: cid }, { id: 42, ts: 1000 });
261 assert.strictEqual(state.msgs.length, 1, "echo upgrades in place β€” no duplicate");
262 m = state.msgs[0];
263 assert.strictEqual(m.sid, 42, "adopts the server id");
264 assert.strictEqual(m.pending, false, "no longer pending");
265 assert.strictEqual(m.slow, false, "slow cleared");
266 assert.strictEqual(m.id, "c" + cid, "keyedList id stays stable across the upgrade");
267 // 3. A duplicate echo (catch-up on reopen) is idempotent.
268 const again = testChatMsgFold(state, { text: "hi there", name: "Ada", uid: "u1", ts: 1000, _cid: cid }, { id: 42, ts: 1000 });
269 assert.strictEqual(again.msgs.length, 1, "re-folding the confirmed message changes nothing");
270});
271
272test("chat.optimistic: a pending copy sorts after confirmed messages, then slots into id order on confirm", () => {
273 let state = testChatMsgFold({ msgs: [] }, { text: "first", ts: 10 }, { id: 100, ts: 10 });
274 state = testChatMsgFold(state, { text: "mine", ts: 20, _cid: "z" }, { cid: "z", ts: 20, pending: true });
275 assert.deepStrictEqual(state.msgs.map((m) => m.text), ["first", "mine"], "pending sits at the bottom");
276 // A higher-id message arriving before our echo still lands before the pending copy.
277 state = testChatMsgFold(state, { text: "later", ts: 30 }, { id: 200, ts: 30 });
278 assert.deepStrictEqual(state.msgs.map((m) => m.text), ["first", "later", "mine"], "pending stays last until confirmed");
279 state = testChatMsgFold(state, { text: "mine", ts: 20, _cid: "z" }, { id: 150, ts: 20 });
280 assert.deepStrictEqual(state.msgs.map((m) => m.text), ["first", "mine", "later"], "on confirm it slots into server order");
281});
282
283test("chat.optimistic: a normal server message carrying _cid still folds its text (native carry-and-ignore)", () => {
284 const state = testChatMsgFold({ msgs: [] }, { text: "hello", name: "Bo", ts: 5, _cid: "unmatched" }, { id: 7, ts: 5 });
285 assert.strictEqual(state.msgs.length, 1, "folds like any message");
286 assert.strictEqual(state.msgs[0].text, "hello", "_cid never leaks into the text");
287 assert.strictEqual(state.msgs[0].sid, 7, "real id");
288 assert.strictEqual(state.msgs[0].pending, false, "not pending β€” it arrived confirmed");
289});
290
291allVectors.chat.forEach((vector) => {
292 if (vector.expected_fold_event?.type === "chat.msg") {
293 test(`chat.msg: ${vector.name} β€” folds correctly and produces expected event shape`, () => {
294 const state = { msgs: [] };
295 const event = {
296 type: "chat.msg",
297 ...vector.decrypted_payload,
298 };
299 const meta = { id: vector.server_message.id, ts: vector.server_message.created_at * 1000 };
300 const result = testChatMsgFold(state, event, meta);
301
302 assert.strictEqual(result.msgs.length, 1, "should have one message");
303 const msg = result.msgs[0];
304 assert.strictEqual(msg.name, vector.expected_fold_event.name, `name should be '${vector.expected_fold_event.name}'`);
305 assert.strictEqual(msg.text, vector.expected_fold_event.text, "text should match");
306 assert.strictEqual(msg.uid, vector.expected_fold_event.uid || "", "uid should match");
307 assert.strictEqual(msg.ts, vector.expected_fold_event.ts, "ts should match (ms)");
308 assert.strictEqual(msg.action, vector.expected_fold_event.action || false, "action flag should match");
309 assert.strictEqual(msg.warn, vector.expected_fold_event.warn || false, "warn flag should match");
310
311 // Attachments
312 if (vector.expected_fold_event.attachments) {
313 assert.deepStrictEqual(msg.attachments, vector.expected_fold_event.attachments, "attachments should match exactly");
314 } else {
315 assert.strictEqual(msg.attachments.length, 0, "attachments should be empty");
316 }
317
318 // Reply quote
319 if (vector.expected_fold_event.replyTo) {
320 assert.notStrictEqual(msg.reply, null, "reply should be present");
321 assert.strictEqual(msg.reply.id, vector.expected_fold_event.replyTo.id, "reply id should match");
322 assert.strictEqual(msg.reply.name, vector.expected_fold_event.replyTo.name, "reply name should match");
323 assert.strictEqual(msg.reply.snippet, vector.expected_fold_event.replyTo.snippet, "reply snippet should match");
324 // The thread flag (additive): true only when the payload says so β€”
325 // absent folds to false, so pre-flag history stays an inline reply.
326 assert.strictEqual(msg.reply.thread, !!vector.expected_fold_event.replyTo.thread, "reply.thread should match the payload's flag");
327 } else {
328 assert.strictEqual(msg.reply, null, "reply should be null");
329 }
330 // Main-feed visibility β€” against the REAL predicate and the REAL
331 // transcript filter. expected_main_feed_visible is evaluated WITH the
332 // quoted root in the loaded window (seeded here from the replyTo
333 // snapshot): a thread-only reply then never lists in the feed (its
334 // root's tally is its only trace there); a quote reply lists as always.
335 // With the root paged out EVERY reply shows β€” the degradation the
336 // thread_reply_root_unloaded vector pins.
337 if (vector.expected_main_feed_visible !== undefined) {
338 const rt = vector.decrypted_payload.replyTo;
339 const seeded = rt
340 ? testChatMsgFold(result, { type: "chat.msg", name: rt.name, text: rt.snippet, ts: msg.ts - 1000 }, { id: rt.id, ts: msg.ts - 1000 })
341 : result;
342 assert.strictEqual(inMainFeed(msg, bySidIndex(seeded.msgs)), vector.expected_main_feed_visible, "inMainFeed (root loaded) should match expected_main_feed_visible");
343 const shown = viewItems(seeded.msgs, { mainFeed: true }).some((i) => i.sid === msg.sid);
344 assert.strictEqual(shown, vector.expected_main_feed_visible, "the transcript filter (root loaded) should agree with expected_main_feed_visible");
345 assert.strictEqual(viewItems(seeded.msgs).some((i) => i.sid === msg.sid), true, "the unfiltered list (thread view) always keeps the reply");
346 assert.strictEqual(inMainFeed(msg, bySidIndex(result.msgs)), true, "root NOT loaded β€” every reply shows inline (nowhere else could carry it)");
347 assert.strictEqual(viewItems(result.msgs, { mainFeed: true }).some((i) => i.sid === msg.sid), true, "the transcript filter agrees: rootless β†’ shown");
348 }
349 });
350 }
351
352 // Main-feed visibility over a folded SEQUENCE, against the REAL filter: a
353 // thread-flagged reply hides only while its resolved root is in the
354 // sequence (root loaded β†’ its bubble carries the tally); rootless it shows
355 // inline; every other message shows.
356 if (vector.message_sequence && vector.expected_main_feed) {
357 test(`chat main feed: ${vector.name} β€” thread replies hide only under a loaded root`, () => {
358 const state = foldSequence(vector.message_sequence);
359 const bySid = bySidIndex(state.msgs);
360 const main = viewItems(state.msgs, { mainFeed: true });
361 const shown = new Set(main.map((i) => i.sid));
362 for (const [sid, visible] of Object.entries(vector.expected_main_feed)) {
363 assert.strictEqual(shown.has(Number(sid)), visible, `message ${sid} should be ${visible ? "shown in" : "hidden from"} the main feed`);
364 assert.strictEqual(inMainFeed(state.msgs.find((x) => x.sid === Number(sid)), bySid), visible, `inMainFeed(${sid}) should be ${visible}`);
365 }
366 assert.strictEqual(viewItems(state.msgs).length, state.msgs.length, "the unfiltered list (thread view) keeps every message");
367 if (vector.expected_threads) {
368 const mainBySid = new Map(main.map((i) => [i.sid, i]));
369 for (const [root, count] of Object.entries(vector.expected_threads.reply_counts)) {
370 // A message hidden in its own thread has no transcript bubble to
371 // carry a tally β€” the rollup itself is checked below, unfiltered.
372 const row = mainBySid.get(Number(root));
373 if (!row) continue;
374 assert.strictEqual(row.replyCount, count, `root ${root}'s transcript tally should count its thread replies (and only those)`);
375 }
376 }
377 // The degradation an OLD client shows: it doesn't know the payload type,
378 // so it drops the conversion and keeps rendering the chain inline β€”
379 // exactly the feed it rendered before this shipped. Nothing breaks, and
380 // it collapses the moment that client updates.
381 if (vector.expected_main_feed_unconverted) {
382 const old = foldSequence(vector.message_sequence, { skipConverts: true });
383 const oldShown = new Set(viewItems(old.msgs, { mainFeed: true }).map((i) => i.sid));
384 for (const [sid, visible] of Object.entries(vector.expected_main_feed_unconverted)) {
385 assert.strictEqual(oldShown.has(Number(sid)), visible, `ignoring the conversion, message ${sid} should be ${visible ? "shown" : "hidden"} β€” an old client's feed`);
386 }
387 }
388 });
389 }
390
391 // The Threads list, over the same folded sequences: one row per thread root,
392 // newest activity first. Its own loop (not folded into the grouping test
393 // below) so it also covers thread_reply_root_unloaded, which carries no
394 // expected_threads β€” and that vector is the whole point of `root_loaded`.
395 if (vector.expected_thread_list) {
396 test(`chat threads list: ${vector.name} β€” one row per thread, freshest first`, () => {
397 const state = foldSequence(vector.message_sequence);
398 const rows = threadListItems(state.msgs);
399 assert.deepStrictEqual(
400 rows.map((r) => ({ root: r.rootSid, root_loaded: r.rootLoaded, name: r.name, snippet: r.snippet, count: r.count })),
401 vector.expected_thread_list.rows,
402 "the Threads list's rows, in the order it shows them");
403 // The open panel's repaint gate reads the SHOWN fields and nothing else:
404 // a fold that only moves the row's `last` pointer (an ordinary reply
405 // landing in a thread already top of the list) must not rebuild the
406 // buttons under a keyboard user, while anything the row displays must.
407 if (rows.length) {
408 const sig = threadListSignature(rows);
409 assert.strictEqual(threadListSignature(rows.map((r) => ({ ...r, last: { sid: 999999 } }))), sig,
410 "a change to `last` alone β€” which the row never shows β€” must not repaint");
411 for (const [field, changed] of [["count", 42], ["name", "Someone else"], ["snippet", "different words"]]) {
412 assert.notStrictEqual(threadListSignature([{ ...rows[0], [field]: changed }, ...rows.slice(1)]), sig,
413 `a change to ${field} β€” which the row shows β€” must repaint`);
414 }
415 }
416 });
417 }
418
419 // Reply-thread grouping vectors: fold the whole sequence, then assert the
420 // thread rollup. A thread is its root plus the replies FLAGGED into it β€”
421 // an inline quote chain resolves to nothing and tallies nowhere β€” plus the
422 // two compose verbs (quoteTarget = the exact bubble, threadTarget = the root).
423 if (vector.expected_threads) {
424 test(`chat threads: ${vector.name} β€” only flagged replies join a thread`, () => {
425 const state = foldSequence(vector.message_sequence);
426 const items = viewItems(state.msgs);
427 const bySid = new Map(items.map((i) => [i.sid, i]));
428 for (const [sid, root] of Object.entries(vector.expected_threads.roots)) {
429 assert.strictEqual(bySid.get(Number(sid))?.replyRoot, root, root ? `message ${sid} should belong to thread ${root}` : `message ${sid} should belong to NO thread`);
430 }
431 for (const [root, count] of Object.entries(vector.expected_threads.reply_counts)) {
432 assert.strictEqual(bySid.get(Number(root))?.replyCount, count, `${root} should tally ${count} thread replies`);
433 }
434 for (const [root, first] of Object.entries(vector.expected_threads.first_reply)) {
435 const firstReply = items.find((i) => i.replyRoot === Number(root));
436 assert.strictEqual(firstReply && firstReply.sid, first, `first reply in root ${root}'s thread should be ${first}`);
437 }
438 // The thread view's own list, straight off the pure selector.
439 for (const [root, ids] of Object.entries(vector.expected_thread_items || {})) {
440 if (root === "note") continue;
441 assert.deepStrictEqual(threadItems(state.msgs, Number(root)).map((m) => m.sid), ids, `threadItems(${root}) should be exactly ${JSON.stringify(ids)}`);
442 }
443 // What "Convert to thread" would move from here β€” the same question the
444 // menus ask to decide whether to offer the verb at all. Empty means the
445 // message has no inline chain under it (nothing to convert), which is
446 // also the state a converted chain leaves behind.
447 for (const [root, ids] of Object.entries(vector.expected_inline_chain || {})) {
448 assert.deepStrictEqual(inlineChainIds(state.msgs, Number(root)), ids, `inlineChainIds(${root}) should be exactly ${JSON.stringify(ids)}`);
449 }
450 // The compose seam: plain Reply quotes the exact message, Reply in
451 // thread names the thread's root (or the message itself when it's in
452 // no thread β€” that message becomes the new root).
453 const targets = vector.expected_reply_targets;
454 if (targets) {
455 for (const [sid, want] of Object.entries(targets.quote || {})) {
456 const m = state.msgs.find((x) => x.sid === Number(sid));
457 assert.deepStrictEqual(quoteTarget(m), want, `quoteTarget(${sid}) β€” plain Reply targets the exact message clicked`);
458 }
459 for (const [sid, want] of Object.entries(targets.thread || {})) {
460 const m = state.msgs.find((x) => x.sid === Number(sid));
461 assert.deepStrictEqual(threadTarget(state.msgs, m), want, `threadTarget(${sid}) β€” Reply in thread targets the thread's root`);
462 }
463 }
464 });
465 }
466
467 if (vector.expected_fold_event?.type === "chat.reaction") {
468 test(`chat.reaction: ${vector.name} β€” folds the encrypted reaction event into the target message`, () => {
469 const p = vector.decrypted_payload;
470 // Seed the target message with the reactor's reaction already on when the
471 // vector removes it, so on=false has something to fold away.
472 const seeded = p.on ? [] : [{ emoji: p.emoji, uid: p.uid, on: true, ts: p.ts - 1 }];
473 const state = { msgs: [{ sid: p.targetSid, uid: p.targetUid || "", reactions: [], reactionEvents: seeded, rev: 0 }] };
474 const event = { type: "chat.reaction", targetSid: p.targetSid, emoji: p.emoji, uid: p.uid, on: p.on, ts: p.ts };
475 assert.deepStrictEqual(
476 { type: event.type, targetSid: event.targetSid, emoji: event.emoji, uid: event.uid, on: event.on },
477 vector.expected_fold_event,
478 "fold event derived from the payload should match the vector"
479 );
480 const result = testChatReactionFold(state, event, {}, { me: "user-someone-else" });
481 assert.strictEqual(result.msgs.length, 1, "should have one message");
482 const msg = result.msgs[0];
483 if (p.on) {
484 assert.deepStrictEqual(msg.reactions, [{ emoji: p.emoji, count: 1, mine: false }], "add derives a count of 1, not mine");
485 } else {
486 assert.deepStrictEqual(msg.reactions, [], "remove folds the reaction away");
487 }
488 assert.strictEqual(msg.rev, 1, "a reaction bumps the rev");
489 // Folding the same event again is idempotent on the derived reactions.
490 const again = testChatReactionFold(result, event, {}, { me: "user-someone-else" });
491 assert.deepStrictEqual(again.msgs[0].reactions, msg.reactions, "re-fold is idempotent");
492 });
493 }
494
495 if (vector.expected_fold_event?.type === "chat.edit") {
496 test(`chat.edit: ${vector.name} β€” edits message text and marks as edited`, () => {
497 const state = { msgs: [{ sid: vector.server_message.id, text: vector.original_payload.text, edited: false, rev: 0, raw: { text: vector.original_payload.text } }] };
498 const event = {
499 type: "chat.edit",
500 msgId: vector.server_message.id,
501 text: vector.expected_fold_event.text,
502 };
503 const result = testChatEditFold(state, event);
504
505 assert.strictEqual(result.msgs.length, 1, "should have one message");
506 const msg = result.msgs[0];
507 assert.strictEqual(msg.text, vector.expected_fold_event.text, "text should match edit");
508 assert.strictEqual(msg.edited, true, "edited flag should be true");
509 assert.strictEqual(msg.rev, 1, "rev should increment");
510 });
511 }
512
513 if (vector.name === "edit") {
514 test("chat.edit over a pre-seeded store: history fold's text overwrites stale cached text (#457)", () => {
515 // The offline-edit path over the local message store: the cache seeded
516 // state with the ORIGINAL text; the reconcile refetch folds the edited
517 // row β€” its chat.msg dedupes against the stale bubble (same sid), so
518 // the synthetic chat.edit MUST carry the post-edit text or the stale
519 // text survives forever. This is why shell.js's history fold includes
520 // `text` β€” do not drop it as redundant (see the vector's note).
521 const sid = vector.server_message.id;
522 // 1. cached paint: the pre-edit payload folds in
523 let state = testChatMsgFold({ msgs: [] }, { ...vector.original_payload }, { id: sid, ts: vector.original_payload.ts });
524 assert.strictEqual(state.msgs[0].text, vector.original_payload.text, "cache seeded the original text");
525 // 2. reconcile: the refetched (edited) row's chat.msg is a dedupe no-op…
526 state = testChatMsgFold(state, { ...vector.edited_payload }, { id: sid, ts: vector.edited_payload.ts });
527 assert.strictEqual(state.msgs[0].text, vector.original_payload.text, "chat.msg dedupes β€” stale text still shown");
528 // …so only the history chat.edit (WITH text) can heal the bubble.
529 state = testChatEditFold(state, { type: "chat.edit", msgId: sid, text: vector.edited_payload.text });
530 assert.strictEqual(state.msgs[0].text, vector.expected_fold_event.text, "edit fold healed the stale text");
531 assert.strictEqual(state.msgs[0].edited, true, "marked edited");
532 });
533 }
534
535 if (vector.expected_fold_event?.type === "chat.delete") {
536 test(`chat.delete: ${vector.name} β€” removes message from state`, () => {
537 const msgId = vector.expected_fold_event.msgId;
538 const state = {
539 msgs: [
540 { sid: msgId - 1, text: "before" },
541 { sid: msgId, text: "deleted" },
542 { sid: msgId + 1, text: "after" },
543 ],
544 };
545 const event = {
546 type: "chat.delete",
547 msgId: msgId,
548 };
549 const result = testChatDeleteFold(state, event);
550
551 assert.strictEqual(result.msgs.length, 2, "should have two messages");
552 assert.strictEqual(result.msgs[0].sid, msgId - 1, "first message preserved");
553 assert.strictEqual(result.msgs[1].sid, msgId + 1, "last message preserved");
554 assert(!result.msgs.some((m) => m.sid === msgId), "deleted message should not be present");
555 });
556 }
557
558 if (vector.name === "tombstone_delete") {
559 test(`tombstone: ${vector.name} β€” stored row folds like the live delete frame`, () => {
560 // The translation rule (shell.js chatAppFoldMessage / foldAppThreadMessage):
561 // a history row carrying tombstone_of folds { type:"chat.delete",
562 // msgId: tombstone_of } β€” byte-identical to the live frame's fold event,
563 // so there is exactly one delete path.
564 const row = vector.server_message;
565 assert.strictEqual(row.blob, "", "tombstone rows carry no ciphertext");
566 assert(row.id > row.tombstone_of, "tombstone id is fresh β€” above the id it unsends");
567 const event = { type: "chat.delete", msgId: row.tombstone_of };
568 assert.deepStrictEqual(event, vector.expected_fold_event, "translation matches the pinned fold event");
569
570 const state = { msgs: [{ sid: row.tombstone_of, text: "unsent" }, { sid: row.tombstone_of + 1, text: "after" }] };
571 const result = testChatDeleteFold(state, event);
572 assert(!result.msgs.some((m) => m.sid === row.tombstone_of), "target dropped from state");
573 assert.strictEqual(result.msgs.length, 1, "only the target dropped");
574
575 // A client that never held the message (fresh load) folds it as a no-op.
576 const fresh = testChatDeleteFold({ msgs: [{ sid: 1, text: "x" }] }, event);
577 assert.strictEqual(fresh.msgs.length, 1, "no-op on a client with no local copy");
578 });
579 }
580});
581
582// The GROUP chat projections are cross-platform spec (AppChatProjection
583// mirrors the wording natively): run the REAL app's chatMessage against any
584// vector that declares one β€” the fold tests above use local handlers, but
585// wording must come from the shipping module.
586const { app: realTodoApp } = await import("../web/static/apps/todo.js");
587allVectors.todo.forEach((vector) => {
588 if (!("expected_group_chat_projection" in vector)) return;
589 test(`todo group projection: ${vector.name}`, () => {
590 const line = realTodoApp.chatMessage(vector.event, { group: true });
591 const expected = vector.expected_group_chat_projection;
592 if (expected === null) {
593 assert.strictEqual(line, null);
594 return;
595 }
596 const got = typeof line === "string" ? { text: line, action: false }
597 : { text: line.text, action: !!line.action };
598 assert.deepStrictEqual(got, expected);
599 });
600});
601
602// Test todo vectors
603allVectors.todo.forEach((vector) => {
604 if (vector.name === "todo_add") {
605 test(`todo.add: ${vector.name} β€” folds correctly`, () => {
606 const state = { items: [] };
607 const event = vector.event;
608 const result = testTodoAddFold(state, event);
609
610 assert.strictEqual(result.items.length, 1, "should have one item");
611 const item = result.items[0];
612 assert.strictEqual(item.id, vector.expected_state_update.items[0].id, "id should match");
613 assert.strictEqual(item.text, vector.expected_state_update.items[0].text, "text should match");
614 assert.strictEqual(item.done, false, "done should be false");
615 assert.strictEqual(item.doneAt, 0, "doneAt should be 0");
616 assert.strictEqual(item.by, vector.expected_state_update.items[0].by, "by should match");
617 });
618 }
619
620 if (vector.name === "todo_done") {
621 test(`todo.done: ${vector.name} β€” marks as done with timestamp`, () => {
622 const state = { items: vector.initial_state.items };
623 const event = vector.event;
624 const result = testTodoDoneFold(state, event);
625
626 assert.strictEqual(result.items.length, 1, "should have one item");
627 const item = result.items[0];
628 assert.strictEqual(item.done, true, "done should be true");
629 assert.strictEqual(item.doneAt, vector.expected_state_update.items[0].doneAt, "doneAt should match");
630 });
631 }
632
633 if (vector.name === "todo_reopen") {
634 test(`todo.reopen: ${vector.name} β€” unmarks as done`, () => {
635 const state = { items: vector.initial_state.items };
636 const event = vector.event;
637 const result = testTodoReopenFold(state, event);
638
639 assert.strictEqual(result.items.length, 1, "should have one item");
640 const item = result.items[0];
641 assert.strictEqual(item.done, false, "done should be false");
642 assert.strictEqual(item.doneAt, 0, "doneAt should be 0");
643 });
644 }
645
646 if (vector.name === "todo_remove") {
647 test(`todo.remove: ${vector.name} β€” deletes item`, () => {
648 const state = { items: vector.initial_state.items };
649 const event = vector.event;
650 const result = testTodoRemoveFold(state, event);
651
652 assert.strictEqual(result.items.length, 1, "should have one item left");
653 assert.strictEqual(result.items[0].id, "todo-2", "remaining item should be todo-2");
654 });
655 }
656});
657
658// Test remind vectors
659allVectors.remind.forEach((vector) => {
660 if (vector.name === "remind_add") {
661 test(`remind.add: ${vector.name} β€” folds correctly`, () => {
662 const state = { items: [] };
663 const event = vector.event;
664 const result = testRemindAddFold(state, event);
665
666 assert.strictEqual(result.items.length, 1, "should have one item");
667 const item = result.items[0];
668 assert.strictEqual(item.id, vector.expected_state_update.items[0].id, "id should match");
669 assert.strictEqual(item.text, vector.expected_state_update.items[0].text, "text should match");
670 assert.strictEqual(item.when, vector.expected_state_update.items[0].when, "when should match");
671 assert.strictEqual(item.sid, vector.expected_state_update.items[0].sid, "sid should match");
672 assert.strictEqual(item.fired, false, "fired should be false");
673 });
674 }
675
676 if (vector.name === "remind_fire") {
677 test(`remind.fire: ${vector.name} β€” marks as fired`, () => {
678 const state = { items: vector.initial_state.items };
679 const event = vector.event;
680 const result = testRemindFireFold(state, event);
681
682 assert.strictEqual(result.items.length, 1, "should have one item");
683 const item = result.items[0];
684 assert.strictEqual(item.fired, true, "fired should be true");
685 assert.strictEqual(item.firedAt, vector.expected_state_update.items[0].firedAt, "firedAt should match");
686 });
687 }
688
689 if (vector.name === "remind_done") {
690 test(`remind.done: ${vector.name} β€” removes reminder`, () => {
691 const state = { items: vector.initial_state.items };
692 const event = vector.event;
693 const result = testRemindDoneFold(state, event);
694
695 assert.strictEqual(result.items.length, 0, "should have no items");
696 });
697 }
698
699 if (vector.name === "remind_multiple_sorted") {
700 test(`remind.multiple_sorted: ${vector.name} β€” keeps items sorted by when`, () => {
701 let state = { items: [] };
702 for (const e of vector.event_sequence) {
703 state = testRemindAddFold(state, e);
704 }
705
706 assert.strictEqual(state.items.length, 2, "should have two items");
707 assert.strictEqual(state.items[0].id, "remind-2", "first item should be remind-2 (soonest)");
708 assert.strictEqual(state.items[1].id, "remind-1", "second item should be remind-1 (latest)");
709 assert.strictEqual(state.items[0].when, 1700003600000, "first should be soonest");
710 assert.strictEqual(state.items[1].when, 1700010000000, "second should be latest");
711 });
712 }
713});
714
715// Test group-apps registry vectors (which apps a group chat has added)
716allVectors.groupapps.forEach((vector) => {
717 test(`groupapps: ${vector.name} β€” folds to the expected registry state`, () => {
718 const state = { apps: (vector.initial_state && vector.initial_state.apps) || [] };
719 const result = vector.event.type === "groupapps.remove" ? testGroupAppsRemoveFold(state, vector.event) : testGroupAppsAddFold(state, vector.event);
720 assert.deepStrictEqual(result.apps, vector.expected_state_update.apps, "registry state should match the vector exactly");
721 });
722});
723
724// Space-apps registry fold handlers (web/static/apps.js β€” the spaceapps app).
725// The groupapps contract one level up, plus the adder's uid on each item β€”
726// the admins-only install policy filters by it at read time.
727function testSpaceAppsAddFold(state, event) {
728 if (typeof event.app !== "string" || !event.app) return state;
729 return { apps: addItem(state.apps, { id: event.app, by: event.name || "", uid: event.uid || "", ts: event.ts || 0 }) };
730}
731
732// Test space-apps registry vectors (which apps are installed for the whole space).
733allVectors.spaceapps.forEach((vector) => {
734 test(`spaceapps: ${vector.name} β€” folds to the expected registry state`, () => {
735 const state = { apps: (vector.initial_state && vector.initial_state.apps) || [] };
736 const result = vector.event.type === "spaceapps.remove" ? testGroupAppsRemoveFold(state, vector.event) : testSpaceAppsAddFold(state, vector.event);
737 assert.deepStrictEqual(result.apps, vector.expected_state_update.apps, "registry state should match the vector exactly");
738 });
739});
740
741// Admin app fold handlers (web/static/apps/admin.js) β€” the update stream.
742// `seen` is the posted-entry watermark; note folds are idempotent, baselines merge.
743function testAdminNoteFold(state, event) {
744 if (typeof event.id !== "string" || !event.id || (state.seen || []).includes(event.id)) return state;
745 return { seen: [...(state.seen || []), event.id] };
746}
747
748function testAdminBaselineFold(state, event) {
749 if (!Array.isArray(event.ids)) return state;
750 return { seen: [...new Set([...(state.seen || []), ...event.ids.filter((x) => typeof x === "string" && x)])] };
751}
752
753// The chat projection: a note is a bubble from Admin (a plain string β€” never an
754// action line); a baseline is silent (null).
755function testAdminChatProjection(event) {
756 if (event.type === "admin.note" && event.text) return event.text;
757 return null;
758}
759
760// Test admin vectors (the update stream in place of the retired Announcer)
761allVectors.admin.forEach((vector) => {
762 test(`admin: ${vector.name} β€” folds to the expected state`, () => {
763 const state = { seen: (vector.initial_state && vector.initial_state.seen) || [] };
764 const result = vector.event.type === "admin.baseline" ? testAdminBaselineFold(state, vector.event) : testAdminNoteFold(state, vector.event);
765 assert.deepStrictEqual(result.seen, vector.expected_state_update.seen, "seen watermark should match the vector exactly");
766 });
767
768 if ("expected_chat_projection" in vector) {
769 test(`admin: ${vector.name} β€” chat projection matches`, () => {
770 const line = testAdminChatProjection(vector.event);
771 const expected = vector.expected_chat_projection;
772 if (expected === null) {
773 assert.strictEqual(line, null, "projection should be silent");
774 } else {
775 assert.strictEqual(typeof line, "string", "a note projects as a message bubble (a string), not an action line");
776 assert.strictEqual(line, expected.text, "bubble text should match");
777 assert.strictEqual(expected.action, false, "a note is never an action line");
778 }
779 });
780 }
781});
782
783// Views vectors (docs/views.md) β€” folded by the real handlers, so the clamps
784// (url schemes, caps, update-never-creates) are pinned exactly as shipped.
785allVectors.views.forEach((vector) => {
786 test(`views: ${vector.name} β€” folds to the expected state`, () => {
787 const state = { views: (vector.initial_state && vector.initial_state.views) || [] };
788 const fold = viewsApp.events[vector.event.type];
789 assert.strictEqual(typeof fold, "function", `views app owns ${vector.event.type}`);
790 const result = fold(state, vector.event);
791 assert.deepStrictEqual(result.views, vector.expected_state_update.views, "views state should match the vector exactly");
792 });
793});
794
795// πŸ€– Webhook vectors (docs/webhooks.md, #610) β€” the contract here is the
796// ABSENCE of handling: webhooks.<ns> events are unclaimed until a consumer app
797// registers the exact type, and unclaimed typed events must be silently
798// ignored. Tested against the REAL dispatch gate (isAppType/foldEvent from
799// apps.js) with the shipped webhooks management app loaded, so a future change
800// that accidentally claims (or crashes on) these types fails here. Native pins
801// the same contract in WebhooksVectorTests.swift (MessagePayload.isRenderableChat).
802allVectors.webhooks.forEach((vector) => {
803 test(`webhooks: ${vector.name} β€” unclaimed type is silently ignored`, () => {
804 assert.strictEqual(vector.expected, "ignored", "webhook vectors document the ignore contract");
805 assert.strictEqual(isAppType(vector.event.type), false, `${vector.event.type} must be unclaimed (no shipped app owns webhooks.* types)`);
806 const folded = foldEvent("thread-1", vector.event, { id: 1, ts: vector.event.ts });
807 assert.strictEqual(folded, null, "foldEvent must drop an unclaimed type without error");
808 });
809});
810
811// πŸ€– Vault vectors (docs/locked-vaults.md) β€” folded by the real reducer and
812// compared in CANONICAL form (every field present, ""/0 defaults, keyring null
813// when unlocked), the same normalisation the Go side applies, so web and CLI
814// pin each other through one file.
815function canonVault(state) {
816 const s = state || {};
817 const item = (i) => ({
818 id: i.id || "", name: i.name || "",
819 value: typeof i.value === "string" ? i.value : "",
820 sealed: typeof i.sealed === "string" ? i.sealed : "",
821 kind: i.kind || "text", by: i.by || "", byName: i.byName || "", at: i.at || 0,
822 updatedBy: i.updatedBy || "", updatedByName: i.updatedByName || "", updatedAt: i.updatedAt || 0,
823 });
824 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 });
825 const wrap = (w) => ({ id: w.id || "", kind: w.kind || "", label: w.label || "", uid: w.uid || "", credId: w.credId || "", wrapped: w.wrapped || "" });
826 return {
827 items: (s.items || []).map(item),
828 log: (s.log || []).map(entry),
829 keyring: s.keyring
830 ? { v: s.keyring.v || 0, pub: s.keyring.pub || "", wraps: (s.keyring.wraps || []).map(wrap) }
831 : null,
832 };
833}
834
835allVectors.vault.forEach((vector) => {
836 test(`vault: ${vector.name} β€” folds to the expected state`, () => {
837 let state; // undefined β€” reduce seeds initialState on the first event
838 for (const event of vector.event_sequence) {
839 state = vaultApp.reduce(state, event);
840 }
841 assert.deepStrictEqual(canonVault(state), canonVault(vector.expected_state), "vault state should match the vector exactly");
842 });
843});
844
845// πŸ€– Agents vectors (docs/superpowers/specs/2026-08-05-eleven-claude-agents-design.md)
846// β€” folded by the REAL reducer: the fleet-board fold's whole job is clamping
847// what an unsigned group-thread payload claims, so a reconstruction would test
848// the wrong thing. The bridge (`lchat agents run`) builds against this file.
849allVectors.agents.forEach((vector) => {
850 test(`agents: ${vector.name} β€” folds to the expected state`, () => {
851 let state;
852 if (vector.event_sequence) {
853 for (const event of vector.event_sequence) state = agentsApp.reduce(state, event);
854 assert.deepStrictEqual(state.sessions, vector.expected_state.sessions, "agents state should match the vector exactly");
855 } else {
856 state = { sessions: (vector.initial_state && vector.initial_state.sessions) || [] };
857 const fold = agentsApp.events[vector.event.type];
858 assert.strictEqual(typeof fold, "function", `agents app owns ${vector.event.type}`);
859 const result = fold(state, vector.event);
860 assert.deepStrictEqual(result.sessions, vector.expected_state_update.sessions, "agents state should match the vector exactly");
861 }
862 });
863
864 if ("expected_group_chat_projection" in vector) {
865 test(`agents: ${vector.name} β€” group feed projection matches`, () => {
866 const event = vector.expected_group_chat_projection_of || vector.event;
867 const line = agentsApp.groupFeedMessage(event);
868 const expected = vector.expected_group_chat_projection;
869 if (expected === null) {
870 assert.strictEqual(line, null, "projection should be silent");
871 return;
872 }
873 const text = typeof line === "string" ? line : line && line.text;
874 const action = typeof line === "object" && line !== null && !!line.action;
875 assert.strictEqual(text, expected.text, "projection text should match");
876 assert.strictEqual(action, !!expected.action, "action-vs-bubble should match");
877 });
878 }
879});
880
881if (fails) process.exit(1);
882