// A profile's Posts / Replies tabs: Posts (the default) is what they posted, // Replies their answers in other people's threads. A tab with few of its // kind among the pages fetched so far asks for older pages by itself. // Run: bun tools/profile-replies-test.js import puppeteer from './puppeteer.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; 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 key = () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; }; const AUTHOR = key(), OTHER = key(); const now = Math.floor(Date.now() / 1000); const theirs = finalizeEvent({ kind: 1, created_at: now - 99999, tags: [], content: 'a post by someone else' }, OTHER.sk); const evs = []; // newest first: 25 replies with 3 posts among them, then 10 older posts let t = now - 60; for (let i = 0; i < 28; i++) { const post = i % 9 === 4; // three posts in the first page evs.push(finalizeEvent(post ? { kind: 1, created_at: t, tags: [], content: 'recent post number ' + i } : { kind: 1, created_at: t, tags: [['e', theirs.id, '', 'root'], ['p', OTHER.pk]], content: 'my reply number ' + i }, AUTHOR.sk)); t -= 60; } for (let i = 0; i < 10; i++) { evs.push(finalizeEvent({ kind: 1, created_at: t, tags: [], content: 'older post number ' + i }, AUTHOR.sk)); t -= 60; } const PROFILE = finalizeEvent({ kind: 0, created_at: now - 10, tags: [], content: JSON.stringify({ name: 'Replier' }) }, AUTHOR.sk); const html = await buildHtml({ minify: true, pwa: false }); const server = Bun.serve({ port: 0, 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(); await page.evaluateOnNewDocument(([pk, evs, prof]) => { window.__pages = 0; const Real = window.WebSocket; window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (data) => { try { const m = JSON.parse(data); if (m[0] === 'REQ') { let out = []; for (const f of m.slice(2)) { if (!(f.authors || []).includes(pk)) continue; if ((f.kinds || []).includes(0)) out.push(prof); if ((f.kinds || []).includes(1) && !f['#e'] && !f['#p']) { const page = evs.filter((e) => !f.until || e.created_at <= f.until).slice(0, f.limit || 30); if (f.until) window.__pages++; out.push(...page); } } setTimeout(() => { for (const e of out) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], e]) })); ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })); }, 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(data); }; return ws; }; window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real); }, [AUTHOR.pk, evs, PROFILE]); const errs = []; page.on('pageerror', (e) => errs.push(String(e))); const rows = () => page.evaluate(() => [...document.querySelectorAll('.notes-feed [data-zap-post]')].map((r) => r.innerText)); const waitFor = async (fn, ms = 15000) => { for (let i = 0; i < ms / 250; i++) { if (await page.evaluate(fn)) return true; await sleep(250); } return false; }; try { await page.setViewport({ width: 390, height: 844 }); await page.goto(server.url.href + npubEncode(AUTHOR.pk), { waitUntil: 'domcontentloaded' }); check('the profile opens on its Posts tab', await waitFor(() => document.querySelector('.prof-tabs button.active')?.dataset.tab === 'posts', 20000)); check('older pages are fetched until Posts has something to show', await waitFor(() => [...document.querySelectorAll('.notes-feed [data-zap-post]')].length >= 12, 15000), (await rows()).length + ' rows, ' + (await page.evaluate(() => window.__pages)) + ' older page(s)'); let r = await rows(); check('Posts holds only their posts', r.length > 0 && r.every((x) => /post number/.test(x) && !/my reply/.test(x)), r.length + ' rows'); check('...the three recent ones and the older ones', r.filter((x) => /recent post/.test(x)).length === 3 && r.some((x) => /older post/.test(x))); await page.evaluate(() => document.querySelector('.prof-tabs button[data-tab="replies"]').click()); await sleep(600); r = await rows(); check('Replies holds only their replies', r.length >= 12 && r.every((x) => /my reply/.test(x)), r.length + ' rows'); check('...each marked as a reply', r.every((x) => /reply/i.test(x.split('\n').slice(0, 3).join(' ')))); await page.evaluate(() => document.querySelector('.prof-tabs button[data-tab="posts"]').click()); await sleep(400); if (process.env.SHOT) { await page.evaluate(() => document.querySelector('.prof-tabs').scrollIntoView({ block: 'start' })); await page.evaluate(() => window.scrollBy(0, -80)); await sleep(300); await page.screenshot({ path: process.env.SHOT }); } check('back to Posts', (await rows()).every((x) => /post number/.test(x))); check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); } finally { await browser.close(); server.stop(true); } console.log(ok ? '\n✅ profiles split posts and replies' : '\n❌ failed'); process.exit(ok ? 0 : 1);