diff options
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/avatar-thumb-test.js | 4 | ||||
| -rw-r--r-- | tools/feature-registry-check.js | 1 | ||||
| -rw-r--r-- | tools/feed-live-test.js | 57 | ||||
| -rw-r--r-- | tools/feed-stability-test.js | 191 | ||||
| -rw-r--r-- | tools/feed-window-test.js | 55 | ||||
| -rw-r--r-- | tools/thread-cache-test.js | 120 |
6 files changed, 407 insertions, 21 deletions
diff --git a/tools/avatar-thumb-test.js b/tools/avatar-thumb-test.js index 413a92b..f70f6b3 100644 --- a/tools/avatar-thumb-test.js +++ b/tools/avatar-thumb-test.js @@ -182,7 +182,7 @@ try { const i = document.querySelector('.header-avatar img.punk') || document.querySelector('img.punk'); return i ? i.getAttribute('src') : ''; }); - check('the default face is the small punk', /^punks-sm\//.test(punkSrc), punkSrc); + check('the default face is the small punk', /^\/punks-sm\//.test(punkSrc), punkSrc); // And someone we have never cached at all — the case that used to sit as a // blank circle until a relay answered — is drawn from their pubkey alone. @@ -195,7 +195,7 @@ try { const i = document.querySelector('.header-avatar img.punk') || document.querySelector('img.punk'); return i ? i.getAttribute('src') : ''; }); - check('an uncached face is drawn straight away, not left blank', /^punks-sm\//.test(strangerSrc), strangerSrc || 'blank circle'); + check('an uncached face is drawn straight away, not left blank', /^\/punks-sm\//.test(strangerSrc), strangerSrc || 'blank circle'); // A picture behind a redirect, loaded plainly first (as the avatar always // is) and only then thumbnailed. diff --git a/tools/feature-registry-check.js b/tools/feature-registry-check.js index 0931156..5d0adc3 100644 --- a/tools/feature-registry-check.js +++ b/tools/feature-registry-check.js @@ -14,6 +14,7 @@ import { featureIndexSource, enabledFeatures } from '../build.js'; const strip = (s) => s .replace(/\/\/[^\n]*/g, '') // line comments .replace(/\s+/g, ' ') // whitespace runs + .replace(/,\s*([}\]])/g, '$1') // optional trailing commas .replace(/, /g, ',').replace(/ ,/g, ',') .trim(); diff --git a/tools/feed-live-test.js b/tools/feed-live-test.js index c4807f1..7732045 100644 --- a/tools/feed-live-test.js +++ b/tools/feed-live-test.js @@ -6,8 +6,8 @@ // catches up by itself now. // // And a post that lands while you are reading halfway down must not shove the -// page under your thumb. It waits behind a pill that says how many, and the -// tap that shows them takes you up to them. +// page under your thumb. Prepared posts arrive above the viewport, and the +// notice takes you up to them when tapped. // // Run: bun tools/feed-live-test.js import puppeteer from 'puppeteer-core'; @@ -33,7 +33,9 @@ const html = await buildHtml({ minify: true, pwa: false }); // a picture that takes its time, so a post showing it must have waited const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64'); const server = Bun.serve({ port: 5296, fetch: async (req) => { - if (new URL(req.url).pathname === '/slow.png') { + const path = new URL(req.url).pathname; + if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path)); + if (path === '/slow.png') { await sleep(700); return new Response(PNG, { headers: { 'content-type': 'image/png', 'cache-control': 'no-store' } }); } @@ -102,6 +104,7 @@ try { await page.evaluate(([k, pk, ns]) => { localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Test Author', t: Date.now() } })); }, [base, AUTHOR, seeded]); await page.reload({ waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); @@ -126,8 +129,7 @@ try { const delivered = await page.evaluate((ev) => window.__inject(ev), fresh); check('...a post really arrives on a live subscription', delivered > 0, delivered + ' socket(s)'); // A post waits at the door for its author's face and its pictures (or the - // two-second deadline, for a face nobody has) before it goes in — so give - // it that long, and no longer. + // loading deadline) before it goes in. This author is cached locally. await sleep(2600); const after = await page.evaluate((text) => { const r = [...document.querySelectorAll('.notes-feed > .row')].find((n) => n.innerText.includes(text)); @@ -157,10 +159,13 @@ try { await sleep(900); check('...that reaching the top clears by itself', !(await page.$('.feed-new-pill'))); - console.log('\n[a post arrives whole, at the top]'); - // At the top of the feed a live post opens itself — no pill. It waits at - // the door for its picture first, and the first time the row exists its - // image is already decoded. A row used to paint first and fill in after. + console.log('\n[a post arrives whole, above the viewport]'); + // Move the first post to the viewport edge so a prepend is offscreen. + await page.evaluate(() => { + const first = document.querySelector('.notes-feed > .row'); + scrollBy(0, Math.ceil(first.getBoundingClientRect().top)); + }); + // The image is decoded before the row is admitted, with no height animation. await page.evaluate(() => { window.__firstSight = null; const feed = document.querySelector('.notes-feed'); @@ -180,11 +185,11 @@ try { }); const t0 = await page.evaluate(() => performance.now()); await page.evaluate((ev) => window.__inject(ev), signed('PICTURE POST http://localhost:5296/slow.png?' + Date.now(), 0)); - check('...sitting at the top', (await page.evaluate(() => window.scrollY)) < 120); + const heldPictureAnchor = await page.evaluate(() => { const r = document.querySelector('.notes-feed > .row'); return { id: r.dataset.key, top: r.getBoundingClientRect().top }; }); await page.waitForFunction(() => !!window.__firstSight, { timeout: 4000 }).catch(() => {}); - await sleep(120); // a hair in: the animation has been started and not finished + await sleep(120); const sight = await page.evaluate(() => window.__firstSight); - check('the post let itself in — no pill', !!sight && sight.img && !(await page.$('.feed-new-pill')), JSON.stringify(sight)); + check('the prepared post arrived above the reader', !!sight && sight.img && !!(await page.$('.feed-new-pill')), JSON.stringify(sight)); check('...having waited for the slow host', !!sight && sight.at - t0 >= 700, sight ? Math.round(sight.at - t0) + 'ms' : ''); const mid = await page.evaluate(() => { const row = [...document.querySelectorAll('.notes-feed > .row')].find((n) => /PICTURE POST/.test(n.innerText || '')); @@ -195,8 +200,12 @@ try { const row = [...document.querySelectorAll('.notes-feed > .row')].find((n) => /PICTURE POST/.test(n.innerText || '')); return row ? { anims: row.getAnimations().length, h: row.getBoundingClientRect().height, overflow: row.style.overflow } : null; }); - check('...and it opened rather than appeared', !!mid && !!settled && mid.anims > 0 && mid.h < settled.h + check('...without animating its height', !!mid && !!settled && mid.anims === 0 && mid.h === settled.h && settled.anims === 0 && settled.h > 0 && !settled.overflow, JSON.stringify({ mid, settled })); + check('...and the reading position is preserved', await page.evaluate(({ id, top }) => { + const row = document.querySelector('.notes-feed > [data-key="' + id + '"]'); + return row && Math.abs(row.getBoundingClientRect().top - top) <= 1; + }, heldPictureAnchor)); // decoded before the row is inserted; the fresh <img> node reports it a // task later, so the row's first frames are what is checked here check('...and its picture was already decoded when the row appeared', !!sight && (sight.ready || sight.readyMs <= 60), JSON.stringify(sight)); @@ -235,13 +244,16 @@ try { await page.evaluate(() => window.scrollTo(0, 900)); await sleep(500); const held = await anchor(); + // the notice keeps counting while the reader stays put: what matters is that it grew by three + const pillN = () => page.evaluate(() => parseInt((document.querySelector('.feed-new-pill')?.textContent || '0').replace(/\D/g, ''), 10) || 0); + const pillBefore = await pillN(); const r1 = await away(() => [signed('CATCH-UP 1', 0), signed('CATCH-UP 2', 1), signed('CATCH-UP 3', 2), signed('an old post that was not in the cache', 5000), signed('another old one', 5100)]); check('coming back re-asks the relays without being told', r1.reqsAfter > r1.reqsBefore, `${r1.reqsBefore} → ${r1.reqsAfter} kind-1 requests`); const a1 = await rowAt(held.text); const feedText = () => page.evaluate(() => document.querySelector('.notes-feed')?.innerText || ''); check('a few new posts go straight in', /CATCH-UP 1/.test(await feedText())); - check('...and the pill says how many are above you', /3 new posts/i.test(await page.evaluate(() => document.querySelector('.feed-new-pill')?.textContent || ''))); + check('...and the pill says how many more are above you', (await pillN()) === pillBefore + 3, pillBefore + ' → ' + (await pillN())); check('...and the post you were reading has not moved', !!held && !!a1 && a1.text === held.text && Math.abs(a1.top - held.top) <= 2, JSON.stringify({ held, now: a1 })); // they sort below everything cached: page down to them @@ -256,19 +268,26 @@ try { const wasTop = await anchor(); await away(() => [signed('AT-THE-TOP A', 0), signed('AT-THE-TOP B', 1)]); const a2 = await rowAt(wasTop.text); - check('the post you had at the top stays under your eyes', !!wasTop && !!a2 && a2.text === wasTop.text && Math.abs(a2.top - wasTop.top) <= 2 && a2.y > 0, + check('the header and first post stay in place', !!wasTop && !!a2 && a2.text === wasTop.text && Math.abs(a2.top - wasTop.top) <= 2 && a2.y === wasTop.y, JSON.stringify({ was: wasTop, now: a2 })); - check('...with the new ones above it, newest first, and a pill saying so', /AT-THE-TOP A/.test(await topRow()) && /2 new posts/i.test(await page.evaluate(() => document.querySelector('.feed-new-pill')?.textContent || ''))); + check('...new posts wait while the header is visible', !/AT-THE-TOP A/.test(await topRow())); + await page.evaluate(() => { + const first = document.querySelector('.notes-feed > .row'); + scrollBy(0, Math.ceil(first.getBoundingClientRect().top)); + }); + await sleep(600); + check('...then arrive offscreen above it, with a notice', /AT-THE-TOP A/.test(await topRow()) && /2 new posts/i.test(await page.evaluate(() => document.querySelector('.feed-new-pill')?.textContent || ''))); console.log('\n[coming back after a long time away]'); await page.evaluate(() => window.scrollTo(0, 900)); await sleep(400); + const heldLong = await anchor(); // all in the same second — a post per second back would reach past the // top of the page after the thirteen seconds the two waits above took await away(() => Array.from({ length: 25 }, (_, i) => signed('LONG AWAY ' + (i + 1), 0))); - const a3 = await anchor(); - check('many new posts: the feed goes to the top', !!a3 && a3.y < 120, JSON.stringify(a3)); - check('...at the newest post, with no pill', /LONG AWAY 1\b/.test(await topRow()) && !(await page.$('.feed-new-pill')), (await topRow()).slice(0, 40)); + const a3 = await rowAt(heldLong.text); + check('many new posts still preserve the reading position', !!a3 && Math.abs(a3.top - heldLong.top) <= 2, JSON.stringify({ heldLong, a3 })); + check('...with the newest posts above and a notice', /LONG AWAY 1\b/.test(await topRow()) && !!(await page.$('.feed-new-pill')), (await topRow()).slice(0, 40)); check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); } finally { await browser.close(); server.stop(true); } diff --git a/tools/feed-stability-test.js b/tools/feed-stability-test.js new file mode 100644 index 0000000..1fdc5ba --- /dev/null +++ b/tools/feed-stability-test.js @@ -0,0 +1,191 @@ +// Offline browser regression: cached rows must arrive whole and stay still. +// Run: bun tools/feed-stability-test.js +import puppeteer from 'puppeteer-core'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { noteEncode, npubEncode } from 'nostr-tools/nip19'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (name, condition, detail = '') => { + console.log(`${condition ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`); + if (!condition) ok = false; +}; +const sk = generateSecretKey(), pk = getPublicKey(sk); +const otherKey = generateSecretKey(), other = getPublicKey(otherKey); +const now = Math.floor(Date.now() / 1000); +const event = (content, at, key = sk, kind = 1) => finalizeEvent({ kind, content, created_at: at, tags: [] }, key); +const origin = 'http://localhost:5298'; +const quote = event('Quoted content ' + origin + '/quote.png', now - 2000, otherKey); +const profile = event(JSON.stringify({ name: 'Mentioned Person', picture: origin + '/quote-avatar.png' }), now, otherKey, 0); +const seeded = Array.from({ length: 45 }, (_, i) => event('Cached post ' + i + '\nEnough text to make each post a comfortable reading target.', now - 600 - i * 60)); +seeded[1] = event('With a quote nostr:' + noteEncode(quote.id) + ' and @' + npubEncode(other), now - 660); +seeded[3] = event('Unavailable picture ' + origin + '/late.png', now - 780); +seeded[8] = event('Five pictures ' + Array.from({ length: 5 }, (_, i) => origin + '/gallery' + i + '.png').join(' '), now - 1080); +seeded[24] = event('Next page picture ' + origin + '/next.png', now - 2040); +const requested = new Map(); +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5298, fetch: async (req) => { + const path = new URL(req.url).pathname; + if (path === '/') return new Response(html, { headers: { 'content-type': 'text/html' } }); + if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path)); + requested.set(path, (requested.get(path) || 0) + 1); + const delay = path === '/avatar.png' ? 3000 : path === '/gallery4.png' || path === '/next.png' ? 3500 : path === '/late.png' ? 10000 : 150; + await sleep(delay); + return new Response('<svg xmlns="http://www.w3.org/2000/svg" width="160" height="100"><rect width="160" height="100" fill="teal"/></svg>', { + headers: { 'content-type': 'image/svg+xml', 'cache-control': 'public, max-age=3600' }, + }); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (e) => errors.push(String(e))); +await page.setViewport({ width: 390, height: 844 }); +await page.evaluateOnNewDocument((fixtures) => { + const sockets = []; + class FakeSocket extends EventTarget { + constructor(url) { + super(); this.url = url; this.readyState = 0; this.subs = new Map(); sockets.push(this); + setTimeout(() => { this.readyState = 1; this.emit('open', new Event('open')); }, 0); + } + emit(type, ev) { this.dispatchEvent(ev); this['on' + type]?.(ev); } + message(data) { this.emit('message', new MessageEvent('message', { data: JSON.stringify(data) })); } + send(raw) { + let m; try { m = JSON.parse(raw); } catch { return; } + if (m[0] === 'CLOSE') { this.subs.delete(m[1]); return; } + if (m[0] !== 'REQ') return; + const [_, id, filter] = m; + this.subs.set(id, filter); + let events = []; + if (filter.kinds?.includes(0) && filter.authors?.includes(fixtures.profile.pubkey)) events = [fixtures.profile]; + if (filter.ids?.includes(fixtures.quote.id)) events = [fixtures.quote]; + if (filter.kinds?.includes(1) && !filter.since && !filter.until) events = window.__catchup || []; + setTimeout(() => { + if (!this.subs.has(id)) return; + for (const e of events) this.message(['EVENT', id, e]); + this.message(['EOSE', id]); + }, events.length ? 700 : 20); + } + close() { this.readyState = 3; this.emit('close', new Event('close')); } + } + Object.assign(FakeSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }); + window.WebSocket = FakeSocket; + window.__inject = (e) => { + let count = 0; + for (const ws of sockets) for (const [id, f] of ws.subs) { + if (ws.readyState === 1 && f.kinds?.includes(1) && f.since) { ws.message(['EVENT', id, e]); count++; } + } + return count; + }; +}, { profile, quote }); +const click = (text) => page.evaluate((text) => [...document.querySelectorAll('button')].find((e) => e.textContent.toLowerCase().includes(text))?.click(), text); +const row = (id) => `.notes-feed > [data-key="${id}"]`; +const snapshot = () => page.evaluate(() => [...document.querySelectorAll('.notes-feed > [data-zap-post]')].map((n) => { + const b = n.getBoundingClientRect(); + return { id: n.dataset.key, top: b.top, height: b.height, text: n.innerText, avatar: n.querySelector('.note-avatar')?.getAttribute('style') }; +})); +try { + await page.goto(origin); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload(); await sleep(300); + await click('create a new wallet'); await sleep(400); + await click('to import'); + await page.waitForSelector('textarea'); + const mnemonic = generateMnemonic(wordlist); + await page.type('textarea', mnemonic); + await click('open wallet'); + await page.waitForFunction(() => document.body.innerText.toLowerCase().includes('receive')); + const base = cacheKeyFor(mnemonic + '\n'); + await page.evaluate(([base, pk, seeded, origin]) => { + localStorage.setItem(base + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(base + ':feedNotes', JSON.stringify(seeded)); + localStorage.setItem(base + ':profiles', JSON.stringify({ [pk]: { name: 'Cached Author', picture: origin + '/avatar.png', t: Date.now() } })); + }, [base, pk, seeded, origin]); + await page.reload(); + await page.waitForFunction(() => document.body.innerText.toLowerCase().includes('receive')); + await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || ''))?.click()); + await page.waitForSelector('.item'); + await page.evaluate(() => [...document.querySelectorAll('.item')].find((e) => /feed/i.test(e.textContent))?.click()); + await sleep(1000); + check('cached rows wait for their slow avatar', !(await page.$('.notes-feed > .row'))); + await page.waitForSelector(row(seeded[0].id), { timeout: 15000 }); + check('quoted note and mention resolved before admission', await page.$eval(row(seeded[1].id), (n) => n.innerText.includes('Quoted content') && n.innerText.includes('@Mentioned Person'))); + check('all five gallery images decoded before admission', await page.$eval(row(seeded[8].id), (n) => n.querySelectorAll('.note-img').length === 5 && [...n.querySelectorAll('.note-img')].every((i) => i.complete && i.naturalWidth > 0))); + check('the next page was prefetched before scrolling', requested.has('/next.png')); + check('an unavailable image remains a usable link', await page.$eval(row(seeded[3].id), (n) => !n.querySelector('img.note-img') && !!n.querySelector('a[href$="/late.png"]'))); + const initial = await snapshot(); + await sleep(3000); + const settledRows = new Map((await snapshot()).map((r) => [r.id, r])); + check('late image responses do not change admitted rows', initial.every((r) => JSON.stringify(r) === JSON.stringify(settledRows.get(r.id)))); + + // With the header visible, a prepend must wait: anchoring only the first + // post would otherwise scroll the header offscreen. + const headerBefore = await page.evaluate(() => ({ y: scrollY, text: document.querySelector('.chat-page').innerText })); + const fresh = event('A new live post', now + 1); + check('a live subscription received the event', await page.evaluate((e) => window.__inject(e), fresh) > 0); + await sleep(1000); + check('live arrival leaves the visible header and posts untouched', await page.evaluate((before) => + scrollY === before.y && document.querySelector('.chat-page').innerText === before.text, headerBefore)); + check('new post waits until it can be inserted offscreen', !(await page.$(row(fresh.id)))); + + // Once the reader scrolls the header away, preserve every visible row on + // every animation frame, including the first frame of the prepend. + await page.evaluate(() => { + const first = document.querySelector('.notes-feed > [data-zap-post]'); + scrollBy(0, Math.ceil(first.getBoundingClientRect().top)); + window.__drift = []; + window.__held = [...document.querySelectorAll('.notes-feed > [data-zap-post]')].filter((r) => { + const b = r.getBoundingClientRect(); return b.bottom > 0 && b.top < innerHeight; + }).map((r) => [r, r.getBoundingClientRect().top]); + window.__sampling = true; + const frame = () => { + for (const [r, y] of window.__held) if (!r.isConnected || Math.abs(r.getBoundingClientRect().top - y) > 1) window.__drift.push([r.dataset.key, y, r.getBoundingClientRect().top]); + if (window.__sampling) requestAnimationFrame(frame); + }; requestAnimationFrame(frame); + }); + await page.waitForSelector(row(fresh.id)); + await sleep(500); + const drift = await page.evaluate(() => { window.__sampling = false; return window.__drift; }); + check('offscreen arrival never moves a visible row, even for one frame', !drift.length, JSON.stringify(drift.slice(0, 3))); + check('new rows do not animate their height', await page.$eval(row(fresh.id), (n) => !n.getAnimations().length)); + + const gap = event('Gap inside visible posts', now - 630); + await page.evaluate((e) => { + window.__catchup = [e]; + Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + }, gap); + await sleep(5200); + await page.evaluate(() => { + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + }); + await sleep(1500); + check('a gap fill does not insert between visible posts', !(await page.$(row(gap.id)))); + await page.evaluate(() => scrollTo(0, 2000)); + await page.waitForSelector(row(gap.id)); + check('the gap fill is admitted once offscreen', !!(await page.$(row(gap.id)))); + + await page.$eval(row(seeded[18].id), (n) => n.scrollIntoView()); + await page.waitForSelector(row(seeded[24].id), { timeout: 10000 }); + check('older posts reveal with decoded pictures', await page.$eval(row(seeded[24].id), (n) => { const i = n.querySelector('.note-img'); return !!i && i.complete && i.naturalWidth > 0; })); + + const before = (await snapshot()).find((r) => r.top + r.height > 0); + await page.evaluate((evs) => { + window.__catchup = evs; + Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + }, Array.from({ length: 25 }, (_, i) => event('Large catch-up ' + i, now + 30 + i))); + await sleep(5200); + await page.evaluate(() => { + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + }); + await page.waitForFunction(() => document.querySelector('.notes-feed')?.innerText.includes('Large catch-up 24'), { timeout: 15000 }); + const after = (await snapshot()).find((r) => r.id === before.id); + check('a large catch-up preserves the reading position', !!after && Math.abs(after.top - before.top) <= 1, JSON.stringify({ before: before.top, after: after?.top })); + check('no browser errors', !errors.length, errors.join(' | ')); +} finally { await browser.close(); server.stop(true); } +process.exit(ok ? 0 : 1); diff --git a/tools/feed-window-test.js b/tools/feed-window-test.js new file mode 100644 index 0000000..64e8364 --- /dev/null +++ b/tools/feed-window-test.js @@ -0,0 +1,55 @@ +// Geometry/state regressions that don't require browser or network access. +// Run: bun tools/feed-window-test.js +import assert from 'node:assert/strict'; +import { mergeFeedWindow } from '../src/feed-window.js'; +const note = (id, at) => ({ id, created_at: at }); +const notes = [note('a', 100), note('b', 90), note('c', 80), note('d', 70)]; +const rows = (top) => notes.map((e, i) => ({ id: e.id, top: top + i * 200, bottom: top + (i + 1) * 200 })); +const above = note('above', 110), gap = note('gap', 95), below = note('below', 60); +const check = (name, fn) => { fn(); console.log('✓ ' + name); }; +check('header-visible arrivals wait without changing the displayed window', () => { + const r = mergeFeedWindow(notes, 4, [above, gap], { rows: rows(180), height: 844 }); + assert.equal(r.notes, notes); + assert.equal(r.shown, 4); + assert.deepEqual(r.deferred, [above, gap]); +}); +check('offscreen prepends preserve every displayed post; visible gap fills wait', () => { + const r = mergeFeedWindow(notes, 4, [above, gap, below], { rows: rows(-50), height: 844 }); + assert.deepEqual(r.added, [above, below]); + assert.deepEqual(r.deferred, [gap]); + assert.deepEqual(r.notes.slice(0, r.shown).map((e) => e.id), ['above', 'a', 'b', 'c', 'd']); +}); +check('a deferred gap becomes available once it is above the viewport', () => { + const r = mergeFeedWindow(notes, 4, [gap], { rows: rows(-450), height: 844 }); + assert.deepEqual(r.deferred, []); + assert.deepEqual(r.notes.map((e) => e.id), ['a', 'gap', 'b', 'c', 'd']); +}); +check('hidden posts cannot allow an insertion between visible posts', () => { + const r = mergeFeedWindow([notes[0], note('hidden', 97), ...notes.slice(1)], 4, [note('gap', 98)], { rows: rows(-50), height: 844 }); + assert.equal(r.added.length, 0); + assert.equal(r.deferred.length, 1); +}); +check('equal timestamps retain their order and obey the visible-gap rule', () => { + const r = mergeFeedWindow(notes, 4, [note('equal', 100)], { rows: rows(-50), height: 844 }); + assert.equal(r.added.length, 0); + assert.equal(r.deferred[0].id, 'equal'); +}); +check('large catch-ups retain the reader at the cache boundary', () => { + const old = Array.from({ length: 200 }, (_, i) => note('old' + i, 1000 - i)); + const news = Array.from({ length: 80 }, (_, i) => note('new' + i, 2000 - i)); + const r = mergeFeedWindow(old, 200, news); + assert.equal(r.shown, 280); + assert.equal(r.notes.at(-1).id, 'old199'); +}); +check('older-page requests still advance beyond the cache boundary', () => { + const old = Array.from({ length: 200 }, (_, i) => note('old' + i, 1000 - i)); + const older = Array.from({ length: 40 }, (_, i) => note('older' + i, 500 - i)); + const r = mergeFeedWindow(old, 200, older); + assert.equal(r.notes.length, 220); + assert.equal(r.notes[200].id, 'older0'); +}); +check('duplicate arrivals cannot create repeated rows', () => { + const r = mergeFeedWindow(notes, 4, [notes[0], above, above]); + assert.equal(r.notes.length, 5); + assert.deepEqual(r.added, [above]); +}); 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 }); } |
