1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
|
// Conversations: a view of any feed (the chip beside Notes, Images, Videos
// and Articles) — the replies its people write, each under the post it
// answers (Amethyst's Conversations). Notes keeps only their posts; a
// profile's Replies tab shows the same context; the view persists across
// feeds and reloads like the other views.
// Run: bun tools/feed-conversations-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 SKA = generateSecretKey(), ALICE = getPublicKey(SKA), SKB = generateSecretKey(), BOB = getPublicKey(SKB);
const now = Math.floor(Date.now() / 1000);
const post = (content, ago, sk = SKA, tags = []) => finalizeEvent({ kind: 1, created_at: now - ago, tags, content }, sk);
const B1 = post('bob root here, a question for everyone', 400, SKB);
const A1 = post('alice root post about nothing', 300);
const MISSING = 'f'.repeat(64);
const R1 = post('alice answers bob right here', 200, SKA, [['e', B1.id, '', 'root'], ['p', BOB]]);
const R2 = post('alice replies to a post nobody has', 100, SKA, [['e', MISSING, '', 'root']]);
const html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 0, fetch: (req) => {
const path = new URL(req.url).pathname;
if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
return 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 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 rowIds = () => page.evaluate(() => [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')));
const contextOf = (id) => page.evaluate((id) => {
const r = document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"]');
const c = r && r.querySelector('.note-context');
return c ? { of: c.getAttribute('data-context-of'), text: c.innerText, card: !!c.querySelector('.quote-card.clickable') } : null;
}, id);
try {
await page.setViewport({ width: 390, height: 844, hasTouch: true });
await page.goto(server.url.href, { 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, alice, bob, roots, replies]) => {
localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', alice], ['p', bob]], c: '', at: Math.floor(Date.now() / 1000) }));
localStorage.setItem(k + ':feedNotes', JSON.stringify(roots));
localStorage.setItem(k + ':feedNotes:following~replies', JSON.stringify(replies));
localStorage.setItem(k + ':profiles', JSON.stringify({ [alice]: { name: 'Alice Replier', about: 'alice bio here', t: Date.now() }, [bob]: { name: 'Bob Asker', t: Date.now() } }));
localStorage.setItem('btc-wallet-feed:' + k, 'following');
}, [base, ALICE, BOB, [A1, B1], [R1, R2]]);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
check('Following paints their posts', await waitFor(() => document.querySelectorAll('.notes-feed .note-post[data-zap-post]').length >= 2, null, 20000));
const following = await rowIds();
check('…only posts: no replies in Following', following.includes(A1.id) && following.includes(B1.id) && !following.includes(R1.id) && !following.includes(R2.id), following.map((x) => x.slice(0, 6)).join(','));
const modes = () => page.$$eval('.media-mode', (l) => l.map((e) => e.getAttribute('data-mode') + '=' + e.textContent.trim() + (e.classList.contains('on') ? '*' : '')).join(' '));
check('Notes and Conversations lead the view chips', await modes() === 'all=Notes* replies=Conversations images=Images videos=Videos articles=Articles', await modes());
check('no Conversations feed chip of its own', !(await page.$$eval('.feed-chips > .feed-chip', (l) => l.some((e) => /Conversations/.test(e.textContent)))));
console.log('\n[Conversations]');
await page.evaluate(() => document.querySelector('.media-mode[data-mode="replies"]').click());
check('the replies paint', await waitFor(() => document.querySelectorAll('.notes-feed .note-post[data-zap-post]').length >= 2, null, 20000));
const convo = await rowIds();
check('…only replies, newest first', JSON.stringify(convo) === JSON.stringify([R2.id, R1.id]), convo.map((x) => x.slice(0, 6)).join(','));
check('the view chip is on, the feed chip still Following', /replies=Conversations\*/.test(await modes()) && await page.$eval('.feed-chip[data-key="chip:following"]', (e) => e.classList.contains('on')));
const c1 = await contextOf(R1.id);
check('a reply sits under the post it answers (in hand from Following, no relay asked)', !!c1 && c1.of === B1.id && c1.card && /Replying to/.test(c1.text) && /Bob Asker/.test(c1.text) && /bob root here/.test(c1.text), JSON.stringify(c1));
check('…and the header no longer repeats "reply"', !(await page.$eval('.notes-feed .note-post[data-zap-post="' + R1.id + '"] .note-header', (e) => /\breply\b/i.test(e.innerText))));
check('a parent nobody has: the card says so', await waitFor((id) => { const c = document.querySelector('.note-post[data-zap-post="' + id + '"] .note-context'); return !!c && /find that note/i.test(c.innerText); }, R2.id, 8000), (await contextOf(R2.id) || {}).text);
// the reply's own tap (its text, not the card above it): its thread, at the reply
await page.evaluate((id) => document.querySelector('.note-post[data-zap-post="' + id + '"] > .note-content > .note-text').click(), R1.id);
check('tapping the reply opens the thread at the reply', await waitFor((id) => !!document.querySelector('[data-focus-note="1"][data-zap-post="' + id + '"]'), R1.id, 8000));
check('…with Bob\'s post as its root', await waitFor((id) => document.querySelector('.thread-card [data-zap-post]')?.getAttribute('data-zap-post') === id && !document.querySelector('.feed-chips'), B1.id, 4000));
await page.goBack(); await sleep(800);
check('Back lands on the Conversations view again', await waitFor(() => document.querySelector('.media-mode[data-mode="replies"]')?.classList.contains('on') && !!document.querySelector('.note-post[data-zap-post]'), null, 6000));
// the context card's tap: the parent's own thread
await page.evaluate((id) => document.querySelector('.note-post[data-zap-post="' + id + '"] .note-context .quote-card').click(), R1.id);
check('tapping the post it answers opens that post, at the top', await waitFor((id) => document.querySelector('.thread-card [data-zap-post]')?.getAttribute('data-zap-post') === id && !document.querySelector('[data-focus-note]') && !document.querySelector('.feed-chips'), B1.id, 8000));
await page.goBack(); await sleep(800);
await waitFor(() => !!document.querySelector('.feed-chips'), null, 6000);
console.log('\n[a profile\'s Replies tab]');
await page.evaluate((id) => document.querySelector('.note-post[data-zap-post="' + id + '"] .note-avatar').click(), R1.id);
check('Alice\'s profile opens', await waitText('alice bio here', 8000));
await page.evaluate(() => document.querySelector('.prof-tabs [data-tab="replies"]').click());
check('her Replies tab lists the reply', await waitFor((id) => !!document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"]'), R1.id, 8000));
const pc = await contextOf(R1.id);
check('…under the post it answers', !!pc && pc.of === B1.id && /bob root here/.test(pc.text), JSON.stringify(pc));
await page.evaluate(() => document.querySelector('.prof-tabs [data-tab="posts"]').click());
await waitFor((id) => !!document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"]'), A1.id, 8000);
check('her Posts tab shows no context block', (await page.$$('.notes-feed .note-context')).length === 0);
await page.goBack(); await sleep(800);
await waitFor(() => !!document.querySelector('.feed-chips'), null, 6000);
console.log('\n[the view on another feed]');
await page.evaluate(() => document.querySelector('.feed-chip.add').click());
check('the editor has no replies toggle (the view is the chip)', await waitFor(() => !!document.querySelector('.feed-interests-toggle'), null, 4000) && !(await page.$('.feed-replies-toggle')));
await page.evaluate(() => {
const name = document.querySelector('.chat-page input[type=text]'); name.value = 'Alice talks'; name.dispatchEvent(new Event('input', { bubbles: true }));
const follows = [...document.querySelectorAll('label')].find((l) => /Everyone I follow/.test(l.textContent)).querySelector('input'); follows.click();
});
await click('save'); await sleep(600);
check('saved as a chip', await waitFor(() => [...document.querySelectorAll('.feed-chip')].some((e) => e.textContent.trim() === 'Alice talks' && e.classList.contains('on')), null, 6000));
check('…opened in the Conversations view, the replies borrowed from the feeds in hand', /replies=Conversations\*/.test(await modes()) && await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 2 && got.includes(ids[0]) && got.includes(ids[1]); }, [R1.id, R2.id], 8000), (await rowIds()).map((x) => x.slice(0, 6)).join(','));
check('…each with its context', (await contextOf(R1.id) || {}).of === B1.id);
await page.evaluate(() => document.querySelector('.media-mode[data-mode="all"]').click());
check('Notes: the same feed\'s posts', await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 2 && got.includes(ids[0]) && got.includes(ids[1]); }, [A1.id, B1.id], 8000), (await rowIds()).map((x) => x.slice(0, 6)).join(','));
check('…with no context blocks', (await page.$$('.notes-feed .note-context')).length === 0);
await page.evaluate(() => document.querySelector('.feed-chip[data-key="chip:following"]').click());
check('Following in Notes is posts only', await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.includes(ids[0]) && !got.includes(ids[1]); }, [A1.id, R1.id], 8000));
await page.evaluate(() => document.querySelector('.media-mode[data-mode="replies"]').click());
check('…and its Conversations view is back at once', await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 2 && got[0] === ids[0]; }, [R2.id], 8000));
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
check('a reload opens on the view last chosen, from its own cache', await waitFor((ids) => document.querySelector('.media-mode[data-mode="replies"]')?.classList.contains('on') && [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')).includes(ids[0]), [R1.id], 20000));
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✅ conversations view: the replies they write, under what they answer' : '\n❌ failed');
process.exit(ok ? 0 : 1);
|