// Offline browser regression: cached rows must arrive whole and stay still. // Run: bun tools/feed-stability-test.js import puppeteer from './puppeteer.js'; 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[12] = event('Next page picture ' + origin + '/next.png', now - 1320); 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('', { 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; (window.__feedRequests ||= []).push(filter); 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}"]`; // the Feed is a tab in the bottom nav (it was a row on the chat home) const feedTab = () => [...document.querySelectorAll('.app-nav-button')].find((b) => b.getAttribute('aria-label') === 'Feed'); 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() } })); localStorage.setItem('btc-wallet-feed:' + base, 'following'); // a wallet with no feed remembered opens on Popular }, [base, pk, seeded, origin]); await page.reload(); await page.waitForFunction(() => document.body.innerText.toLowerCase().includes('receive')); await sleep(2000); check('a small feed query starts before opening the feed', await page.evaluate(() => !document.querySelector('.notes-feed') && (window.__feedRequests || []).some((f) => f.kinds?.includes(1) && !f.kinds.some((k) => k !== 1 && k !== 1068) && f.limit === 10))); await page.evaluate((tab) => eval(tab)()?.click(), feedTab.toString()); await sleep(1000); // Cached posts paint at once — never a spinner over posts we hold — and // settle into their prepared presentation once faces and pictures are in. check('cached rows paint at once, before their slow avatar has loaded', !!(await page.$('.notes-feed[data-booting] > .row'))); await page.waitForSelector('.notes-feed:not([data-booting]) > .row', { timeout: 15000 }); await page.waitForSelector(row(seeded[0].id), { timeout: 15000 }); check('quoted note and mention resolved once settled', await page.$eval(row(seeded[1].id), (n) => n.innerText.includes('Quoted content') && n.innerText.includes('@Mentioned Person'))); check('all five gallery images decoded once settled', 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 new post goes in above the first post, which // is held where it is (the page grows upward; the header scrolls off by // what went in above it). const firstBefore = await page.evaluate(() => { const r = document.querySelector('.notes-feed > [data-zap-post]'); return { id: r.dataset.key, top: r.getBoundingClientRect().top }; }); 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 page.waitForSelector(row(fresh.id), { timeout: 5000 }).catch(() => {}); check('live arrival at the top holds the first post still', await page.evaluate((b) => { const r = document.querySelector('.notes-feed > [data-key="' + b.id + '"]'); return !!r && Math.abs(r.getBoundingClientRect().top - b.top) <= 1; }, firstBefore)); check('...and goes in above it', await page.evaluate(([f, id]) => { const a = document.querySelector('.notes-feed > [data-key="' + f + '"]'), b = document.querySelector('.notes-feed > [data-key="' + id + '"]'); return !!a && !!b && a.getBoundingClientRect().top < b.getBoundingClientRect().top; }, [fresh.id, firstBefore.id])); // Once the reader has scrolled the header away, preserve every visible row // on every animation frame, including the first frame of the prepend. await page.evaluate(() => { const first = [...document.querySelectorAll('.notes-feed > [data-zap-post]')][1]; scrollBy(0, Math.ceil(first.getBoundingClientRect().top)); }); await sleep(500); await page.evaluate(() => { 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); }); const fresh2 = event('Another live post', now + 2); check('a second live post is received', await page.evaluate((e) => window.__inject(e), fresh2) > 0); await page.waitForSelector(row(fresh2.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(fresh2.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[12].id), { timeout: 10000 }); check('older posts reveal with decoded pictures', await page.$eval(row(seeded[12].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')); }); // the new posts go in above the reader — inside the window's top spacer, // not the DOM — so what shows is the notice counting them try { await page.waitForFunction(() => parseInt((document.querySelector('.feed-new-pill')?.textContent || '0').replace(/\D/g, ''), 10) >= 25, { timeout: 15000 }); } catch (e) { console.log(' catch-up did not surface:', JSON.stringify(await page.evaluate(() => ({ pill: document.querySelector('.feed-new-pill')?.textContent || null, rows: document.querySelectorAll('.notes-feed > .row').length, h: document.documentElement.scrollHeight, y: scrollY, top: document.querySelector('[data-key="win-top"]')?.offsetHeight || 0, first: document.querySelector('.notes-feed > .row')?.innerText.slice(0, 40), catchupLeft: (window.__catchup || []).length, reqs: (window.__feedRequests || []).length }))), errors.slice(0, 3)); } 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 })); // Start on the wallet and let its small background warm-up finish. The // first feed tap must paint rows synchronously, without a loading frame. await page.evaluate(([base, notes]) => { localStorage.setItem(base + ':feedNotes', JSON.stringify(notes)); const nav = { ...history.state.nav, screen: 'wallet', tab: 'history', chatOpen: false, noteThread: null, profilePk: null, msgView: 'home' }; history.replaceState({ nav, i: 0 }, '', '/'); }, [base, seeded.filter((_, i) => ![1, 3, 8, 12].includes(i)).slice(0, 10)]); await page.reload(); await sleep(5500); await page.waitForSelector('.app-nav-button'); check('first opening after background preparation has rows and no spinner', await page.evaluate((tab) => { eval(tab)()?.click(); return !!document.querySelector('.notes-feed > .row') && !document.querySelector('.chat-page .spinner'); }, feedTab.toString())); check('no browser errors', !errors.length, errors.join(' | ')); } finally { await browser.close(); server.stop(true); } process.exit(ok ? 0 : 1);