aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/onboard-backup-test.js35
-rw-r--r--tools/relay-budget-test.js167
-rw-r--r--tools/resume-wake-test.js111
-rw-r--r--tools/thread-like-count-test.js91
-rw-r--r--tools/web-comment-test.js123
5 files changed, 527 insertions, 0 deletions
diff --git a/tools/onboard-backup-test.js b/tools/onboard-backup-test.js
index ddef8d7..3cb607c 100644
--- a/tools/onboard-backup-test.js
+++ b/tools/onboard-backup-test.js
@@ -93,6 +93,41 @@ try {
await page.close();
}
+ // Three: a wallet made elsewhere comes in by its twelve words, and that
+ // door has to be on the front page too — it used to be a line under the
+ // freshly minted words behind "Create a new wallet", found by chance.
+ console.log('\n[restoring from twelve words]');
+ {
+ const ctx = await browser.createBrowserContext();
+ const page = await ctx.newPage();
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5291/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText(page, 'sign in with nostr');
+ const door = await page.evaluate(() => {
+ const b = document.getElementById('onb-restore');
+ const c = document.getElementById('onb-create');
+ return b && c ? { label: b.textContent.trim(), below: b.getBoundingClientRect().top > c.getBoundingClientRect().top, visible: !!b.offsetParent } : null;
+ });
+ check('the front page has a door for an existing wallet', !!door && door.visible && /twelve words|seed/i.test(door.label), door ? door.label : 'missing');
+ check('...below the way in, with the other alternatives', !!door && door.below);
+ await page.evaluate(() => document.getElementById('onb-restore')?.click());
+ await sleep(600);
+ const pane = await page.evaluate(() => ({
+ title: /restore your wallet/i.test(document.body.innerText),
+ field: !!document.querySelector('textarea[placeholder*="seed words" i]'),
+ minted: document.querySelectorAll('.words .w .t').length,
+ }));
+ check('it opens straight onto the phrase field, no minted words', pane.title && pane.field && pane.minted === 0,
+ `${pane.title ? 'titled' : 'untitled'}, ${pane.field ? 'field' : 'no field'}, ${pane.minted} minted words`);
+ const phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
+ await page.type('textarea[placeholder*="seed words" i]', phrase);
+ await click(page, 'open wallet');
+ check('the typed phrase opens the wallet', await waitText(page, 'receive'));
+ await ctx.close();
+ }
+
console.log('\n[signing in, then writing it down]');
{
const { page, ctx, seed } = await signIn();
diff --git a/tools/relay-budget-test.js b/tools/relay-budget-test.js
new file mode 100644
index 0000000..bb1dd11
--- /dev/null
+++ b/tools/relay-budget-test.js
@@ -0,0 +1,167 @@
+// What a Following feed asks of a relay, and how. Measured from the laptop
+// (2026-11-02) an open of Following sent some 560 REQs in twenty seconds:
+// a relay list asked per quoted note's author (one REQ per author per
+// relay), the live subscriptions closed and reopened at the end of every
+// refresh pass, the follows' reports fetched afresh at every reload, and
+// every one-shot REQ held open on our fast relay until the slowest relay
+// in the ask had answered. damus answered the home IP "banned: too many
+// rate-limit violations". Now: relay lists go out in one REQ per batch,
+// unchanged live subscriptions stay up, the reports sync honours the saved
+// clock, each relay's REQ closes on its own EOSE, and no relay carries more
+// than ten one-shot REQs at once. Run: bun tools/relay-budget-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { neventEncode } 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 SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = () => Math.floor(Date.now() / 1000);
+const post = (sk, content, ago, tags = []) => finalizeEvent({ kind: 1, created_at: now() - ago, tags, content }, sk);
+// six people the follow quotes, each with one note on the relay
+const quoters = Array.from({ length: 6 }, () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; });
+const quotedNotes = quoters.map((q, i) => post(q.sk, `quoted thought ${i}`, 7200 + i));
+const stored = [...quotedNotes, post(SK, 'morning one', 3600), post(SK, 'morning two', 3000)];
+const quotePosts = quotedNotes.map((n, i) => post(SK, `look at this ${i} nostr:${neventEncode({ id: n.id, author: n.pubkey })}`, 1200 + i * 10, [['q', n.id, '', n.pubkey]]));
+stored.push(...quotePosts);
+// one quoted note only a far relay holds (the wide net's job)
+const farQ = generateSecretKey();
+const farNote = finalizeEvent({ kind: 1, created_at: now() - 9000, tags: [], content: 'kept only on the far relay' }, farQ);
+const farPost = post(SK, `and this nostr:${neventEncode({ id: farNote.id, author: farNote.pubkey })}`, 1100, [['q', farNote.id, '', farNote.pubkey]]);
+stored.push(farPost);
+const wideStore = [farNote];
+const cached = [...quotePosts, ...stored.filter((e) => e.pubkey === AUTHOR && !quotePosts.includes(e))].sort((a, b) => b.created_at - a.created_at);
+const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id)) && (!f.since || e.created_at >= f.since);
+
+// two relays on one server: /fast answers at once, /slow holds its EOSE
+const SLOW_MS = 3000;
+const log = []; // { t, relay, kind: 'REQ'|'CLOSE'|'EOSE', id, filters }
+const sockets = new Set();
+let maxOpen = { fast: 0, slow: 0 };
+const server = Bun.serve({ port: 0, fetch: (req, srv) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/fast' || path === '/slow' || path === '/wide') return srv.upgrade(req, { data: { relay: path.slice(1), subs: new Map() } }) ? undefined : new Response('ws', { status: 426 });
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+}, websocket: {
+ open(ws) { sockets.add(ws); },
+ message(ws, raw) {
+ let m; try { m = JSON.parse(raw); } catch { return; }
+ const relay = ws.data.relay;
+ if (m[0] === 'EVENT') { ws.send(JSON.stringify(['OK', m[1].id, true, ''])); return; }
+ if (m[0] === 'REQ') {
+ const filters = m.slice(2);
+ ws.data.subs.set(m[1], filters);
+ maxOpen[relay] = Math.max(maxOpen[relay], ws.data.subs.size);
+ log.push({ t: Date.now(), relay, kind: 'REQ', id: m[1], filters });
+ const answer = () => {
+ if (!ws.data.subs.has(m[1])) return;
+ for (const e of (relay === 'wide' ? wideStore : stored)) if (filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e]));
+ ws.send(JSON.stringify(['EOSE', m[1]]));
+ log.push({ t: Date.now(), relay, kind: 'EOSE', id: m[1] });
+ };
+ if (relay === 'slow') setTimeout(answer, SLOW_MS); else answer();
+ } else if (m[0] === 'CLOSE') { ws.data.subs.delete(m[1]); log.push({ t: Date.now(), relay, kind: 'CLOSE', id: m[1] }); }
+ },
+ close(ws) { sockets.delete(ws); },
+} });
+const PORT = server.port;
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.setViewport({ width: 390, height: 844 });
+await page.evaluateOnNewDocument((port) => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (url, p) {
+ if (/relay\.coinos\.io/.test(String(url))) url = `ws://localhost:${port}/fast`;
+ else if (/relay\.damus\.io/.test(String(url))) url = `ws://localhost:${port}/slow`;
+ else if (/nostr\.wine/.test(String(url))) url = `ws://localhost:${port}/wide`;
+ return p ? new Real(url, p) : new Real(url);
+ };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+}, PORT);
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+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);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+const reqs = (relay, pred) => log.filter((x) => x.kind === 'REQ' && x.relay === relay && pred(x.filters));
+const isLive = (fs) => fs.some((f) => f.authors && f.since && !f.limit && f.kinds && f.kinds.includes(1));
+// a subscription meant to stay up: since with no limit, or limit 0 (the DM inbox)
+const isSub = (fs) => fs.some((f) => (f.since && !f.limit) || f.limit === 0);
+
+try {
+ await page.goto(`http://localhost:${PORT}/`, { 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(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ 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: 'Early Bird', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, cached]);
+ log.length = 0; maxOpen = { fast: 0, slow: 0 };
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ const t0 = Date.now();
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the quote posts are on screen', await waitFor(() => document.querySelectorAll('.notes-feed > .row[data-key]').length >= 6, null, 20000));
+ await sleep(9000); // the refresh pass, the quotes' lookups, the reports sync
+
+ console.log('\n[relay lists for the quoted notes\' authors]');
+ const qpks = new Set(quoters.map((q) => q.pk));
+ const listAsks = reqs('fast', (fs) => fs.some((f) => f.kinds && f.kinds.length === 1 && f.kinds[0] === 10002 && (f.authors || []).some((a) => qpks.has(a))));
+ const covered = new Set(listAsks.flatMap((x) => x.filters.flatMap((f) => f.authors || [])).filter((a) => qpks.has(a)));
+ check('asked for in one REQ, not one per author', listAsks.length === 1 && covered.size === 6, listAsks.length + ' REQ(s) covering ' + covered.size + ' of 6 authors');
+
+ console.log('\n[quoted notes]');
+ const wideIds = new Set(reqs('wide', (fs) => fs.some((f) => f.ids)).flatMap((x) => x.filters.flatMap((f) => f.ids || [])));
+ const nearHeld = quotedNotes.filter((n) => wideIds.has(n.id)).length;
+ check('the ones our relay holds were never asked of the wide net', nearHeld === 0, nearHeld + ' of 6 asked on the far relay');
+ check('the one only the far relay holds was found there', wideIds.has(farNote.id) && (await page.evaluate(() => document.body.innerText.includes('kept only on the far relay'))), wideIds.has(farNote.id) ? 'asked' : 'never asked');
+
+ console.log('\n[the live subscriptions]');
+ const live = reqs('fast', isLive);
+ const liveClosed = live.filter((x) => log.some((y) => y.kind === 'CLOSE' && y.relay === 'fast' && y.id === x.id));
+ check('opened once and kept across the refresh', live.length === 1 && liveClosed.length === 0, live.length + ' live REQs, ' + liveClosed.length + ' closed');
+
+ console.log('\n[one-shot REQs]');
+ const fastReqs = reqs('fast', (fs) => !isSub(fs));
+ const lag = [];
+ for (const r of fastReqs) {
+ const eose = log.find((y) => y.kind === 'EOSE' && y.relay === 'fast' && y.id === r.id);
+ const close = log.find((y) => y.kind === 'CLOSE' && y.relay === 'fast' && y.id === r.id && y.t >= r.t);
+ if (eose && close) { lag.push(close.t - eose.t); if (close.t - eose.t > 1500) console.log(' late close ' + (close.t - eose.t) + ' ms: ' + JSON.stringify(r.filters).slice(0, 160)); }
+ }
+ const slowLag = lag.filter((x) => x > 1500).length;
+ check('closed on the fast relay\'s own EOSE, not the slow relay\'s', lag.length >= 10 && slowLag === 0, lag.length + ' closed, ' + slowLag + ' later than 1.5 s (slowest ' + Math.max(0, ...lag) + ' ms; the slow relay holds EOSE ' + SLOW_MS + ' ms)');
+ const slowReqs = reqs('slow', () => true).length;
+ check('no more than ten one-shot REQs open on a relay at once', maxOpen.slow <= 10 + 5, slowReqs + ' REQs to the slow relay, at most ' + maxOpen.slow + ' open at once (fast: ' + maxOpen.fast + ')');
+ const totalFast = reqs('fast', () => true).length;
+ console.log(' ' + totalFast + ' REQs to the fast relay in ' + ((Date.now() - t0) / 1000).toFixed(0) + ' s');
+
+ console.log('\n[a reload within the half hour]');
+ const reportsBefore = reqs('fast', (fs) => fs.some((f) => f.kinds && f.kinds[0] === 1984)).length;
+ log.length = 0;
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ await sleep(6000);
+ const reportsAfter = reqs('fast', (fs) => fs.some((f) => f.kinds && f.kinds[0] === 1984)).length;
+ check('the follows\' reports are not fetched again', reportsBefore >= 1 && reportsAfter === 0, reportsBefore + ' REQ(s) on the first open, ' + reportsAfter + ' after the reload');
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ the feed asks the relays for less, and asks it in fewer REQs' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/resume-wake-test.js b/tools/resume-wake-test.js
new file mode 100644
index 0000000..3b992fb
--- /dev/null
+++ b/tools/resume-wake-test.js
@@ -0,0 +1,111 @@
+// Back from the background on a phone, the app's relay sockets read OPEN
+// and are dead (the tab was frozen; the network forgot them), and the
+// catch-up it sends on them finds nothing. The library noticed a dead one
+// only after its 29 s ping + 20 s timeout, then waited 10 s to redial — a
+// Following feed stuck at the morning's posts for a minute or more after
+// opening the app in the evening ("no new notes since 11:35?", Adam,
+// 2026-11-01). On resume every open socket is now asked one tiny REQ; a
+// silent one is closed and redialed at once, its subscriptions refired, and
+// the feed refresh runs on the live sockets. Run: bun tools/resume-wake-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+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 SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = () => Math.floor(Date.now() / 1000);
+const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now() - ago, tags: [], content }, SK);
+const stored = [post('morning one', 3600), post('morning two', 3000), post('morning three', 2400)];
+const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id)) && (!f.since || e.created_at >= f.since);
+// one relay; a socket marked dead keeps its connection and answers nothing,
+// the way a phone's frozen sockets do once the network has forgotten them
+const sockets = new Set(); let opened = 0, closed = 0;
+const server = Bun.serve({ port: 0, fetch: (req, srv) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/relay') return srv.upgrade(req) ? undefined : new Response('ws', { status: 426 });
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+}, websocket: {
+ open(ws) { ws.data = { subs: new Map(), dead: false }; sockets.add(ws); opened++; },
+ message(ws, raw) {
+ if (ws.data.dead) return;
+ let m; try { m = JSON.parse(raw); } catch { return; }
+ if (m[0] === 'EVENT') { ws.send(JSON.stringify(['OK', m[1].id, true, ''])); return; }
+ if (m[0] === 'REQ') {
+ const s = { id: m[1], filters: m.slice(2) };
+ ws.data.subs.set(m[1], s);
+ for (const e of stored) if (s.filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e]));
+ ws.send(JSON.stringify(['EOSE', m[1]]));
+ } else if (m[0] === 'CLOSE') ws.data.subs.delete(m[1]);
+ },
+ close(ws) { sockets.delete(ws); closed++; },
+} });
+const PORT = server.port;
+const liveSubs = () => [...sockets].filter((s) => !s.data.dead).flatMap((s) => [...s.data.subs.values()]);
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.setViewport({ width: 390, height: 844 });
+await page.evaluateOnNewDocument((port) => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (url, p) { if (/relay\.coinos\.io/.test(String(url))) url = `ws://localhost:${port}/relay`; return p ? new Real(url, p) : new Real(url); };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+}, PORT);
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const warns = [];
+page.on('console', (m) => { if (/redialing|silent after resume/.test(m.text())) warns.push(m.text()); });
+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);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { const t0 = Date.now(); for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return (Date.now() - t0) / 1000; await sleep(100); } return null; };
+const rowTexts = () => page.evaluate(() => [...document.querySelectorAll('.notes-feed > .row[data-key]')].map((r) => r.textContent.replace(/\s+/g, ' ').trim().slice(0, 40)));
+
+try {
+ await page.goto(`http://localhost:${PORT}/`, { 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(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ 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: 'Early Bird', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, stored]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the morning\'s posts are in the feed', (await waitFor(() => document.querySelectorAll('.notes-feed > .row[data-key]').length >= 3, null, 20000)) != null, (await rowTexts()).join(' | '));
+ check('the feed watches the relay live', (await waitFor(() => true, null, 100), await (async () => { for (let i = 0; i < 100; i++) { if (liveSubs().some((s) => s.filters.some((f) => f.authors && f.since))) return true; await sleep(100); } return false; })()), liveSubs().length + ' subs on ' + sockets.size + ' sockets');
+ const openedBefore = opened;
+
+ console.log('\n[the phone sleeps: its sockets die quietly, a follow posts]');
+ for (const s of sockets) s.data.dead = true;
+ const fresh = post('evening news', 0);
+ stored.push(fresh);
+ await page.evaluate((k) => { try { localStorage.setItem('btc-wallet-bg-at', String(Date.now() - 10 * 60_000)); } catch {} }, base);
+ await sleep(300);
+
+ console.log('\n[back on screen]');
+ const t0 = Date.now();
+ await page.evaluate(() => window.dispatchEvent(new Event('focus')));
+ const redial = await (async () => { for (let i = 0; i < 150; i++) { if (opened > openedBefore) return (Date.now() - t0) / 1000; await sleep(100); } return null; })();
+ check('the dead socket is found out and redialed within seconds', redial != null && redial < 6, redial != null ? redial.toFixed(1) + 's (' + warns[0] + ')' : 'no new socket in 15 s');
+ const shown = await waitFor(() => { const p = document.querySelector('.feed-new-pill'); if (p) p.click(); return [...document.querySelectorAll('.notes-feed > .row[data-key]')].some((r) => r.textContent.includes('evening news')); }, null, 15000);
+ check('the post from while it slept is on screen within seconds', shown != null && (Date.now() - t0) / 1000 < 12, shown != null ? ((Date.now() - t0) / 1000).toFixed(1) + 's after resume' : (await rowTexts()).join(' | '));
+ check('the dead sockets were let go', closed >= 1, closed + ' closed');
+ check('…and the live subscriptions are back on the new socket', liveSubs().some((s) => s.filters.some((f) => f.authors && f.since)), liveSubs().length + ' subs');
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ back from the background, dead sockets are redialed at once and the feed catches up' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/thread-like-count-test.js b/tools/thread-like-count-test.js
new file mode 100644
index 0000000..6d81b3f
--- /dev/null
+++ b/tools/thread-like-count-test.js
@@ -0,0 +1,91 @@
+// The heart's number in a thread matches the people the who-panel lists.
+// A thread remembers its counts between visits; a reaction that arrived
+// through the feed's own count fetch before the thread was opened again
+// must raise the remembered number, not be outvoted by it.
+// Run: bun tools/thread-like-count-test.js
+import assert from 'node:assert/strict';
+import puppeteer from './puppeteer.js';
+import { generateSecretKey, finalizeEvent } from 'nostr-tools/pure';
+import { nip19 } from 'nostr-tools';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const sk = generateSecretKey(), now = Math.floor(Date.now() / 1000);
+const notes = Array.from({ length: 12 }, (_, i) => finalizeEvent({ kind: 1, created_at: now - 60 - i * 60, tags: [],
+ content: `Post number ${i}. The tide was out and the gulls had the whole beach to themselves.` }, sk));
+const target = notes[0];
+const react = (n) => finalizeEvent({ kind: 7, created_at: now - 30 + n, content: '+', tags: [['e', target.id], ['p', target.pubkey]] }, generateSecretKey());
+const reactions = [react(1), react(2)];
+const profile = finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Poster' }) }, sk);
+const html = await buildHtml({ minify: true, pwa: false });
+const snapshot = JSON.stringify({ version: 1, generatedAt: Date.now(), notes, profiles: [profile], popUntil: now - 3600 });
+const server = Bun.serve({ port: 0, fetch: (req) => new URL(req.url).pathname === '/api/feed'
+ ? new Response(snapshot, { headers: { 'content-type': 'application/json' } })
+ : new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage();
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(String(e)));
+ await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
+ await page.setRequestInterception(true);
+ page.on('request', (r) => r.url().startsWith(server.url.origin) ? r.continue() : r.abort());
+ // The relay serves as many of the two reactions as localStorage says
+ // (one on the first visit, both on the second).
+ await page.evaluateOnNewDocument(([notes, profile, reactions]) => {
+ class FakeSocket extends EventTarget {
+ constructor(url) { super(); this.url = url; this.readyState = 0; this.subs = new Set();
+ setTimeout(() => { this.readyState = 1; this.emit('open', new Event('open')); }, 0); }
+ emit(type, ev) { this.dispatchEvent(ev); this['on' + type]?.(ev); }
+ message(d) { this.emit('message', new MessageEvent('message', { data: JSON.stringify(d) })); }
+ 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, f] = m; this.subs.add(id);
+ const n = Number(localStorage.getItem('test-reactions') || 1);
+ let evs = [];
+ if (f.kinds?.includes(1) && !f.since) evs = notes.filter((e) => (!f.until || e.created_at <= f.until)
+ && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id))).slice(0, f.limit || 100);
+ if (f.kinds?.includes(0) && f.authors?.includes(profile.pubkey)) evs = [profile];
+ if (f['#e']) evs = f.kinds?.includes(7) ? reactions.slice(0, n).filter((r) => f['#e'].includes(r.tags[0][1])) : [];
+ if (f.ids) evs = notes.filter((e) => f.ids.includes(e.id));
+ setTimeout(() => { if (!this.subs.has(id)) return; for (const e of evs) this.message(['EVENT', id, e]); this.message(['EOSE', id]); }, 30);
+ }
+ close() { this.readyState = 3; this.emit('close', new Event('close')); }
+ }
+ Object.assign(FakeSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+ window.WebSocket = FakeSocket;
+ }, [notes, profile, reactions]);
+ const heartSel = (scope, id) => `${scope} .note-post[data-zap-post="${id}"] .note-acts > .note-act:nth-child(4) .note-act-n`;
+ const heart = (scope, id) => page.evaluate((s) => document.querySelector(s)?.textContent || '', heartSel(scope, id));
+ const openRow = (id) => page.evaluate((id) => document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"] > .note-content > .note-text').click(), id);
+ const visit = async (n, path = '/feed') => {
+ await page.goto(server.url.origin + '/feed', { waitUntil: 'domcontentloaded' });
+ await page.evaluate((n) => localStorage.setItem('test-reactions', String(n)), n);
+ await page.goto(server.url.origin + path, { waitUntil: 'domcontentloaded' });
+ };
+ // first visit: the thread itself, from a link (a notification, say), with
+ // one heart on the relay — the count answer lands while the thread is
+ // open, so the thread remembers it
+ await visit(1, '/' + nip19.neventEncode({ id: target.id }));
+ await page.waitForFunction((s) => document.querySelector(s)?.textContent === '1', { timeout: 15000 }, heartSel('.thread-page', target.id));
+ await sleep(1500); // the thread is persisted
+ // second visit: the feed, where the count fetch brings both hearts; the
+ // thread is then opened from the row
+ await visit(2);
+ await page.waitForFunction(() => document.querySelectorAll('.notes-feed > [data-zap-post]').length >= 5, { timeout: 20000 });
+ await page.waitForFunction((s) => document.querySelector(s)?.textContent === '2', { timeout: 10000 }, heartSel('.notes-feed', target.id));
+ await openRow(target.id);
+ await page.waitForFunction((s) => document.querySelector(s), { timeout: 10000 }, heartSel('.thread-page', target.id));
+ await sleep(800);
+ const n = await heart('.thread-page', target.id);
+ const who = await page.evaluate((id) => {
+ const row = document.querySelector('.thread-page .note-post[data-zap-post="' + id + '"]');
+ row.querySelector('.note-acts > .note-act:last-child').click();
+ return new Promise((r) => setTimeout(() => r(row.querySelectorAll('.note-who-person').length), 300));
+ }, target.id);
+ assert.equal(who, 2, 'the panel lists both people');
+ assert.equal(n, '2', `the heart in the thread says 2, not the remembered 1 (got "${n}")`);
+ assert.deepEqual(errors, []);
+ console.log('✓ the thread\'s heart count catches up with the people who reacted');
+} finally { await browser.close(); server.stop(true); }
diff --git a/tools/web-comment-test.js b/tools/web-comment-test.js
new file mode 100644
index 0000000..7a6258f
--- /dev/null
+++ b/tools/web-comment-test.js
@@ -0,0 +1,123 @@
+// Comments scoped to a web address (NIP-22 kind 1111 with an I tag:
+// DaddyTube's, on a film's IMDb page) belong with the person's replies.
+// Adam's own ("Charming and wholesome", 2026-11-02, on relay.primal.net)
+// showed on neither his profile's Replies tab — the profile page never
+// asked for kind 1111 — nor said what it was a comment on. Now the page
+// asks for comments, and a web-scoped comment carries the page it is on
+// where "Replying to" would go, named by its alt tag when it has one.
+// Also here: a report naming a post as 'other', with the author's bare p
+// tag beside it, counted as a report of the PERSON (es stayed 0 for an
+// 'other' e tag) — two such reports from follows folded the author's every
+// post. Run: bun tools/web-comment-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+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 keys = () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; };
+const B = keys(), C = keys(), D = keys(); // B comments; C and D report
+const now = () => Math.floor(Date.now() / 1000);
+const ev = (sk, kind, content, ago, tags = []) => finalizeEvent({ kind, created_at: now() - ago, tags, content }, sk);
+const IMDB = 'https://www.imdb.com/title/tt32565993/';
+const post = ev(B.sk, 1, 'a plain post about sheep', 3600);
+const comment = ev(B.sk, 1111, 'Charming and wholesome', 1800, [['I', IMDB], ['K', 'web'], ['i', IMDB], ['k', 'web']]);
+const named = ev(B.sk, 1111, 'The dog steals every scene', 1700, [['I', IMDB], ['K', 'web'], ['i', IMDB], ['k', 'web'], ['alt', 'Comment on Sheep Detectives (2025) in DaddyTube']]);
+const reports = [C, D].map((r) => ev(r.sk, 1984, '', 900, [['e', post.id, 'other'], ['p', B.pk]]));
+const stored = [post, comment, named, ...reports];
+const reqLog = [], sentLog = [];
+const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id)) && (!f.since || e.created_at >= f.since);
+const server = Bun.serve({ port: 0, fetch: (req, srv) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/relay') return srv.upgrade(req) ? undefined : new Response('ws', { status: 426 });
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+}, websocket: {
+ message(ws, raw) {
+ let m; try { m = JSON.parse(raw); } catch { return; }
+ if (m[0] === 'EVENT') { ws.send(JSON.stringify(['OK', m[1].id, true, ''])); return; }
+ if (m[0] === 'REQ') {
+ reqLog.push(m.slice(2));
+ for (const e of stored) if (m.slice(2).some((f) => match(f, e))) { sentLog.push(e.content.slice(0, 20)); ws.send(JSON.stringify(['EVENT', m[1], e])); }
+ ws.send(JSON.stringify(['EOSE', m[1]]));
+ }
+ },
+} });
+const PORT = server.port;
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.setViewport({ width: 390, height: 844 });
+await page.evaluateOnNewDocument((port) => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (url, p) { if (/relay\.coinos\.io/.test(String(url))) url = `ws://localhost:${port}/relay`; return p ? new Real(url, p) : new Real(url); };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+}, PORT);
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+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);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { const r = await page.evaluate(fn, arg); if (r) return r; await sleep(100); } return null; };
+const rowWith = (text) => page.evaluate((x) => { const r = [...document.querySelectorAll('.row[data-key]')].find((n) => n.textContent.includes(x)); return r ? { folded: !!r.querySelector('.note-folded') || r.classList.contains('note-folded'), scope: r.querySelector('.note-scope a') ? { href: r.querySelector('.note-scope a').href, text: r.querySelector('.note-scope a').textContent.trim() } : null, text: r.textContent.replace(/\s+/g, ' ').trim().slice(0, 120) } : null; }, text);
+
+try {
+ await page.goto(`http://localhost:${PORT}/`, { 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(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pks, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: pks.map((p) => ['p', p]), c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pks[0]]: { name: 'Shaun', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, [B.pk, C.pk, D.pk], [post]]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the post is in Following', !!(await waitFor((x) => [...document.querySelectorAll('.notes-feed > .row[data-key]')].some((r) => r.textContent.includes(x)), 'plain post', 20000)));
+ await sleep(5000); // the follows' reports arrive and the feed repaints
+ const plain = await rowWith('plain post');
+ check('two "other" reports of the post, each with the author\'s bare p tag, do not fold the author', plain && !plain.folded, plain ? plain.text : 'row gone');
+
+ console.log('\n[Conversations]');
+ await click('conversations');
+ const row = await waitFor((x) => { const r = [...document.querySelectorAll('.row[data-key]')].find((n) => n.textContent.includes(x)); return !!r; }, 'Charming and wholesome', 15000);
+ check('the web-scoped comment is listed', !!row);
+ if (process.env.DEBUG) {
+ console.log(' rows:', JSON.stringify(await page.evaluate(() => [...document.querySelectorAll('.row[data-key]')].map((r) => r.textContent.replace(/\s+/g, ' ').trim().slice(0, 50)))));
+ console.log(' chips:', JSON.stringify(await page.evaluate(() => [...document.querySelectorAll('.media-mode')].map((b) => b.textContent + (b.classList.contains('on') ? '*' : '')))));
+ console.log(' REQs with 1111:', JSON.stringify(reqLog.filter((fs) => fs.some((f) => (f.kinds || []).includes(1111))).map((fs) => fs.map((f) => ({ kinds: f.kinds, authors: (f.authors || []).length, limit: f.limit, since: !!f.since })))));
+ console.log(' sent:', JSON.stringify(sentLog));
+ }
+ const c1 = await rowWith('Charming and wholesome');
+ check('…under the page it is on, linked', !!c1?.scope && c1.scope.href === IMDB && c1.scope.text.includes('Comment on imdb.com/title/tt32565993'), c1 ? JSON.stringify(c1.scope) : '');
+ check('…not tagged as a plain reply', !!c1 && !/↩\s*reply/i.test(c1.text), c1 ? c1.text : '');
+ const c2 = await rowWith('dog steals');
+ check('a comment with an alt tag is labelled by it', !!c2?.scope && c2.scope.text.includes('Comment on Sheep Detectives (2025) in DaddyTube'), c2 ? JSON.stringify(c2.scope) : 'row missing');
+
+ console.log('\n[the profile\'s Replies tab]');
+ await page.evaluate((x) => { const r = [...document.querySelectorAll('.row[data-key]')].find((n) => n.textContent.includes(x)); r.querySelector('.note-avatar').click(); }, 'Charming and wholesome');
+ check('the profile opens', await waitText('replies', 10000));
+ await click('replies');
+ const onProfile = await waitFor((x) => { const r = [...document.querySelectorAll('.row[data-key]')].find((n) => n.textContent.includes(x)); return !!r; }, 'Charming and wholesome', 12000);
+ check('the comment is on the Replies tab', !!onProfile);
+ const p1 = await rowWith('Charming and wholesome');
+ check('…with its page above it', !!p1?.scope && p1.scope.href === IMDB, p1 ? JSON.stringify(p1.scope) : '');
+ console.log('\n[the comment opened]');
+ await page.evaluate((x) => { const r = [...document.querySelectorAll('.row[data-key]')].find((n) => n.textContent.includes(x)); r.querySelector('.note-content').click(); }, 'Charming and wholesome');
+ const opened = await waitFor((id) => location.pathname.startsWith('/nevent1') && !!document.querySelector('.note-post[data-zap-post="' + id + '"] .note-scope a'), comment.id, 10000);
+ check('the thread opens on it, its page above it', !!opened);
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ web-scoped comments show with their page, and an "other" report stays off the author' : '\n❌ failed');
+process.exit(ok ? 0 : 1);