| 1 | import assert from "assert"; |
| 2 | import { 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. |
| 6 | import { 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. |
| 9 | import { 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. |
| 13 | import { 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). |
| 16 | import { isAppType, foldEvent } from "../web/static/apps.js"; |
| 17 | import "../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. |
| 20 | import { 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. |
| 31 | function 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 | } |
| 36 | function 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. |
| 96 | function 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 | |
| 114 | function 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 | |
| 149 | function 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 | |
| 159 | function 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) |
| 164 | function addItem(items, item) { |
| 165 | if (items.some((i) => i.id === item.id)) return items; |
| 166 | return [...items, item]; |
| 167 | } |
| 168 | |
| 169 | function updateItem(items, id, updates) { |
| 170 | return items.map((i) => (i.id === id ? { ...i, ...updates } : i)); |
| 171 | } |
| 172 | |
| 173 | function removeItem(items, id) { |
| 174 | return items.filter((i) => i.id !== id); |
| 175 | } |
| 176 | |
| 177 | function 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 | |
| 183 | function testTodoDoneFold(state, event) { |
| 184 | return { items: updateItem(state.items, event.id, { done: true, doneAt: event.ts || 0 }) }; |
| 185 | } |
| 186 | |
| 187 | function testTodoReopenFold(state, event) { |
| 188 | return { items: updateItem(state.items, event.id, { done: false, doneAt: 0 }) }; |
| 189 | } |
| 190 | |
| 191 | function testTodoRemoveFold(state, event) { |
| 192 | return { items: removeItem(state.items, event.id) }; |
| 193 | } |
| 194 | |
| 195 | // Remind app fold handlers (web/static/apps/remind.js) |
| 196 | function 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 | |
| 211 | function testRemindFireFold(state, event) { |
| 212 | return { items: updateItem(state.items, event.id, { fired: true, firedAt: event.ts || 0 }) }; |
| 213 | } |
| 214 | |
| 215 | function 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) |
| 220 | function 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 | |
| 225 | function testGroupAppsRemoveFold(state, event) { |
| 226 | return { apps: removeItem(state.apps, event.app) }; |
| 227 | } |
| 228 | |
| 229 | // Test each vector |
| 230 | let passes = 0; |
| 231 | let fails = 0; |
| 232 | |
| 233 | function 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. |
| 249 | test("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 | |
| 272 | test("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 | |
| 283 | test("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 | |
| 291 | allVectors.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. |
| 586 | const { app: realTodoApp } = await import("../web/static/apps/todo.js"); |
| 587 | allVectors.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 |
| 603 | allVectors.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 |
| 659 | allVectors.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) |
| 716 | allVectors.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. |
| 727 | function 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). |
| 733 | allVectors.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. |
| 743 | function 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 | |
| 748 | function 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). |
| 755 | function 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) |
| 761 | allVectors.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. |
| 785 | allVectors.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). |
| 802 | allVectors.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. |
| 815 | function 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 | |
| 835 | allVectors.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. |
| 849 | allVectors.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 | |
| 881 | if (fails) process.exit(1); |
| 882 | |