aboutsummaryrefslogtreecommitdiffstats
path: root/tools/thread-cache-test.js
diff options
context:
space:
mode:
Diffstat (limited to 'tools/thread-cache-test.js')
-rw-r--r--tools/thread-cache-test.js120
1 files changed, 120 insertions, 0 deletions
diff --git a/tools/thread-cache-test.js b/tools/thread-cache-test.js
new file mode 100644
index 0000000..fd3f5c8
--- /dev/null
+++ b/tools/thread-cache-test.js
@@ -0,0 +1,120 @@
+// 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, replies: [long, parent] });
+ assert.equal(store.find(parent.id).root.id, root.id);
+});
+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) => {
+ if (filter.kinds?.includes(1) && (filter.ids || 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);
+ });
+ 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 }); }