diff options
| author | Adam Soltys <[email protected]> | 2026-09-23 22:51:58 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-23 22:51:58 -0700 |
| commit | 75679fdd5d24d36e3c54316e88ccefcf954afc82 (patch) | |
| tree | c86a369455da3c2e9532d51d2e72898afda8fccc /tools | |
| parent | fb170b4783c9b49c73e926c08aba9bc4d1bb418e (diff) | |
| download | coinosv3-75679fdd5d24d36e3c54316e88ccefcf954afc82.tar.gz coinosv3-75679fdd5d24d36e3c54316e88ccefcf954afc82.zip | |
Feed: threads cached locally, posts never inserted under the reader, rows keep their first look.
Work by Codex, wrapped up and verified here.
Threads: a viewed conversation is persisted (thread-cache.js, up to 12
threads / 160 events), so a refresh or a cached note link shows the root
and ancestors at once instead of the selected reply and a spinner; relays
are re-asked in the background and an empty answer keeps the cache.
Feed window (feed-window.js): arrivals are merged without inserting
between visible rows — a gap fill under the reader's eyes waits until
that part of the feed is offscreen, prepends anchor the reading position,
the notice counts what went in above, and only its tap goes to the top.
Older pages are prepared a few screens ahead; a page's pictures and faces
are decoded before it is shown.
Rows keep the presentation they were admitted with (feedPaint): background
profile and quote refreshes no longer rewrite text or swap faces mid-read;
media dimensions are known before paint. Thumbnails queue instead of being
dropped past the first three.
Tests: thread-cache, feed-window, feed-stability; feed-live updated for
the new arrival rules (the notice counts up while you read).
Co-Authored-By: Claude Fable 5.1 <[email protected]>
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 }); } |
