// Follows and the feed: the Follow button reflects the contact list, the feed // reads the people on it, and a brand-new wallet is told what to do instead // of being shown an empty box. // // Nothing here publishes to a relay — the contact list is seeded locally, the // way it would arrive from one. // // Run: bun tools/feed-follow-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 { npubEncode } from 'nostr-tools/nip19'; import { buildHtml } from '../build.js'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let ok = true; const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; const html = await buildHtml({ minify: true, pwa: false }); const server = Bun.serve({ port: 5237, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); const page = await browser.newPage(); const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); // the Feed is a tab in the bottom nav (it used to be offered on the messages home) const openFeed = () => page.evaluate(() => { const b = [...document.querySelectorAll('.app-nav-button')].find((e) => e.getAttribute('aria-label') === 'Feed'); if (b) b.click(); return !!b; }); const chip = (name) => page.evaluate((n) => { const e = [...document.querySelectorAll('.feed-chips > .feed-chip')].find((b) => b.textContent.trim() === n); if (e) e.click(); return !!e; }, name); const text = () => page.evaluate(() => document.body.innerText); const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await text()).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; const FOLLOWED = 'e'.repeat(63) + '1'; const OTHER = 'd'.repeat(63) + '2'; try { await page.setViewport({ width: 390, height: 844 }); await page.goto('http://localhost:5237/', { waitUntil: 'domcontentloaded' }); await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(500); await click('to import'); await sleep(400); await page.waitForSelector('textarea'); const mnemonic = generateMnemonic(wordlist); await page.type('textarea', mnemonic); await click('open wallet'); await waitText('receive', 20000); const base = cacheKeyFor(mnemonic + '\n'); check('the bottom nav has a Feed tab', await openFeed()); await sleep(1000); // a new wallet opens on Popular; Following is the people you follow check('...whose chips include Following', await chip('Following')); await sleep(1000); check('a wallet that follows nobody is told so', /don't follow anyone yet/i.test(await text())); check('...and offered somewhere to start', /find people/i.test(await text())); // a contact list, as a relay would hand it over await page.evaluate(([k, pk]) => { localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); }, [base, FOLLOWED]); await page.reload({ waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); await openFeed(); await sleep(1200); // (the feed you were on, Following, is remembered) check('Following no longer says you follow nobody', /\bFollowing\b/.test(await page.evaluate(() => document.querySelector('.feed-chips > .feed-chip.on')?.textContent || '')) && !/don't follow anyone yet/i.test(await text())); // and their own profile page knows we follow them await page.goto('http://localhost:5237/' + npubEncode(FOLLOWED), { waitUntil: 'domcontentloaded' }); await sleep(6000); const btns = await page.evaluate(() => [...document.querySelectorAll('button')].map((b) => b.textContent.trim())); check('their profile offers to unfollow, not to follow', btns.includes('Following') && !btns.includes('Follow'), btns.filter((b) => /follow/i.test(b)).join(',') || 'no follow button'); // Outbox: each author is read on the relays they publish to, not only ours. // Every socket the page opens is recorded, so the plan can be checked by // what it actually dialled. await page.evaluateOnNewDocument(() => { window.__dialled = []; const Real = window.WebSocket; window.WebSocket = function (url, ...rest) { window.__dialled.push(String(url)); return new Real(url, ...rest); }; window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real); }); await page.goto('http://localhost:5237/', { waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); await page.evaluate(([k, a, b]) => { localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', a], ['p', b]], c: '', at: Math.floor(Date.now() / 1000) })); // relay lists as NIP-65 would have given them localStorage.setItem(k + ':relayLists', JSON.stringify({ [a]: { r: ['wss://alice.example.invalid'], t: Date.now() }, [b]: { r: ['wss://bob.example.invalid'], t: Date.now() }, })); }, [base, FOLLOWED, OTHER]); await page.reload({ waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); await openFeed(); await sleep(6000); const dialled = await page.evaluate(() => window.__dialled || []); check('each author is read on their own write relay', dialled.some((u) => u.includes('alice.example.invalid')) && dialled.some((u) => u.includes('bob.example.invalid')), dialled.filter((u) => u.includes('example.invalid')).join(' ') || 'neither dialled'); check('...and our own relays are still asked as well', dialled.some((u) => u.includes('relay.coinos.io')), dialled.length + ' sockets'); // Covering every author once is not the same as reading them. People // publish to several relays and each holds a different slice — measured // over 250 real authors, seven of twenty-five sampled had posts living // only on their fourth or later write relay, and spending the whole socket // budget instead of stopping at first coverage found a quarter more posts. // So a lone author with six relays must be read on all six, not three. await page.evaluate(([k, a]) => { localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', a]], c: '', at: Math.floor(Date.now() / 1000) })); localStorage.setItem(k + ':relayLists', JSON.stringify({ [a]: { r: [1, 2, 3, 4, 5, 6].map((n) => 'wss://r' + n + '.example.invalid'), t: Date.now() }, })); }, [base, FOLLOWED]); await page.reload({ waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); await openFeed(); await sleep(6000); const wide = await page.evaluate(() => window.__dialled || []); const hit = [1, 2, 3, 4, 5, 6].filter((n) => wide.some((u) => u.includes('r' + n + '.example.invalid'))); check('an author who publishes to six relays is read on all six', hit.length === 6, 'reached ' + (hit.join(',') || 'none')); // The feed opens at ten posts and grows as you reach the bottom, however // many it is holding. (Tall posts: a page of short ones doesn't reach the // distance from the bottom where the next page is read.) await page.evaluate((k) => { const notes = []; const now = Math.floor(Date.now() / 1000); for (let i = 0; i < 60; i++) notes.push({ id: String(i).padStart(64, '0'), pubkey: 'f'.repeat(64), kind: 1, created_at: now - i * 60, content: 'post ' + i + ' ' + 'with enough words to stand tall '.repeat(30), tags: [] }); localStorage.setItem(k + ':feedNotes', JSON.stringify(notes)); }, base); await page.reload({ waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); await openFeed(); await sleep(1500); const first = await page.evaluate(() => document.querySelectorAll('.notes-feed > .row').length); check('the feed opens at ten posts, not sixty', first === 10, first + ' rows'); await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); await sleep(1200); const second = await page.evaluate(() => document.querySelectorAll('.notes-feed > .row').length); check('reaching the bottom shows ten more', second === 20, second + ' rows'); } finally { await browser.close(); server.stop(true); } console.log(ok ? '\n✅ follows and feed' : '\n❌ failed'); process.exit(ok ? 0 : 1);