// Persistent thread context and the actual thread loader, with relay queries // controlled in-process. No browser, network access, or publishing required. // Run: bun tools/thread-cache-test.js import assert from 'node:assert/strict'; import { rm } from 'node:fs/promises'; import { createThreadStore } from '../src/thread-cache.js'; const memory = () => { const values = new Map(); return { getItem: (k) => values.get(k) ?? null, setItem: (k, v) => values.set(k, v) }; }; const storage = memory(); globalThis.localStorage = storage; const id = (n) => n.toString(16).padStart(64, '0'); const note = (n, tags = []) => ({ id: id(n), pubkey: id(900), kind: 1, content: 'Post ' + n, created_at: n, tags }); const root = note(1), parent = note(2, [['e', root.id, '', 'reply']]), reply = note(3, [['e', parent.id, '', 'reply']]); const check = (name, fn) => { fn(); console.log('✓ ' + name); }; const store = createThreadStore(storage); check('a fresh store restores full content and tags by any viewed event', () => { const long = { ...reply, content: 'x'.repeat(5000), tags: [...reply.tags, ['emoji', 'wave', 'https://example.com/wave.png']] }; store.save({ root, replies: [parent, long] }, long.id); const restored = createThreadStore(storage).find(long.id); assert.deepEqual(restored, { rootId: root.id, root, profiles: {}, counts: {}, replies: [long, parent], quotes: [] }); assert.equal(store.find(parent.id).root.id, root.id); }); check('a thread rooted in a poll (kind 1068) is kept too', () => { const poll = { ...note(40), kind: 1068, tags: [['option', '0', 'Yes!'], ['option', '1', 'No!']] }; const answer = note(41, [['e', poll.id, '', 'root']]); store.save({ root: poll, replies: [answer] }, poll.id); const restored = createThreadStore(storage).find(poll.id); assert.equal(restored?.root.kind, 1068); assert.deepEqual(restored.replies, [answer]); }); check('thread authors keep their names and local avatar thumbnails across reloads', () => { const thumb = 'data:image/webp;base64,AAAA'; const face = { name: 'Nostrich', picture: 'https://example.com/face.webp', eventAt: 50, thumbFor: 'https://example.com/face.webp', thumb, thumbPx: 144 }; store.save({ root, replies: [parent, reply] }, reply.id, { [root.pubkey]: face }); store.save({ root, replies: [parent, reply] }, reply.id); // a relay refresh must keep the face assert.deepEqual(createThreadStore(storage).find(reply.id).profiles[root.pubkey], { ...face, t: 0 }); }); check('thread action totals are present before relay refresh and survive a context save', () => { store.save({ root, replies: [parent, reply] }, reply.id, {}, { [reply.id]: { likes: 29, boosts: 7, sats: 210 } }); store.save({ root, replies: [parent, reply] }, reply.id); assert.deepEqual(createThreadStore(storage).find(reply.id).counts[reply.id], { likes: 29, boosts: 7, sats: 210 }); }); check('posts quoted inside the thread are restored with it, and a later save keeps them', () => { const q = note(77), poll = { ...note(78), kind: 1068 }; // a quote can be any kind store.save({ root, replies: [parent, reply] }, reply.id, {}, {}, [q, poll]); store.save({ root, replies: [parent, reply] }, reply.id); // a relay refresh with no quotes resolved yet assert.deepEqual(createThreadStore(storage).find(reply.id).quotes, [q, poll]); }); check('a large thread retains the selected reply and its ancestors', () => { const siblings = Array.from({ length: 200 }, (_, i) => note(1000 + i, [['e', root.id, '', 'root']])); store.save({ root, replies: [...siblings, parent, reply] }, reply.id); const saved = store.find(reply.id); assert(saved.replies.some((e) => e.id === parent.id)); assert(saved.replies.length < 160); }); check('the thread cache is bounded and recent conversations win', () => { for (let i = 10; i < 30; i++) store.save({ root: note(i), replies: [] }, id(i)); assert.equal(store.find(root.id), null); assert.equal(store.find(id(10)), null); assert(store.find(id(29))); }); check('corrupt or unavailable storage does not break opening a thread', () => { assert.equal(createThreadStore({ getItem: () => '{' }).find(root.id), null); assert.equal(createThreadStore({ getItem: () => JSON.stringify([{ root: { ...root, tags: null }, replies: [] }]) }).find(root.id), null); assert.doesNotThrow(() => createThreadStore({ getItem() { throw Error(); }, setItem() { throw Error(); } }).save({ root, replies: [] }, root.id)); }); // Expose the real loader only in this test bundle, as the browser tests do. const bundle = await Bun.build({ entrypoints: ['src/features/messages.js'], target: 'bun', plugins: [{ name: 'thread-cache-test', setup(build) { build.onLoad({ filter: /src\/features\/messages\.js$/ }, async ({ path }) => ({ loader: 'js', contents: (await Bun.file(path).text()) .replace("id: 'messages',", "id: 'messages', testThread: threadFor, testOpen: openNoteRef,") })); build.onLoad({ filter: /src\/nostr\.js$/ }, async ({ path }) => ({ loader: 'js', contents: (await Bun.file(path).text()) .replace('export async function queryOn(relays, filter, maxWait = 1500) {', 'export async function queryOn(relays, filter, maxWait = 1500) { return globalThis.__threadQuery(filter);') .replace(/export function subscribeOn\(([^)]*)\) \{/, 'export function subscribeOn($1) { return () => {};') })); } }] }); assert(bundle.success, bundle.logs.join('\n')); const path = '/tmp/coinos-thread-cache-test-' + process.pid + '.mjs'; await Bun.write(path, await bundle.outputs[0].text()); try { const { messagesFeature } = await import(path); let requests = [], waiting = []; globalThis.__threadQuery = (filter) => { // root lookups by id carry no kind (a root can be a poll); reply queries are kind 1 if (filter.ids || (filter.kinds?.includes(1) && filter['#e'])) { requests.push(filter); return new Promise((resolve) => waiting.push({ filter, resolve })); } return Promise.resolve([]); }; const makeFeature = () => { const ui = { screen: 'wallet' }; const feature = messagesFeature({ ui, wallet: { loaded: true, nostrRelays: () => [], loadFeatureState: (k, d) => d, saveFeatureState() {} }, h() {}, render() {}, toast() {}, hook() {} }); return { feature, ui }; }; // Use new IDs, so none of the storage-only tests can seed this lookup. const r = note(400), p = note(401, [['e', r.id, '', 'reply']]), e = note(402, [['e', p.id, '', 'reply']]); const first = makeFeature(); const loading = first.feature.testThread(e); assert.equal(loading.status, 'loading'); const wait = async (condition) => { for (let i = 0; i < 100; i++) { if (condition()) return; await Bun.sleep(5); } assert(condition(), 'expected loader progress'); }; await wait(() => waiting.length === 2); for (const q of waiting.splice(0)) q.resolve(q.filter.ids ? [p] : []); await wait(() => waiting.length === 2); for (const q of waiting.splice(0)) q.resolve(q.filter.ids ? [r] : []); await wait(() => loading.status === 'ready'); check('the loader persists the discovered root and intermediate ancestor', () => { const cached = store.find(e.id); assert.equal(cached.rootId, r.id); assert(cached.replies.some((x) => x.id === p.id)); assert(cached.replies.some((x) => x.id === e.id)); }); requests = []; const refreshed = makeFeature(); refreshed.ui.noteThread = { rootId: p.id, focusId: e.id, seed: e }; const restored = refreshed.feature.testThread(e); check('refresh returns the complete thread synchronously with no loading state', () => { assert.equal(restored.status, 'ready'); assert.equal(restored.root.id, r.id); assert(restored.replies.some((x) => x.id === p.id)); assert(restored.replies.some((x) => x.id === e.id)); assert.equal(refreshed.ui.noteThread.rootId, r.id); }); await wait(() => waiting.length === 1); check('background refresh asks for replies to the canonical root', () => { assert.equal(requests.length, 1); assert.deepEqual(requests[0]['#e'], [r.id]); }); for (const q of waiting.splice(0)) q.resolve([]); await Bun.sleep(20); check('an empty relay refresh preserves locally cached context', () => { assert.equal(restored.root.id, r.id); assert.equal(restored.replies.length, 2); }); for (const clickWhileLoading of [false, true]) { const warm = makeFeature(), seed = note(clickWhileLoading ? 501 : 500); const answer = note(clickWhileLoading ? 511 : 510, [['e', seed.id, '', 'root']]); requests = []; const prefetched = warm.feature.testThread(seed, true); await wait(() => waiting.length === 1); if (clickWhileLoading) assert.equal(warm.feature.testThread(seed), prefetched); for (const q of waiting.splice(0)) q.resolve([answer]); await prefetched.loading; if (!clickWhileLoading) assert.equal(store.find(seed.id), null, 'speculation does not evict persisted visits'); const opened = warm.feature.testThread(seed); assert.equal(opened, prefetched); assert.equal(opened.status, 'ready'); assert.equal(opened.replies[0].id, answer.id); assert.equal(requests.length, 1, 'click shares the existing relay query'); assert.equal(store.find(seed.id).replies[0].id, answer.id); } console.log('✓ clicks reuse completed and in-flight prefetches and persist visited threads'); const deepLink = makeFeature(); requests = []; await deepLink.feature.testOpen({ id: e.id }); check('a cached note reference opens immediately without an event lookup', () => { assert.equal(deepLink.ui.noteThread.seed.id, e.id); assert.equal(requests.length, 0); }); } finally { await rm(path, { force: true }); }